diff --git a/.cursor/skills/publish/SKILL.md b/.cursor/skills/publish/SKILL.md index 9b0039bca6..d5b4085e97 100644 --- a/.cursor/skills/publish/SKILL.md +++ b/.cursor/skills/publish/SKILL.md @@ -23,7 +23,7 @@ disable-model-invocation: true | `CHANGELOG_FILE` | `CHANGELOG.md` | 相对于仓库根目录的路径,文件不存在则跳过 | | `VERSION_FILE` | `pyproject.toml` | 包含版本号的文件 | | `VERSION_PATTERN` | `^\s*version\s*=\s*"[^"]+"` | 匹配版本行的正则表达式 | -| `README_FILE` | `README.md` | 含 shields.io 版本徽标的文件(badge 版本号同步升级) | +| `README_GLOB` | `README*.md` | 含 shields.io 版本徽标的 README(含多语言变体,如 `README.md`、`README_CN.md`);全部同步升级 | | `INIT_VERSION_FILE` | `src/octop/__init__.py` | 含运行时常量 `__version__` 的文件(缺失则跳过并提示) | | `TAG_PREFIX` | `v` | Git tag 前缀;工作流监听 `v*`,生成 `v0.1.14` 风格标签 | | `REMOTE` | `origin` | Git 远程仓库名 | @@ -175,27 +175,76 @@ git checkout -B {RELEASE_BRANCH_PREFIX}{version} {REMOTE}/{INTEGRATION_BRANCH} 如果 `## [Unreleased]` 标题不存在,在 `# Changelog` 标题行之后插入新条目(若无标题则插入到文件顶部)。 -**4b. 升级版本号(同步所有版本来源):** +**4b. 升级版本号(把旧号 X.Y.Z 全部换成当前号 A.B.C):** -发布版本号必须保持多文件一致。依次升级以下位置: +表示「当前发布版本」的钉死号必须一致,**不得只改 `pyproject.toml`**。 +把旧版本 `X.Y.Z` 换成目标版本 `A.B.C`。缺文件则提示并继续(不中止)。 + +必改清单(按顺序,用 Edit 工具精确替换,勿用会误伤 CHANGELOG 历史条目的全局 replace): 1. `VERSION_FILE`(`pyproject.toml`)— wheel / PyPI 的唯一版本源: ```bash grep -n '^\s*version\s*=\s*"[^"]+"' pyproject.toml - # 用 Edit 工具将该行的 "X.Y.Z" 替换为 "A.B.C" + # "X.Y.Z" → "A.B.C" ``` -2. `README_FILE`(`README.md`)— shields.io 版本徽标: +2. 所有匹配 `README_GLOB` 且含 shields.io 版本徽标的 README(含多语言): ```bash - grep -n 'shields.io/badge/version-' README.md - # 用 Edit 工具将 `version-X.Y.Z-orange` 替换为 `version-A.B.C-orange` + grep -l 'shields.io/badge/version-' README.md README_*.md 2>/dev/null + grep -n 'shields.io/badge/version-' {file} + # `version-X.Y.Z-orange` → `version-A.B.C-orange` ``` - 文件不存在则跳过并提示(不中止)。 + 当前仓库至少包括 `README.md` 与 `README_CN.md`。全部未命中则提示并继续。 3. `INIT_VERSION_FILE`(`src/octop/__init__.py`)— 运行时常量 `__version__`: ```bash grep -n '__version__' src/octop/__init__.py - # 用 Edit 工具将 `__version__ = "X.Y.Z"` 替换为 `"A.B.C"` + # `__version__ = "X.Y.Z"` → `"A.B.C"` + ``` +4. 飞牛 FnOS 两个 manifest 的 `version=`(`scripts/build-fpk.sh` 打包时会再注入,但仓库源文件必须先改): + ```bash + grep -n '^version=' fnos/docker/manifest fnos/native/manifest + # `version=X.Y.Z` → `version=A.B.C`(两个文件都要改) + ``` +5. 飞牛 Docker compose 镜像标签(与本包版本相同,供 FPK 拉取 GHCR): + ```bash + grep -n 'ghcr.io/tencentcloud/octop:' fnos/docker/app/docker/docker-compose.yaml + # `ghcr.io/tencentcloud/octop:X.Y.Z` → `:A.B.C` + # 不得改成 `:latest`(测试禁止 latest) ``` - 文件不存在则跳过并提示(不中止)。 +6. `uv.lock` 里可编辑包 `octop` 的版本(漏改会导致 lock 与 pyproject 不一致): + ```bash + grep -n -A2 'name = "octop"' uv.lock | head -5 + # 将该包的 `version = "X.Y.Z"` 改为 `"A.B.C"` + # 或在改完 pyproject.toml 后执行 `uv lock`,只接受 octop 版本行变化 + ``` + +**扫尾(必做):** 全库搜索旧号,把漏网的「当前版本钉死」一并改掉: + +```bash +git grep -n --fixed-strings "X.Y.Z" +``` + +对每一处命中: + +| 类型 | 处理 | +|------|------| +| 当前版本钉死(compose / badge / manifest / lock / `__version__` / 文档里「当前版本」) | 换成 `A.B.C` | +| 测试里断言「等于当前发布号」的硬编码 | 改成读 `_pyproject_version()`(或同步为 `A.B.C`) | +| `CHANGELOG.md` 已发布章节(`## [X.Y.Z]` 及正文) | **保留**,不要改历史 | +| 测试夹具里拿旧号做比较(如 `1.0.2b4` vs `1.0.2b5`、解析示例) | **保留** | +| workflow / 注释里的示例旧号(如 `0.9.28`) | **保留**(不是当前钉死) | + +提交前再扫必改清单,必须已经没有旧号: + +```bash +git grep -n --fixed-strings "X.Y.Z" -- \ + pyproject.toml src/octop/__init__.py \ + README.md README_CN.md \ + fnos/docker/manifest fnos/native/manifest \ + fnos/docker/app/docker/docker-compose.yaml \ + uv.lock +``` + +任一文件仍命中旧号:**补改后再提交**,不得带着半套版本号推 release。 **4c. 提交:** @@ -317,4 +366,6 @@ git checkout {original_branch} - 发版后删除 `release/*`;`main → develop` 由 `sync-main-to-develop.yml` 自动同步(失败时再手动补) - 中止前展示完整错误输出 - 插入新版本条目后保持 `[Unreleased]` 为空 +- 把旧版本号 `X.Y.Z` 换成 `A.B.C`:`pyproject.toml`、`__version__`、多语言 README 徽标、`fnos/*/manifest`、`fnos/docker/app/docker/docker-compose.yaml` 镜像标签、`uv.lock` 的 octop 包版本;提交前 `git grep` 必改清单确认无旧号 +- 勿改 CHANGELOG 历史章节、测试夹具里的旧版本比较、workflow 示例号 - 推送 tag 后提示用户关注 GitHub Actions 的发布结果 diff --git a/.env.example b/.env.example index ca11d6f210..78fcd6d729 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,11 @@ OCTOP_BIND_HOST=0.0.0.0 # Docker first-run bootstrap (only used when ~/.octop/octop.db is absent) # Password must be ≥8 chars with letters and digits (same policy as change-password). -# Future: first boot may randomize this and write it to credential.txt instead. +# Set a valid password to create the admin via `octop init`. Leave empty (or set a +# password the policy rejects) to start the web setup wizard instead; the wizard +# unlock password is written to ~/.octop/octop-login.txt. OCTOP_ADMIN_USERNAME=admin -# First-run admin password; leave empty to auto-generate a random one (written to ~/.octop/credential.txt) +# First-run admin password; empty / rejected → setup wizard (not a silent random admin) OCTOP_DEFAULT_PASSWORD= OCTOP_ADMIN_DISPLAY_NAME=Admin @@ -37,7 +39,12 @@ OPENAI_API_KEY= DASHSCOPE_API_KEY= # Langfuse observability (optional) -OCTOP_LANGFUSE_ENABLED=false +# Octop's on/off switch is the admin setting in Dashboard → Settings → +# Observability (stored as the settings key `observability_langfuse_enabled`); +# there is no OCTOP_LANGFUSE_ENABLED variable. LANGFUSE_TRACING_ENABLED=false +# is the SDK-level kill switch, honoured by the langfuse client along with the +# public / secret key and base URL below. +LANGFUSE_TRACING_ENABLED=true # Optional login captcha (boot snapshot; restart after changing). # Default slider is dashboard-only. Strong providers need both keys. diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index dd7875222a..cc8241e4ff 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,12 +4,19 @@ name: Docker Publish # 文档与对外引用统一使用:ghcr.io/tencentcloud/octop # (Hub 仍用 DOCKERHUB_* secrets 同步推送。) # +# 架构:ubuntu-latest 打 linux/amd64,ubuntu-24.04-arm 打 linux/arm64, +# 再合并为 :{version} / :latest 多架构清单。飞牛 Docker 版 FPK 共用一份, +# NAS 按本机架构拉取对应镜像层。 +# # 触发路径: # 1. 人工推送 v* tag → on.push.tags # 2. Auto Tag 用 GITHUB_TOKEN 推 tag 后显式 workflow_dispatch(token push 不会触发 push 工作流) +# 3. 在任意分支 Run workflow,填 tag=vX.Y.Z(用该分支的 workflow 文件,checkout 指定 tag) # # 注意:workflow_dispatch 下 metadata-action 的 type=semver 不可靠, -# 必须从 GITHUB_REF_NAME 显式解析版本号。 +# 必须从 tag / 输入显式解析版本号。 +# 不要把 DOCKERHUB_USERNAME 放进 job outputs:跨 job 传递会被抹成空, +# 导致只推 GHCR,合并清单时再对空 Hub 名失败。 # # FnOS FPK 由 Release 成功后自动 dispatch,并等待本工作流推送的 :{version} 就绪。 # @@ -21,43 +28,84 @@ on: tags: - "v*" workflow_dispatch: + inputs: + tag: + description: "v* tag to publish (e.g. v1.0.2b6). Required when not running on a tag." + required: false + type: string permissions: contents: read packages: write jobs: - docker: - name: Build and push image + prepare: + name: Resolve version runs-on: ubuntu-latest - # 拒绝在非 v* ref 上误跑(例如 UI 里对 main 点 Run workflow) - if: startsWith(github.ref, 'refs/tags/v') + # 拒绝在非 v* ref 上误跑(例如 UI 里对 main 点 Run workflow 且未填 tag) + if: >- + startsWith(github.ref, 'refs/tags/v') + || (github.event_name == 'workflow_dispatch' && startsWith(inputs.tag, 'v')) + outputs: + version: ${{ steps.ver.outputs.version }} + tag: ${{ steps.ver.outputs.tag }} + ghcr: ${{ steps.image.outputs.ghcr }} steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Resolve version from tag ref id: ver + env: + DISPATCH_TAG: ${{ inputs.tag }} run: | set -euo pipefail - ref="${GITHUB_REF_NAME}" + if [ -n "${DISPATCH_TAG:-}" ]; then + ref="$DISPATCH_TAG" + else + ref="${GITHUB_REF_NAME}" + fi case "$ref" in v*) echo "version=${ref#v}" >> "$GITHUB_OUTPUT" echo "tag=$ref" >> "$GITHUB_OUTPUT" ;; *) - echo "::error::Expected refs/tags/v*, got ref_name=$ref" + echo "::error::Expected v* tag, got ref=$ref" exit 1 ;; esac + - name: Image names + id: image + run: | + echo "ghcr=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.arch }} + needs: prepare + runs-on: ${{ matrix.runner }} + env: + DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USERNAME }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + platform: linux/amd64 + arch: amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + arch: arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + fetch-depth: 0 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub + if: env.DOCKERHUB_USER != '' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -70,23 +118,16 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Image names - id: image - run: | - echo "ghcr=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - echo "hub=${{ secrets.DOCKERHUB_USERNAME }}/octop" >> "$GITHUB_OUTPUT" - - name: Docker metadata id: meta uses: docker/metadata-action@v5 with: images: | - ${{ steps.image.outputs.hub }} - ${{ steps.image.outputs.ghcr }} - # 显式 raw tag:兼容 push.tags 与 Auto Tag 的 workflow_dispatch + ${{ env.DOCKERHUB_USER != '' && format('{0}/octop', env.DOCKERHUB_USER) || '' }} + ${{ needs.prepare.outputs.ghcr }} + # 单架构中间 tag;:{version} / :latest 由 merge 合成多架构清单 tags: | - type=raw,value=${{ steps.ver.outputs.version }} - type=raw,value=latest + type=raw,value=${{ needs.prepare.outputs.version }}-${{ matrix.arch }} - name: Build and push uses: docker/build-push-action@v6 @@ -94,8 +135,70 @@ jobs: context: . file: ./docker/Dockerfile push: true - platforms: linux/amd64 + platforms: ${{ matrix.platform }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=docker-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }} + + merge: + name: Multi-arch manifest + needs: [prepare, build] + # ARM runner 偶发失败时仍发布已有架构,避免卡住 :{version} 与 Docker FPK。 + if: always() && needs.prepare.result == 'success' && !cancelled() + runs-on: ubuntu-latest + env: + DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USERNAME }} + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: env.DOCKERHUB_USER != '' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push multi-arch manifests + env: + VER: ${{ needs.prepare.outputs.version }} + GHCR: ${{ needs.prepare.outputs.ghcr }} + HUB: ${{ env.DOCKERHUB_USER != '' && format('{0}/octop', env.DOCKERHUB_USER) || '' }} + run: | + set -euo pipefail + any_multi=0 + for REG in "$GHCR" "$HUB"; do + [ -n "$REG" ] || continue + sources=() + for arch in amd64 arm64; do + if docker buildx imagetools inspect "${REG}:${VER}-${arch}" >/dev/null 2>&1; then + sources+=("${REG}:${VER}-${arch}") + else + echo "::warning::missing ${REG}:${VER}-${arch}" + fi + done + if [ "${#sources[@]}" -eq 0 ]; then + echo "::error::No platform images published for ${REG}:${VER}" + exit 1 + fi + if [ "${#sources[@]}" -ge 2 ]; then + any_multi=1 + fi + docker buildx imagetools create \ + -t "${REG}:${VER}" \ + -t "${REG}:latest" \ + "${sources[@]}" + echo "=== ${REG}:${VER} ===" + docker buildx imagetools inspect "${REG}:${VER}" + done + if [ "$any_multi" -eq 0 ]; then + echo "::warning:::${VER} is not multi-arch (ARM image missing). Docker FPK on ARM NAS will not run." + fi diff --git a/.github/workflows/fnos-build-fpk.yml b/.github/workflows/fnos-build-fpk.yml index 20aaf880df..e3523ab463 100644 --- a/.github/workflows/fnos-build-fpk.yml +++ b/.github/workflows/fnos-build-fpk.yml @@ -11,11 +11,15 @@ name: Build Octop FPK # ghcr.io/tencentcloud/octop:{version|latest} # - Native wheel:优先从同版本 GitHub Release(v*)下载; # 缺失时再回退到本仓源码构建 +# - fnpack:fpk / native 用 Actions cache 复用 .verify/fnpack(key 含 FNPACK_VERSION) # - 产物挂到同一个 v* GitHub Release(与 wheel / 桌面包并列),不再单独打 fnos-* tag # # Jobs: -# version → ensure-image → fpk(Docker 版 .fpk,仅打包 compose) -# ↘ native(本地版 .fpk)→ release(把 .fpk 挂到已有的 v* GitHub Release) +# version → ensure-image → fpk(Docker 版 .fpk,仅打包 compose;镜像已是 amd64+arm64) +# ↘ native(x86_64 本地版 .fpk) +# ↘ native-arm64(ubuntu-24.04-arm 装 aarch64 site-packages) +# → native-arm64-pack(amd64 上用 fnpack 打 Octop-fnos-native-arm64-*.fpk) +# → release(把 .fpk 挂到已有的 v* GitHub Release) on: workflow_dispatch: @@ -24,6 +28,10 @@ permissions: contents: write packages: read +# Keep in sync with scripts/build-fpk.sh default FNPACK_VERSION. +env: + FNPACK_VERSION: "1.2.3" + concurrency: group: build-octop-fnos cancel-in-progress: false @@ -87,13 +95,14 @@ jobs: set -euo pipefail TAG="${IMAGE}:${VER}" echo "Waiting for ${TAG} (published by docker-publish)..." - for i in $(seq 1 90); do + # 多架构清单要等 amd64 + arm64 都推完再 merge,比单架构更久。 + for i in $(seq 1 120); do if docker buildx imagetools inspect "$TAG" >/dev/null 2>&1; then echo "Image ready: $TAG" - docker buildx imagetools inspect "$TAG" | head -20 + docker buildx imagetools inspect "$TAG" | head -40 exit 0 fi - echo "[$i/90] not ready yet..." + echo "[$i/120] not ready yet..." sleep 20 done echo "::error::Timed out waiting for ${TAG}. Ensure Docker Publish finished for v${VER}." @@ -108,6 +117,12 @@ jobs: fetch-depth: 0 fetch-tags: true + - name: Cache fnpack CLI + uses: actions/cache@v4 + with: + path: .verify/fnpack + key: fnpack-${{ env.FNPACK_VERSION }}-linux-amd64 + - name: Build Docker FPK env: FPK_NAME_PREFIX: Octop-fnos @@ -121,8 +136,7 @@ jobs: if-no-files-found: error native: - # 即使原生版构建失败也不阻塞整体发布:Docker 版(fpk)始终会发布。 - continue-on-error: true + # 失败时 workflow 变红;release 仍因 if: always() 会挂上已有的 Docker .fpk。 needs: version runs-on: ubuntu-latest outputs: @@ -133,6 +147,12 @@ jobs: fetch-depth: 0 fetch-tags: true + - name: Cache fnpack CLI + uses: actions/cache@v4 + with: + path: .verify/fnpack + key: fnpack-${{ env.FNPACK_VERSION }}-linux-amd64 + - name: Try download wheel from GitHub Release id: wheel env: @@ -188,6 +208,7 @@ jobs: env: UV_PYTHON_INSTALL_DIR: ${{ github.workspace }}/.uv-python run: | + set -euo pipefail set -x LOG="${{ github.workspace }}/native-install.log" : > "$LOG" @@ -207,8 +228,12 @@ jobs: python3.12 -m zipfile -e "$whl" "$SP" done cp dist/octop-*.whl "$SP/octop.whl" + mkdir -p "$SP/octop_harness/backends" + cp "${{ github.workspace }}/scripts/fnos/vendor/storage_errors.py" \ + "$SP/octop_harness/backends/storage_errors.py" + bash "${{ github.workspace }}/scripts/fnos/slim-site-packages.sh" "$SP" PYTHONPATH="$SP" python3.12 \ - -c "import octop, mcp, langchain_mcp_adapters; from octop.launch import run_foreground_blocking; print('octop', octop.__version__, '| mcp', mcp.__version__, '| lcma', langchain_mcp_adapters.__version__)" + -c "import octop, mcp, langchain_mcp_adapters; from octop.launch import run_foreground_blocking; from octop_harness.backends.storage_errors import classify_storage_error; print('octop', octop.__version__, '| mcp', mcp.__name__, '| lcma', langchain_mcp_adapters.__name__, '| storage_errors', classify_storage_error.__name__)" } 2>&1 | tee -a "$LOG" - name: Build native FPK @@ -218,14 +243,14 @@ jobs: run: bash scripts/build-fpk.sh native - name: Show native fpk size - run: ls -lh dist/Octop-fnos-native-*.fpk + run: ls -lh dist/Octop-fnos-native-[0-9]*.fpk - name: Upload native fpk artifact id: upload_native uses: actions/upload-artifact@v4 with: name: native-fpk - path: dist/Octop-fnos-native-*.fpk + path: dist/Octop-fnos-native-[0-9]*.fpk if-no-files-found: error - name: Upload native install log (for debugging) @@ -236,10 +261,167 @@ jobs: path: native-install.log if-no-files-found: ignore + # 在 ARM runner 上安装 aarch64 扩展(.so),fnpack 只有 linux-amd64,打包放到下一 job。 + native-arm64: + needs: version + runs-on: ubuntu-24.04-arm + outputs: + site_packages_ok: ${{ steps.upload_sp.outcome == 'success' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Try download wheel from GitHub Release + id: wheel + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VER: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + mkdir -p dist + REL_TAG="v${VER}" + if gh release download "$REL_TAG" --repo "${{ github.repository }}" -p 'octop-*.whl' -D dist 2>/tmp/wheel-dl.err; then + ls -lh dist/octop-*.whl + echo "source=release" >> "$GITHUB_OUTPUT" + echo "Reusing wheel from GitHub Release ${REL_TAG}" + else + echo "Release wheel not available yet:" + cat /tmp/wheel-dl.err || true + echo "source=build" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Node.js 20 (frontend build) + if: steps.wheel.outputs.source == 'build' + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: dashboard/package-lock.json + + - name: Build Octop wheel from current source + if: steps.wheel.outputs.source == 'build' + run: | + set -e + echo "=== build frontend ===" + cd dashboard + npm ci --prefer-offline --no-audit + NODE_ENV=production NODE_OPTIONS="--max-old-space-size=4096" npm run build + cd .. + echo "=== frontend output ===" + ls -la src/octop/dashboard | head -20 + echo "=== build wheel ===" + python3 -m pip install --quiet build + python3 -m build --wheel + echo "=== built wheel ===" + ls -lh dist/octop-*.whl + + - name: Install Octop core into app/site-packages (aarch64) + id: install_core + timeout-minutes: 15 + env: + UV_PYTHON_INSTALL_DIR: ${{ github.workspace }}/.uv-python + run: | + set -euo pipefail + set -x + LOG="${{ github.workspace }}/native-arm64-install.log" + : > "$LOG" + mkdir -p "${{ github.workspace }}/fnos/native/app/site-packages" + SP="${{ github.workspace }}/fnos/native/app/site-packages" + { + uname -m + python3.12 -c "import platform; print(platform.machine())" + python3 -m pip install --quiet uv + # esdk-obs-python is sdist-only; --only-binary :all: fails the resolver. + python3 -m uv pip install --python python3.12 \ + --target "$SP" --no-cache \ + "$(ls dist/octop-*.whl | head -1)" + rm -rf "$SP/mcp" "$SP"/mcp-*.dist-info \ + "$SP/langchain_mcp_adapters" "$SP"/langchain_mcp_adapters-*.dist-info + mkdir -p /tmp/octop-pinned + python3.12 -m pip download --only-binary :all: --no-deps --no-cache-dir \ + -d /tmp/octop-pinned \ + "mcp==1.28.1" "langchain-mcp-adapters==0.3.0" + for whl in /tmp/octop-pinned/*.whl; do + echo "Extracting pinned wheel: $whl" + python3.12 -m zipfile -e "$whl" "$SP" + done + cp dist/octop-*.whl "$SP/octop.whl" + mkdir -p "$SP/octop_harness/backends" + cp "${{ github.workspace }}/scripts/fnos/vendor/storage_errors.py" \ + "$SP/octop_harness/backends/storage_errors.py" + bash "${{ github.workspace }}/scripts/fnos/slim-site-packages.sh" "$SP" + PYTHONPATH="$SP" python3.12 \ + -c "import octop, mcp, langchain_mcp_adapters; from octop.launch import run_foreground_blocking; from octop_harness.backends.storage_errors import classify_storage_error; print('octop', octop.__version__, '| mcp', mcp.__name__, '| lcma', langchain_mcp_adapters.__name__, '| storage_errors', classify_storage_error.__name__)" + } 2>&1 | tee -a "$LOG" + + - name: Upload aarch64 site-packages + id: upload_sp + uses: actions/upload-artifact@v4 + with: + name: native-arm64-site-packages + path: fnos/native/app/site-packages + if-no-files-found: error + + - name: Upload native arm64 install log (for debugging) + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-arm64-install-log + path: native-arm64-install.log + if-no-files-found: ignore + + native-arm64-pack: + needs: [version, native-arm64] + if: needs.native-arm64.outputs.site_packages_ok == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Cache fnpack CLI + uses: actions/cache@v4 + with: + path: .verify/fnpack + key: fnpack-${{ env.FNPACK_VERSION }}-linux-amd64 + + - name: Download aarch64 site-packages + uses: actions/download-artifact@v4 + with: + name: native-arm64-site-packages + path: fnos/native/app/site-packages + + - name: Build native ARM64 FPK + env: + FPK_NAME_PREFIX: Octop-fnos + FPK_ARCH: arm64 + run: | + set -euo pipefail + test -d fnos/native/app/site-packages/octop + bash scripts/build-fpk.sh native + + - name: Show native arm64 fpk size + run: ls -lh dist/Octop-fnos-native-arm64-*.fpk + + - name: Upload native arm64 fpk artifact + uses: actions/upload-artifact@v4 + with: + name: native-arm64-fpk + path: dist/Octop-fnos-native-arm64-*.fpk + if-no-files-found: error + release: - # 即使原生版失败(continue-on-error)也要发布 Docker 版。 + # native / native-arm64 失败时仍运行,把已经打好的 .fpk 挂到 Release。 if: always() - needs: [fpk, native, version] + needs: [fpk, native, native-arm64-pack, version] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -262,11 +444,20 @@ jobs: path: artifacts/native-fpk if-no-files-found: ignore + - name: Download native arm64 fpk artifact (best-effort) + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: native-arm64-fpk + path: artifacts/native-arm64-fpk + if-no-files-found: ignore + - name: Stage dist run: | mkdir -p dist cp artifacts/docker-fpk/*.fpk dist/ || true cp artifacts/native-fpk/*.fpk dist/ || true + cp artifacts/native-arm64-fpk/*.fpk dist/ || true ls -lh dist if ! ls dist/*.fpk >/dev/null 2>&1; then echo "::error::No .fpk files to attach." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 563a6c19f2..84c925d16c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release # 版本标签 v* → 测包、发 PyPI、建 GitHub Release,再级联: # - Sync Main Into Develop -# - Build Octop FPK(复用本 Release 的 wheel;等待 docker-publish 的 GHCR 镜像) +# - Build Octop FPK(复用本 Release 的 wheel;等待 docker-publish 的多架构 GHCR 镜像) # # 与 docker-publish.yml / octop-desktop.yml 并行(由 Auto Tag 同时 dispatch, # 或人工 push v* tag 同时触发)。桌面产物由 Desktop Package 挂到同一 GitHub Release。 diff --git a/.gitignore b/.gitignore index 02f773718d..80474654ba 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,16 @@ scripts/dev-local-link.sh .tools/ .tools-sdk/ +# Local fnpack CLI (build-fpk.sh) and FPK scratch dirs +.verify/fnpack +.verify/fnpack.exe +.verify/fnpack.partial +.verify/fnpack.exe.partial +.buildtmp.*/ + +# FnOS native FPK install target (CI / local pack scratch; do not vendor) +fnos/native/app/site-packages/ + # Local agent planning docs (not for upstream) docs/superpowers/ scripts/lh_image_install_octop_tencentos.sh diff --git a/AGENTS.md b/AGENTS.md index f057414637..c3a7157ae3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,8 +46,8 @@ No external queue. No required services beyond an LLM provider. | Web framework | FastAPI + uvicorn | | Async runtime | asyncio (no threads except `run_in_executor`) | | Database | SQLite via sync `sqlite3` (WAL) **or** PostgreSQL via `psycopg` / `psycopg_pool` | -| LLM runtime | `harness-agent` (LangGraph) at `/workspace/harness-agent` | -| Gateway | `harness-gateway` at `/workspace/harness-gateway` | +| LLM runtime | `octop-harness` (LangGraph) | +| Gateway | `octop-gateway` | | Frontend | React 18 + TypeScript + Vite | | Package manager | uv — always `uv run pytest`, never bare `pytest` | | API docs UI | Scalar (`scalar-fastapi`) at `/api/docs` | @@ -107,13 +107,15 @@ cli/ ──► launch.py ──► api/ + infra/ | Path | Owns | Typical importers | |------|------|-------------------| -| `infra/agents/` | Agent registry (`manager.py`), harness runtime, provider store (`providers/`), settings stores (`security/`, `acp_settings`, `langfuse`), MBTI personas, expert catalog (`experts/`) | `server.py`, `gateway/`, `api/routers/agents.py` | +| `infra/agents/` | Agent registry (`manager.py`), harness runtime, providers, settings (`settings/`), workspace paths (`workspace/`), threads helpers (`threads/`), security, persona/MBTI, experts, plugins, teams, memory | `server.py`, `gateway/`, `api/routers/agents.py` | | `infra/backend/` | Workspace storage adapter, resolver, remote probe (COS/S3/…) | `agents/`, `api/routers/workspace*.py` | | `infra/connectors/` | Connector catalog, OAuth, MCP gateway, credential crypto | `api/routers/connectors.py`, `internal_mcp.py`, `agents/manager.py` (MCP assembly) | | `infra/cron/` | Cron jobs, triggers, agent tool hooks | `server.py`, `api/routers/cron.py` | | `infra/db/` | `SqlitePool`, migrations, `RepoBundle` / `SharedServices` in `services.py` | all domain code needing persistence | | `infra/gateway/` | IM ingress (`processor.py`), threads, slash commands (`slash/`), bot setup (`bot_creators/`) | `server.py`, `api/routers/chat.py`, `channels.py` | +| `infra/history/` | Versioned message archive, trajectory, turn projection (`projection.py`) | `gateway/`, `api/routers/chat`, `cron/`, `agents/teams` | | `infra/setup/` | First-run wizard, system service install, TLS / Let's Encrypt | `server.py`, `launch.py`, `api/routers/setup.py`, `api/routers/tls.py` | +| `infra/skills/` | Skill packages, SkillHub HTTP client (`skillhub_market`, `skillhub_common`) | `api/routers/skills.py`, `agents/experts` | | `infra/users/` | Users, roles, password hashing, `UserManager` | `server.py`, `api/routers/auth.py`, `users.py` | | `infra/errors.py` | `OctopError`, `ErrorCode` — shared exception types | everywhere in `infra/` and `api/` | | `infra/metrics.py` | In-process counters (`METRICS`) | lazy-import inside hot paths | @@ -158,8 +160,9 @@ Only `launch.py` may import both `infra/server` and `api/app` in the same module | Layer | When | Examples | |-------|------|----------| -| **Offline** | Read/write local `~/.octop` SQLite only | `user *`, `provider *`, `cron` list/create/delete, `agent list/delete`, `chats` CRUD, `models` presets/list/active, `channel` CRUD, `admin`, `skills` enable/disable | +| **Offline** | Read/write local `~/.octop` SQLite only | `user *`, `provider *`, `cron` list/create/delete, `agent list/delete`, `chats` CRUD, `models` presets/list/active, `channel` CRUD, `admin`, `skills` enable/disable, `bridge` list/get | | **Embedded** | Needs harness/gateway runtime; boots in-process `OctopServer` | `chats send/repl`, `chats get` (history), `cron run-now`, `agent` create/start/stop/reload, `provider test`, `channel test`, `skills list`, `acp` | +| **Embedded (bridge)** | In-process `BridgeManager` (no full server); CLI cannot keep Bridge WS after exit | `bridge` probe/create/patch/delete/connect/disconnect/agents | | **External** | Talks to OS/daemon directly, no Octop HTTP | `models ollama-*`, channel QR bind (WeCom/WeChat), Feishu bot-creator subprocess | No `octop user login` — CLI trusts local filesystem access to `~/.octop`. Pin acting user with `octop config set-user` or root `--user`; pin agent with `octop agent use` or root `--agent`. If `octop run` is already running, config CLI writes take effect after server restart (cron, channels loaded at boot). @@ -204,7 +207,7 @@ make build-frontend # dashboard/ → src/octop/dashboard/ **Agent scope:** Most agent routes use `/api/agents/{agent_id}/…` in the URL. A few legacy endpoints (e.g. MBTI) still take `X-Octop-Agent-Id`. Always validate ownership: load the agent row → `_assert_agent_owner(row, user)` (or admin bypass). -**Agent workspace I/O:** All reads/writes of agent workspace **content files** go through `HarnessAgent.workspace` (`BackendWorkspace` from `harness-agent`). Do **not** use `agent.backend` directly, `resolve_harness_backend`, or `Path.write_text` / `read_text` on `~/.octop/agents//` for workspace content. Do **not** branch on backend type or `virtual_mode` in Octop — path rules live in `BackendWorkspace`. +**Agent workspace I/O:** All reads/writes of agent workspace **content files** go through `HarnessAgent.workspace` (`BackendWorkspace` from `octop-harness`). Do **not** use `agent.backend` directly, `resolve_harness_backend`, or `Path.write_text` / `read_text` on `~/.octop/agents//` for workspace content. Do **not** branch on backend type or `virtual_mode` in Octop — path rules live in `BackendWorkspace`. New agents additionally keep system-scoped files under `{workspace}/.octop/` (e.g. sessions/sqlite, skills/agents system trees, auth tokens under `.octop/auth`). `system_files_path` is internal and not user-configurable; the `.octop` directory is created on first harness workspace init. Legacy agents keep auth at `{workspace}/.octop-auth`. @@ -220,7 +223,7 @@ New agents additionally keep system-scoped files under `{workspace}/.octop/` (e. **Database:** SQLite and PostgreSQL share one schema. Add or change tables via a numbered pair `infra/db/migrations/00N_description.sql` **and** `00N_description.pg.sql`, then bump the -version assertion in `tests/unit/db/test_db_pool.py` (currently `v == 7`). Rebuilds that SQLite +version assertion in `tests/unit/db/test_db_pool.py` (currently `v == 20`). Rebuilds that SQLite cannot express as `ALTER` live in `infra/db/migrate.py` helpers and must stay idempotent. Unreleased schema work on `develop` **folds into the current unreleased `00N`**, not a new @@ -276,7 +279,7 @@ i18n/ **API errors:** `ErrorCode` values map to `errors.` in JSON. `OctopError.to_envelope(locale=…)` and the global exception handler localize `message`. Dashboard mirrors codes under `apiErrors.*` in `dashboard/src/locales/{en,zh}.json` — tests require backend and frontend keys to match. -**IM channels:** Tool names and hint lines must be localized in `stream_project.py` — pass localized `tool_name` and pre-formatted `tool_hint_text` on `MessageEvent.tool_start/end` (harness-gateway reads `tool_hint_text` when present). Do not rely on English `ChannelConstraints.tool_hint_template` alone. +**IM channels:** Tool names and hint lines must be localized in `stream_project.py` — pass localized `tool_name` and pre-formatted `tool_hint_text` on `MessageEvent.tool_start/end` (octop-gateway reads `tool_hint_text` when present). Do not rely on English `ChannelConstraints.tool_hint_template` alone. **Frontend split** @@ -352,11 +355,12 @@ Boundary rules are in [§5](#5-module-boundaries). Additionally: | Question | Location | |----------|----------| | How does auth work? | `api/deps.py`, `api/middleware/jwt_auth.py`, `api/routers/auth.py` | +| LDAP (directory) login | `infra/auth/ldap/`, `api/routers/auth_ldap.py`; config row is `sso_providers.kind='ldap'`; guide: `docs/ldap.md` | | Setup wizard (password file, tokens) | `infra/setup/`, `api/routers/setup.py` | | TLS / Let's Encrypt | `infra/setup/tls/`, `api/routers/tls.py` | | `octop run` boot sequence | `launch.py`, `cli/run_cmd.py` | | How is a message processed? | `infra/gateway/processor.py` → harness agent | -| How are agents started/stopped? | `infra/agents/manager.py`, `infra/agents/runtime.py` | +| How are agents started/stopped? | `infra/agents/manager.py` | | How does cron work? | `infra/cron/manager.py`, `infra/cron/job.py` | | What DB tables exist? | `infra/db/migrations/` + `infra/db/repos/` | | What env vars are supported? | `config.py` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 095811131c..d209e2e2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,122 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [语义化版本规范](https://semver.org/spec/v2.0.0.html)。 +## [1.0.2b7] - 2026-10-10 +- 暗色主题下专家 / 技能 / 子智能体 Markdown 编辑器白底:Monaco 未跟随应用主题渲染为亮色 `vs`;编辑器现按应用明暗模式切换主题(工作区文件编辑器同样改为读取应用主题,而非仅系统偏好)(Fixes #1355) + +### 变更 +- 运行轨迹弹框中 ASSISTANT 摘要显示所用模型 +- 编辑对端专家时模型、知识库、连接器走对端隧道;抽屉标题显示对端标识 +- 云端协同卡片标题去掉重复的对端用户名 +- 账号头像改为职业肖像;技能卡片使用具象图标 +- 个人设置去掉重复头像预览,身份信息改为一行,并收紧头像选择框下间距 +- 个人设置与修改密码改为右侧抽屉,保存/取消放在底部 +- 登录页去掉用户名提示;空状态与远程桌面/浏览器检查按钮更清晰 + +### 修复 +- 修复便携版程序内升级重复解析已有依赖导致超时的问题;随包提供 uv,独立启动脚本将升级安装到实际加载的 packages 目录。未带 uv 的旧包先下载应用 wheel,确认已有依赖满足新版约束后才离线安装,避免依赖不兼容时提前覆盖应用;下载和本地安装各有 90 秒预算,依赖不满足或安装后 CLI 校验失败时再完整安装依赖,跨镜像回退共用从首次完整安装开始的 900 秒计时窗口。升级校验读取实际加载的代码版本并忽略导入时的额外输出,避免 pip 遗留 dist-info 或包无法加载造成误判;同版本和旧版本在安装前拒绝(#1562)。 +- Ask / Plan 模式下输入栏「+」菜单的选择面板过矮(模型、知识库一次只看得见一条),改为按视口可用高度封顶(目标 400px,且不低于左侧菜单);右侧比左侧矮时上对齐、更高时下对齐向上长;连接器/技能/专家/子智能体数量角标与知识库一样跟在文字后;模型列表现在可搜索 +- 飞牛原生 start 被中途杀掉时立刻退出,不再继续就绪轮询 +- 飞牛 Docker / 本地版清单版本与 pyproject 对齐为 1.0.2b6 +- Docker 首次启动:未设置或不合格的 `OCTOP_DEFAULT_PASSWORD` 不再静默生成随机管理员,改为写入向导口令并走设置向导 +- 云端协同:心跳检测半开连接、禁止自连/重复对端、每用户连接上限;对端回合与隧道请求不再堵住收包循环 +- 自定义 OpenAI 兼容供应商默认关闭流式用量,避免华为 MindIE 等网关因 `stream_options` 返回 422 +- 桌面端下载走本机 Downloads,外链与连接器授权改用系统浏览器 +- Plan 模式把 `plans/*.md` 写回专家工作区,避免 POSIX 默认后端落到容器根目录 `/plans` +- 对话失败与工具错误写入可检索的服务端日志;聊天里可一键关闭实时 Token 后重试 +- 英文界面残留中文文案:浏览器 AI 助手对话消息、技能录制与回放提示、PWA 安装引导、MBTI 人格标签、侧边栏更新徽标 + +### 安全 + +- 安装向导续跑接口 `POST /api/setup/resume-wizard` 不再匿名可调用:需向导密码、有效向导令牌或管理员 JWT,并按 IP 限流;安装完成状态改由持久化标记 `setup_completed_at` 判定,取代按用户数推断的脆弱启发式(SEC-1) +- 工作区文件读写统一收敛到 `_safe_io_path()` 路径边界:HTTP 接口不再接受 `from_workspace` 参数,路径一律按工作区相对解析,仅显式 `file://` URL 可访问宿主绝对路径,且需通过下载白名单与新增的 Octop 控制面拒绝规则(`~/.octop` 永不可经工作区接口读写)(SEC-2) +- 角色变更与创建管理员账号需管理员权限;普通权限持有者此前可自行提权为 admin(SEC-3) +- 模型 / 语音提供商的 `api_key` 在列表与详情响应中脱敏(前 4 位 + 星号),明文密钥不再下发到前端(SEC-4) +- 浏览器 WebSocket / HTTP 路由、文件系统 `ensure-bwrap` / `ensure-docker`、连接器内部接口补齐此前缺失的 browser / admin 权限校验;`write_doc` 改为仅所有者可写(SEC-6/7/13) +- 提供商探测与模型列表拉取的 `base_url`、插件安装 URL 统一走 SSRF 校验(本地运行时豁免)(SEC-8/10) +- `HITL resume` 校验会话归属(用户 + 智能体),此前可对任意 thread 设置审批策略(SEC-16) +- `/api/setup/status` 不再返回向导密码文件路径(SEC-17) +- 审计日志 `limit` 收敛到 1..500;查询串 `access_token` 鉴权仅保留给 SSE 轨迹流,其余路由强制走 `Authorization` 头(SEC-15/19) +- 内部令牌比对改用常量时间比较;skillhub 子命令在用户可控位置参数前插入 `--`(SEC-14a/20) +- `browser/stream.py`、`browser/harness.py`、`browser/record_replay.py`、`filesystem.py` 纳入 ACL 回归测试清单,权限校验无法再静默回退(GATE-1) + +### 修复 + +- 预览媒体不再信任前端传入的 MIME 提示:SVG 一律强制下载,并附 `nosniff` 与 `Content-Security-Policy: sandbox`(SEC-11) +- 环境变量脱敏改为按模式匹配(`*_KEY` / `*_SECRET` / `*_TOKEN` / `*_PASSWORD`),此前只覆盖两个固定键(SEC-18) +- 401 重定向闩锁在拿到新令牌后重新武装:此前只要发生过一次 401,本页面生命周期内后续所有 401 都不再跳转登录页,用户重新登录后令牌再次过期也只能看到请求持续失败(FE-1) +- 受限浏览器(隐私模式、内嵌 webview)在访问 `localStorage` / `sessionStorage` 时抛 `SecurityError`,此前会让认证令牌读写整体抛出、进而使全部请求失败;现按“无令牌”降级处理(FE-14) + ## [Unreleased] +## [1.0.2b6] - 2026-10-04 + +### 新增 +- Agent Mail 连接器(授权、邮件工具、新邮件任务) +- LDAP 目录登录 +- 专家默认对话模式;输入栏「+」菜单;未开审批时隐藏批准入口 +- 飞牛 ARM 安装包与多架构镜像 + +- 邮箱连接器读取含裸非 ASCII 字节邮件头(如未 MIME 编码的中文发件人/主题)时崩溃 `Object of type Header is not JSON serializable`:`search_emails`/`read_email` 改用 `email.policy.default` 解析并统一 `str()` 转换,同时自动解码 MIME 编码头为可读文本;正文中声明未知字符集(如 `unknown-8bit`)时回退 UTF-8 而非抛 `LookupError`。影响所有基于该通用 IMAP/SMTP 适配器的邮箱(QQ/网易/Gmail 等)。 +### 新增 +- 专家可配置默认对话模式(Ask / Plan / Craft):新建 / 编辑专家及从专家创建时可选,新建对话与无模式粘性的线程(含 IM / CLI / cron 渠道)按该默认解析,缺省为 Craft;已有对话保持各自粘性的模式不变(Fixes #1310)。 +- 记忆提炼调用参数可配置:记忆设置页新增提炼输出预算、单次提炼/批量整理等待超时;API 另支持 `extract_extra_body` 传递思考开关等厂商参数。参数经 agent 配置透传到 harness 的记忆辅助调用,与提炼时机(空闲/间隔)相互独立,深度思考的本地模型不再必然撑爆默认 120 秒读取超时(Fixes #1360,需配套 octop-harness / octop-memory 的同名变更)。 +### 变更 +- octop-harness 升到 1.0.1;飞牛安装改为建账号向导 + +### 修复 +- 会话「放通所有 / 放通这些工具」后后续 execute 仍弹审批 +- 误报流式失败、提问卡不弹出、TLS 下内部 MCP、过长工具名 +- 远程存储卡住堵住启动;S3 / Postgres 浏览;桌面 beta 覆盖安装;飞牛 8089 残留 +- Windows 全盘存储根、邮箱非 ASCII 头、缺失界面文案 +- PWA 诊断页在手机上可滚动 + +## [1.0.2b5] - 2026-09-29 + +### 新增 +- Octop↔Octop 云端桥接,经隧道使用远程专家(聊天与工作区可写;工具/插件/渠道部分可写;技能包、全局 ACP、连接器、知识库仅本机) +- 内置助手可回答产品与使用问题 +- 可配置 Ollama 本地模型下载目录 +- 账号可自定义侧边栏;角色改为存模板 id(账号侧栏与权限快照,不是运行时角色同步) + + +### 修复 +- 媒体预览 CSP、Langfuse 环境变量、IM 附件元数据 +- CLI 状态文件原子读写;移动端采集失败后停止 JPEG 流 +- 服务关闭时仍可识别本机 Ollama 模型 +- Dashboard 设计 token、知识库暗色预览、备份导入内置技能过滤 +- 旧插件 `harness_agent` 导入;移动端输入框避让视口 + +### 变更 +- 更新 README 路线图、锚点与产品名链接 + +## [1.0.2b4] - 2026-09-27 + +### 新增 +- 侧栏可按账号自定义;角色模板 id 持久化 + +### 修复 +- 暗色主题下知识库 Markdown 预览不可见 + +### 变更 +- README 路线图与产品链接更新 + +## [1.0.2b3] - 2026-09-26 + +### 新增 +- 角色模板、邀请快照与头像;登录页「记住登录状态」 +- 企查查同时支持一键 OAuth 与 API Key +- 团队产物、工具轨迹与主持人收尾;管理员批量策略与令牌 + +### 变更 +- 运行时依赖切换为 octop-harness / octop-gateway / octop-memory / octop-browser 1.0.0 + +### 修复 +- 团队聊天与 IM:思考仅在团队房间默认折叠,工具状态改为通用提示 +- 知识库文档上限与重命名、审计/会话列表 limit、多 worker 下角色与 HITL 即时生效 +- 内置技能写保护、超大插件 UI 剥离、端口/验证码分数/IPv6 等边界校验 +- 旧备份恢复时幂等补齐 schema v18 + ## [1.0.2b2] - 2026-09-23 ### 新增 @@ -21,9 +135,11 @@ ### 新增 +- 技能包新增「复制策略」(`copy_policy`,默认 `snapshot` 行为不变):创建者/管理员可按包设为 `lock`(复制出的工作区副本带 origin/locked 水印,非创建者在工作区写接口被拒)或 `deny`(复制端点 403、前端按钮禁用);复制成功与拒绝均落 audit。锁定副本在 Agent 工具层的写保护依赖 harness 侧配合,已在 issue 中单列(#770) - 登录验证码新增极验行为验 v4(#870):设置页配置 captcha_id / captcha_key,登录弹窗完成验证,服务端按官方协议 HMAC-SHA256 签名后到 gcaptcha4 二次校验(form-urlencoded,仅 result=success 放行) - 对话支持默认折叠思考与工具过程(浏览器本地偏好)(#718) - 对话中可隐藏不常用的共享专家(浏览器本地偏好)(#589) +- 腾讯云语音新增实时(流式)识别:开启后输入框麦克风为按住说话,边说边显示中间结果、松开即出全文;凭据仅保存在服务端,浏览器只收到开关状态;配置存于语音 provider 的 extra_json,无数据库迁移 ### 修复 diff --git a/Makefile b/Makefile index afc0a00f76..2ebbfcd4c2 100644 --- a/Makefile +++ b/Makefile @@ -251,10 +251,17 @@ precommit: format-all lint typecheck test-affected # silently run zero tests. The patch in conftest.py / tests/support/ # testmon_staged_changes.py redirects detection to `git diff HEAD` (staged + # unstaged) so the gate actually fires on a commit's changes. +# +# NOTE: `-n $(PYTEST_JOBS)` matters as much as the testmon scoping. Without it +# this target is the only pytest entry point that runs serially, so a commit +# touching anything shared (a router, conftest, a utils module) degrades into +# the whole suite on one core — measured at ~4.5 h for the 4684-test suite. +# testmon still records results correctly under xdist because reports are +# funnelled back to the controller process. .PHONY: test-affected test-affected: - @echo "[test] pytest (testmon: only tests affected by changes)..." - $(RUN) pytest --testmon + @echo "[test] pytest (testmon: only tests affected by changes, -n $(PYTEST_JOBS))..." + $(RUN) pytest --testmon -n $(PYTEST_JOBS) # ─── Quality (frontend) ────────────────────────────────────────────────────── diff --git a/README.md b/README.md index 8e3a646f78..e4c63e1569 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@

Python 3.12+ License: MIT - Version + Version PyPI Code Style: Ruff GitHub stars @@ -24,13 +24,13 @@

- Highlights · - Overview · - Core Technology · - Features · - Roadmap · - Quick Start · - Contents + Highlights · + Overview · + Core Technology · + Features · + Roadmap · + Quick Start · + Contents

@@ -41,29 +41,32 @@ **Octop** is an open-source, self-hosted AI assistant. It's not just a tool — it's a digital life form that can operate in parallel. Through its multi-agent architecture, it builds an intelligent environment that is both independent and collaborative for teams, families, and individuals. Best of all, it runs entirely on your machine — the fully self-hosted design means privacy is never a compromise, while single-process startup makes the powerful web console, CLI, and IM integrations readily accessible. -Chat through the Web Dashboard, Feishu, DingTalk, QQ, Telegram, WeCom, or programmatic HTTP/SSE/WebSocket. Extend capabilities with the **expert library**, **Connectors** (OAuth + MCP), and **ACP** integration for IDE workflows. +Chat through the Web Dashboard, Feishu, DingTalk, QQ, WeChat, Telegram, Discord, WeCom, or programmatic HTTP/SSE/WebSocket. Extend capabilities with the **expert library**, **Connectors** (OAuth + MCP), and **ACP** integration for IDE workflows. ## ✨ Highlights | | Feature | Description | |---|---------|-------------| -| 👥 | **Multi-user expert team** | One admin, shared household; built-in expert library — switch specialists per scenario | +| 👥 | **Multi-user expert team** | One admin, shared household; built-in expert library and expert market — switch specialists per scenario | +| 🤝 | **Expert sharing** | Publish experts and shared skill/sub-agent pools so teammates reuse proven setups instead of rebuilding them | | 🎭 | **MBTI personas** | 16 personality templates plus an interactive quiz — give each agent a distinct character | +| 🎯 | **AgentTeams** *(Beta)* | A coordinator schedules multiple experts on multi-step work; [details](docs/expert-teams.md) | | 🔒 | **Security built-in** | JWT multi-user isolation, tool approval, shell command guardrails, and PII redaction — data stays local | -| 🔌 | **Connector ecosystem** | Tencent suite (Docs, Weibo trends, News, …); OAuth and MCP gateway extend resource boundaries | -| 💾 | **Pluggable backends** | Local disk, Docker containers, PostgreSQL, or COS/S3 — AI operates inside isolated boundaries | -| 🧠 | **Portable memory** | Powered by harness-memory; memory migrates with the workspace | -| 📚 | **Knowledge base** | RAG over your documents; semantic retrieval grounds agent answers in your private corpus | +| 🔌 | **Connector ecosystem** | Tencent suite (Docs, Meeting, News, …); OAuth and MCP gateway extend resource boundaries | +| 💾 | **Pluggable workspace backends** | Local disk, Docker sandbox, PostgreSQL, or COS/S3 for agent files — separate from the control-plane DB | +| 🧠 | **Portable memory** | Powered by [Octop Memory](https://github.com/TencentCloud/octop-memory); memory migrates with the workspace | +| 📚 | **Knowledge base** | RAG over your documents; share corpora within a deployment and ground answers in your private data | | 🧩 | **Plugins** | Extend Octop with third-party plugins; bundled plugins are seeded and toggled on demand | | ↔️ | **ACP bidirectional** | `octop acp` for IDE/terminal AI; delegate to OpenCode / Claude Code with permission gates | | 💻 | **Terminal AI+** | Interactive shell in the browser — AI-assisted command execution and troubleshooting | | 🌐 | **Browser AI+** | Headless Chromium sessions for web automation, screenshots, and remote browsing | | 🖥️ | **Remote desktop** | Live screen and input from the dashboard on Linux, Windows, and macOS — remote office work and GUI apps; one-click isolated desktop on headless Linux | +| 🪟 | **Desktop client** | Native Windows / macOS / Linux apps (and FnOS packages) alongside the web dashboard | | 🏠 | **Self-hosted** | Dashboard, CLI, IM channels, and cron in one `octop run` — all data under `~/.octop/` | ## 📌 Overview -Octop is a self-hosted AI assistant platform for households and small teams. It runs a single process that serves a web dashboard, a CLI, IM channels (Feishu, DingTalk, QQ, Telegram, WeCom, and more), and cron automation — all sharing one control-plane database under `~/.octop/` (SQLite by default; PostgreSQL optional). +Octop is a self-hosted AI assistant platform for households and small teams. It runs a single process that serves a web dashboard, a CLI, IM channels (Feishu, DingTalk, QQ, WeChat, Telegram, Discord, WeCom, and more), and cron automation — all sharing one control-plane database under `~/.octop/` (SQLite by default; PostgreSQL optional). > Octop's design goal: keep every conversation, workspace, and credential on your own machine, while giving each user a personal team of specialized agents they can switch between per task. @@ -71,10 +74,10 @@ Octop is a self-hosted AI assistant platform for households and small teams. It

🐾 What can you do with Octop - **Personal assistant** — let a dedicated agent write weekly reports, organize notes, and manage your schedule; memory persists with the workspace. -- **Family sharing** — one admin account, the whole household; assign different agents and experts per member. -- **Team helper** — multiple agents collaborate in parallel, bridging Feishu / DingTalk / WeCom to route tasks into group chats. +- **Family sharing** — one admin account, the whole household; assign different agents and experts per member; share experts and knowledge bases when useful. +- **Team helper** — AgentTeams or parallel agents, bridging Feishu / DingTalk / WeCom / WeChat to route tasks into group chats. - **Developer boost** — delegate coding tasks to OpenCode / Claude Code via ACP, or troubleshoot from the terminal with AI assistance. -- **Web automation** — use Browser AI+ to fill forms, capture screenshots, and gather public info. +- **Web automation** — use Browser AI+ and remote desktop for forms, screenshots, and GUI apps. - **Scheduled tasks** — configure cron in natural language so the agent pushes or runs jobs on time every day. @@ -85,20 +88,20 @@ Octop is a self-hosted AI assistant platform for households and small teams. It |-------|-----------| | **Language** | Python 3.12+ | | **Web framework** | FastAPI + uvicorn | -| **Agent runtime** | harness-agent | -| **Gateway** | harness-gateway | +| **Agent runtime** | [Octop Harness](https://github.com/TencentCloud/octop-harness) | +| **Gateway** | [Octop Gateway](https://github.com/TencentCloud/octop-gateway) | | **Control plane DB** | SQLite (WAL, default) or PostgreSQL (optional) | | **Frontend** | React 18 + TypeScript + Vite + Ant Design | | **Scheduling** | APScheduler | | **ACP** | agent-client-protocol | | **Build / quality** | hatchling · ruff · mypy · pytest | -Octop is built on the Harness stack — a set of focused runtimes that Octop composes into one process: +Octop is built on the Octop Harness stack — a set of focused runtimes that Octop composes into one process: -- **harness-agent** — Agent runtime: model routing, tools, skills, and conversation checkpointing. -- **harness-gateway** — multi-platform IM channel bridge that normalizes incoming messages into a single processing pipeline. -- **harness-memory** — hierarchical recall with full-text search, so an agent's memory travels with its workspace. -- **harness-browser** — CDP-based browser automation with persistent profiles for web tasks. +- **[Octop Harness](https://github.com/TencentCloud/octop-harness)** — Agent runtime: model routing, tools, skills, and conversation checkpointing. +- **[Octop Gateway](https://github.com/TencentCloud/octop-gateway)** — multi-platform IM channel bridge that normalizes incoming messages into a single processing pipeline. +- **[Octop Memory](https://github.com/TencentCloud/octop-memory)** — hierarchical recall with full-text search, so an agent's memory travels with its workspace. +- **[Octop Browser](https://github.com/TencentCloud/octop-browser)** — CDP-based browser automation with persistent profiles for web tasks. Instead of an external queue or message broker, Octop routes every surface — Web UI, IM, and cron — through one in-process `HarnessProcessor`. The result is a single, restart-safe process whose entire state is rebuilt from the control-plane database on boot (local SQLite by default; PostgreSQL optional). @@ -109,24 +112,27 @@ Instead of an external queue or message broker, Octop routes every surface — W - First-run setup wizard (`octop init`) - Interactive API docs at `/api/docs` (off by default — set `"enable_api_docs": true` in `config.json` to enable) -### Agents -- Multiple agents per user; each has its own workspace, providers, channels, and cron +### Experts +- Multiple experts per user; each has its own workspace, providers, channels, and cron - 16 MBTI persona templates + custom system prompt -- Expert library scanned at boot (`infra/agents/experts/library/`) -- Workspace backends: local disk, COS, S3, and other remote stores +- Expert library scanned at boot (`infra/agents/experts/library/`); expert market and in-deployment expert sharing +- **AgentTeams** *(Beta)* — coordinator + member experts for multi-step tasks ([docs/expert-teams.md](docs/expert-teams.md)) +- Workspace backends (expert files): local disk, Docker sandbox, PostgreSQL, COS/S3, and other remote stores — distinct from the control-plane database ### Channels & automation -- IM channels: Feishu, DingTalk, QQ, Telegram, WeCom, and more +- IM channels: Feishu, DingTalk, QQ, WeChat, Telegram, Discord, WeCom, and more - Proactive cron jobs with natural-language and slash-command triggers - Unified message processing across Web UI, IM, and cron surfaces ### Surfaces -- **Web dashboard** — chat, agents, connectors, channels, cron, settings -- **CLI** — `octop run`, `octop chat`, `octop acp`, admin commands +- **Web dashboard** — chat, experts / teams, connectors, channels, cron, knowledge, plugins, settings +- **Desktop client** — native apps for Windows / macOS / Linux; FnOS packages for NAS +- **CLI** — `octop run`, `octop chats`, `octop acp`, admin commands - **HTTP/SSE/WebSocket API** — full programmatic access +- **Remote desktop** — dashboard control of the host desktop session ### Knowledge & plugins -- **Knowledge base** — RAG over your documents; upload files and let semantic retrieval ground agent answers in your private corpus +- **Knowledge base** — RAG over your documents; optional sharing within the same deployment - **Plugins** — install and manage third-party plugins (`octop plugin`); bundled plugins are seeded and toggled on demand from the dashboard ### ACP (Agent Client Protocol) @@ -150,12 +156,23 @@ Full setup: **[docs/acp.md](docs/acp.md)**. Here are our mid-to-long term plans: -- [ ] **Shared resource pool** — a central pool of skills and sub-agents that any user can drop into a new expert without rebuilding from scratch. -- [ ] **Expert sharing** — publish your experts to other users in the same deployment, so good configurations are reused instead of recreated. +**Shipped** +- [x] **Shared resource pool** — a central pool of skills and sub-agents that any user can drop into a new expert without rebuilding from scratch. +- [x] **Expert sharing** — publish your experts to other users in the same deployment, so good configurations are reused instead of recreated. +- [x] **PC client** — native desktop apps for Windows / macOS / Linux alongside the web dashboard and IM channels. + +**In progress** +- [ ] **AgentTeams** *(Beta)* — let one coordinator autonomously schedule and orchestrate multiple experts to tackle multi-step tasks. +- [ ] **Mobile client** *(closed beta)* — native mobile apps currently in internal testing. + +**Planned** - [ ] **Browser & terminal polishing** — browser skill *recording* (capture a workflow and replay it as a skill) and a more capable terminal AI assistant. -- [ ] **AgentTeams** — let one coordinator autonomously schedule and orchestrate multiple experts to tackle multi-step tasks. - [ ] **Self-evolution** — automatically distill everyday conversations into reusable skills, so the assistant grows with you. -- [ ] **PC / mobile clients** — native desktop and mobile apps alongside the web dashboard and IM channels. +- [ ] **Managed Agents** — platform-hosted agent lifecycle (provision, scale, and operate agents without managing the full self-hosted stack yourself). +- [ ] **Project** — project-scoped workspaces that group agents, files, and conversations around a shared goal. +- [ ] **Cloud–edge continuum** — run Octop locally while offloading selected tasks to the cloud, so light work stays on-device and heavier jobs use remote capacity when you need it. +- [ ] **Plugin marketplace** — a curated market to discover, install, and update third-party plugins without leaving Octop. +- [ ] **Conversational control plane** — deepen Octop’s own skills so chat can cover the full dashboard surface: create experts, wire channels, manage knowledge bases, and scaffold plugins end-to-end. This roadmap may shift as the community grows; treat it as indicative only. @@ -199,11 +216,8 @@ source ~/.bashrc # Bash The installer places `octop` on your PATH via `~/.octop/bin`. Optional extras: ```bash -# Browser automation (Playwright Chromium) +# Download Playwright Chromium for browser automation (skipped if a system Chrome/Chromium is already present) curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash -s -- --extras browser - -# Feishu channel support -curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash -s -- --extras channels-feishu ``` See [scripts/README.md](scripts/README.md) for all install options (`--version`, `--from-source`, `--mirror`, Windows flags). @@ -215,7 +229,7 @@ See [scripts/README.md](scripts/README.md) for all install options (`--version`, | 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 | +| FnOS NAS | `Octop-fnos-docker-.fpk` (recommended on ARM; amd64/arm64 image) / `Octop-fnos-native-.fpk` (x86_64, no Docker) / `Octop-fnos-native-arm64-.fpk` (ARM64 fallback if 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. @@ -223,9 +237,10 @@ See [desktop/README.md](desktop/README.md) for the desktop shell and [fnos/READM ```bash pip install octop -# optional: pip install "octop[browser]" # optional local ONNX embedding model cache (Models → Local): pip install "octop[local-embedding]" # Downloads catalog weights under ~/.octop/embedding_models; not chat, not Memory. +# Browser automation uses the bundled Playwright package; install Chromium via the installer --extras browser, +# the dashboard, or: python -m playwright install chromium ``` From a source checkout with uv: @@ -255,7 +270,7 @@ octop run --host 0.0.0.0 --port 8088 octop service start ``` -Open **http://127.0.0.1:8088**. With Docker, the first init generates a random admin password (written to `/data/.octop/credential.txt`) unless `OCTOP_DEFAULT_PASSWORD` is set. Interactive `octop init` / the setup wizard asks you to choose a password (≥8 characters, letters and digits). +Open **http://127.0.0.1:8088**. With Docker, a valid `OCTOP_DEFAULT_PASSWORD` creates the admin on first boot (credentials in `/data/.octop/credential.txt`); if it is unset or rejected, open the setup wizard (unlock password in `/data/.octop/octop-login.txt`). Interactive `octop init` / the setup wizard asks you to choose a password (≥8 characters, letters and digits). ### Docker (recommended for production) @@ -269,18 +284,18 @@ docker run -d \ -p 8088:8088 \ -v octop-data:/data/.octop \ -e HOME=/data \ - -e OCTOP_DEFAULT_PASSWORD="" \ + -e OCTOP_DEFAULT_PASSWORD="" \ octop:latest ``` -Open `http://localhost:8088`. First boot creates the admin account and writes the credentials to `/data/.octop/credential.txt` in the container. With `OCTOP_DEFAULT_PASSWORD` unset a strong random password is generated; a password you set must be ≥8 characters with letters and digits (weak/common passwords are rejected by the app password policy and fall back to a random one). Override the username via `OCTOP_ADMIN_USERNAME`. +Open `http://localhost:8088`. A valid `OCTOP_DEFAULT_PASSWORD` creates the admin on first boot and writes credentials to `/data/.octop/credential.txt`. If it is unset or rejected by the password policy, no admin is created — complete the setup wizard (unlock password in `/data/.octop/octop-login.txt`). Override the username via `OCTOP_ADMIN_USERNAME`. > **Password policy:** at least 8 characters with letters and digits. | Variable | Default | Description | |----------|---------|-------------| | `OCTOP_PORT` | `8088` | HTTP listen port | -| `OCTOP_DEFAULT_PASSWORD` | _(unset)_ | First-run admin password (Docker bootstrap). Unset = random password written to `credential.txt` | +| `OCTOP_DEFAULT_PASSWORD` | _(unset)_ | First-run admin password (Docker bootstrap). Valid → create admin; unset or rejected → setup wizard | | `OCTOP_ADMIN_USERNAME` | `admin` | First-run admin username | | `OCTOP_DATA` | `~/.octop` | Host data directory (compose bind mount) | @@ -288,29 +303,29 @@ See [`.env.example`](.env.example) for the full list. ## 📑 Contents -- [Highlights](#highlights) -- [Overview](#overview) -- [Core Technology](#core-technology) -- [Features](#features) -- [Roadmap](#roadmap) -- [Quick Start](#quick-start) +- [Highlights](#-highlights) +- [Overview](#-overview) +- [Core Technology](#-core-technology) +- [Features](#-features) +- [Roadmap](#-roadmap) +- [Quick Start](#-quick-start) - **Deploy & Use** - - [Install options](#install-options) - - [Configuration](#configuration) - - [CLI reference](#cli-reference) - - [Web dashboard](#web-dashboard) - - [Data directory](#data-directory) + - [Install options](#-install-options) + - [Configuration](#️-configuration) + - [CLI reference](#-cli-reference) + - [Web dashboard](#️-web-dashboard) + - [Data directory](#-data-directory) - **Architecture & Dev** - - [Architecture](#architecture) - - [Project layout](#project-layout) - - [Development](#development) + - [Architecture](#️-architecture) + - [Project layout](#-project-layout) + - [Development](#️-development) - **Project Info** - - [Security & privacy](#security--privacy) - - [Contributing](#contributing) - - [Changelog](#changelog) - - [Related projects](#related-projects) - - [WeCom customer group](#wecom-customer-group-cn) - - [License](#license) + - [Security & privacy](#-security--privacy) + - [Contributing](#-contributing) + - [Changelog](#-changelog) + - [Related projects](#-related-projects) + - [Community](#-community) + - [License](#-license) ## 📦 Install options @@ -320,7 +335,7 @@ See [`.env.example`](.env.example) for the full list. | Remote one-liner | Windows | `irm …/octop/install.ps1 \| iex` or `install.bat` | | Local script | macOS / Linux | `bash scripts/install.sh` | | Local script | Windows | `scripts\install.bat` or `install.ps1` | -| PyPI | Any | `pip install octop` or `pip install "octop[browser]"` | +| PyPI | Any | `pip install octop` (optional extras such as `local-embedding`) | | Docker | Any | `docker/docker-compose.yml` | All install scripts provision an isolated environment at `~/.octop/venv` and a `~/.octop/bin/octop` wrapper — they do not touch system Python. @@ -370,11 +385,14 @@ OpenAI-compatible APIs, DashScope (Qwen), Ollama, and other presets — configur | **Feishu** | App ID, App Secret | | **DingTalk** | App Key, App Secret | | **QQ** | Bot AppID, Token | +| **WeChat** | QR bind / account credentials; [CLI](docs/cli.md) | | **Telegram** | Bot Token | | **Discord** | Bot Token; all accessible channels allowed by default, optional channel/DM allowlists; [setup and testing](docs/discord-channel.md) | | **WeCom** | Corp ID, Agent Secret | | **Web Dashboard** | Enabled by default | +Other kinds (e.g. Yuanbao, Xiaoyi, MQTT) are available via the gateway — see channel setup in the dashboard or CLI. + ## 📖 CLI reference | Command | Description | @@ -413,13 +431,15 @@ After `octop run`, open **http://127.0.0.1:8088**. Octop Web Dashboard

-- **Chat** — real-time conversation with agents -- **Agents** — create agents, pick experts / MBTI personas, configure providers +- **Chat** — real-time conversation with experts and teams +- **Experts** — create experts, pick templates / MBTI personas, share or publish experts, configure providers +- **AgentTeams** *(Beta)* — coordinator + member experts for multi-step work - **Connectors** — OAuth apps and MCP gateways - **Channels** — IM platform setup - **Cron** — visual cron job management -- **Knowledge base** — manage document corpora and semantic retrieval +- **Knowledge base** — manage document corpora, semantic retrieval, and in-deployment sharing - **Plugins** — install, enable, and configure plugins +- **Remote desktop** — live screen and input from the dashboard - **ACP** — configure outbound coding-agent runners - **Settings** — users, security, TLS, system @@ -453,9 +473,9 @@ OctopServer ├─ UserManager │ └─ HarnessAgentManager (per user) │ └─ AgentRuntime (per agent) - │ ├─ HarnessAgent Agent runtime (harness-agent) + │ ├─ HarnessAgent Agent runtime (octop-harness) │ ├─ HarnessProcessor IM / UI / cron entry point - │ ├─ ChannelManager IM connections (harness-gateway) + │ ├─ ChannelManager IM connections (octop-gateway) │ └─ CronManager APScheduler └─ FastAPI app (uvicorn) ``` @@ -527,14 +547,17 @@ See [CHANGELOG.md](CHANGELOG.md) for release history. | Project | Description | |---------|-------------| -| harness-agent | Agent runtime — model routing, tools, skills, checkpointing | -| harness-gateway | Multi-platform IM channel bridge | -| harness-memory | Hierarchical recall and FTS search | -| harness-browser | CDP browser automation with persistent profiles | +| [Octop Harness](https://github.com/TencentCloud/octop-harness) | Agent runtime — model routing, tools, skills, checkpointing | +| [Octop Gateway](https://github.com/TencentCloud/octop-gateway) | Multi-platform IM channel bridge | +| [Octop Memory](https://github.com/TencentCloud/octop-memory) | Hierarchical recall and FTS search | +| [Octop Browser](https://github.com/TencentCloud/octop-browser) | CDP browser automation with persistent profiles | +| [Octop Pet](https://github.com/jubaoliang/OctopPet) | A desktop pet for Octop | + +## 💬 Community -> These `harness-*` projects are being prepared for open-sourcing; repository links will be added once they are published. +- **Discord** — join the English-speaking community: [discord.gg/jPas5J8Ua](https://discord.gg/jPas5J8Ua) -## 💬 WeCom Customer Group (CN) +### WeCom Customer Group (CN) For the customer WeCom support group, scan: diff --git a/README_CN.md b/README_CN.md index 4ee2e69837..bf6d49a47e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -15,22 +15,21 @@

Python 3.12+ License: MIT - Version + Version PyPI Code Style: Ruff GitHub stars GitHub forks - Discord

- 概述 · - 亮点 · - 核心技术 · - 功能特性 · - 规划 · - 快速开始· - 目录 + 亮点 · + 概述 · + 核心技术 · + 功能特性 · + 规划 · + 快速开始 · + 目录

@@ -39,40 +38,45 @@ --- -## 📌 概述 - **Octop** 是一个开源、自托管的 AI 助手。它不仅是工具,更是可并行运作的数字生命体。通过多 Agent 架构,它为团队、家庭和个人构建了既独立又协作的智能环境。并且这一切都运行在你的机器上——完全自托管的设计让隐私不再是妥协,而单进程启动的便捷性,则让强大的 Web 控制台、CLI 与 IM 集成触手可及。 -借助飞书、钉钉、QQ、Telegram、企业微信或 HTTP/SSE/WebSocket API 与任意 Agent 对话;通过**专家库**一键创建专业角色,通过 **Connector**(OAuth + MCP)接入外部服务,通过 **ACP** 与 IDE / 终端 AI 工具双向协作。 - -> Octop 的设计目标:让每一次对话、工作区与凭据都留在你自己的机器上,同时为每个用户配备一组可按场景切换的专业 Agent。 +借助飞书、钉钉、QQ、微信、企业微信或 HTTP/SSE/WebSocket API 与任意 Agent 对话;通过**专家库**一键创建专业角色,通过 **Connector**(OAuth + MCP)接入外部服务,通过 **ACP** 与 IDE / 终端 AI 工具双向协作。 ## ✨ 亮点 | | 特性 | 说明 | |---|------|------| -| 👥 | **多用户多 Agent 专家团** | 一人管理,全家共用;内置专家库,按场景切换专业角色 | +| 👥 | **多用户多 Agent 专家团** | 一人管理,全家共用;内置专家库与专家市场,按场景切换专业角色 | +| 🤝 | **专家共享** | 支持发布专家与技能 / 子智能体共享池,同实例内复用优秀配置 | | 🎭 | **MBTI 人格** | 16 种人格模板与互动测试,为每个 Agent 赋予鲜明性格 | +| 🎯 | **AgentTeams** *(Beta)* | 协调者调度多位专家协作完成多步骤任务;[详见](docs/expert-teams.md) | | 🔒 | **更安全** | JWT 多用户隔离、工具审批、Shell 命令防护与敏感信息脱敏,数据留在本地 | -| 🔌 | **Connector 拓展体系** | 一键接入腾讯全家桶(文档 / 微博 / 新闻等),OAuth 与 MCP 网关轻松扩展 | -| 💾 | **可插拔后端存储** | 本地目录、Docker 容器、PostgreSQL 或 COS/S3,AI 在隔离边界内操作 | -| 🧠 | **可迁移记忆系统** | 基于 harness-memory,记忆随工作区迁移 | -| 📚 | **知识库** | 基于文档的 RAG 检索,让 Agent 的回答锚定你的私有知识库 | +| 🔌 | **Connector 拓展体系** | 一键接入腾讯全家桶(文档 / 会议 / 新闻等),OAuth 与 MCP 网关轻松扩展 | +| 💾 | **可插拔工作区后端** | 本地目录、Docker 沙箱、PostgreSQL 或 COS/S3 存放 Agent 文件 — 与控制面数据库分离 | +| 🧠 | **可迁移记忆系统** | 基于 [Octop Memory](https://github.com/TencentCloud/octop-memory),记忆随工作区迁移 | +| 📚 | **知识库** | 基于文档的 RAG 检索;支持同实例共享语料,让回答锚定私有知识 | | 🧩 | **插件** | 支持第三方插件扩展;内置插件随安装注入,按需一键启用 | | ↔️ | **ACP 双向集成** | `octop acp` 增强 IDE 与终端 AI;对话中委派 OpenCode / Claude Code 等 | | 💻 | **终端 AI+** | 浏览器内交互式 Shell,AI 辅助命令执行与排障 | | 🌐 | **浏览器 AI+** | 基于 Chromium 的无头浏览器会话,支持网页自动化、截图与远程操控 | | 🖥️ | **远程桌面** | 控制台内实时看屏与键鼠操控,跨 Linux / Windows / macOS;适合远程办公、GUI 软件操作,无图形 Linux 可一键搭建隔离桌面 | +| 🪟 | **桌面客户端** | Windows / macOS / Linux 原生应用,另有飞牛(FnOS)安装包,可与 Web 控制台并行使用 | | 🏠 | **可自托管** | 一条 `octop run` 即可运行控制台、CLI、IM 通道与定时任务,数据存于 `~/.octop/` | +## 📌 概述 + +Octop 是面向家庭与小团队的自托管 AI 助手平台。单进程同时提供 Web 控制台、CLI、IM 通道(飞书、钉钉、QQ、微信、企业微信等)与定时任务,共享 `~/.octop/` 下的控制面数据库(默认 SQLite;可选 PostgreSQL)。 + +> Octop 的设计目标:让每一次对话、工作区与凭据都留在你自己的机器上,同时为每个用户配备一组可按场景切换的专业 Agent。 +

🐾 你能用 Octop 做什么 - **个人助理** — 让专属 Agent 帮你写周报、整理资料、定日程,记忆随工作区长期保留。 -- **家庭共享** — 一个管理员账号,全家共用;按成员分配不同 Agent 与专家角色。 -- **团队助手** — 多 Agent 并行协作,对接飞书 / 钉钉 / 企业微信,把任务自动分发到群里。 +- **家庭共享** — 一个管理员账号,全家共用;按成员分配不同 Agent 与专家角色,也可共享专家与知识库。 +- **团队助手** — AgentTeams 或多 Agent 并行协作,对接飞书 / 钉钉 / 企业微信 / 微信,把任务自动分发到群里。 - **开发者增效** — 通过 ACP 把编码任务委派给 OpenCode / Claude Code,或在终端用 AI 辅助排障。 -- **网页自动化** — 用浏览器 AI+ 自动填表、截图、采集公开信息。 +- **网页自动化** — 用浏览器 AI+ 与远程桌面自动填表、截图、操作 GUI 软件。 - **定时任务** — 用自然语言配置 Cron,让 Agent 每天按时推送或执行任务。
@@ -85,20 +89,20 @@ |------|------| | **语言** | Python 3.12+ | | **Web 框架** | FastAPI + uvicorn | -| **Agent 运行时** | harness-agent | -| **IM 桥接** | harness-gateway | +| **Agent 运行时** | [Octop Harness](https://github.com/TencentCloud/octop-harness) | +| **IM 桥接** | [Octop Gateway](https://github.com/TencentCloud/octop-gateway) | | **控制平面数据库** | SQLite (WAL,默认) 或 PostgreSQL(可选) | | **前端** | React 18 + TypeScript + Vite + Ant Design | | **调度** | APScheduler | | **ACP** | agent-client-protocol | | **构建 / 质量** | hatchling · ruff · mypy · pytest | -Octop 基于一系列 Harness 工程实践构建——它将这些专注的运行时组合进同一个进程: +Octop 基于一系列 Octop Harness 工程实践构建——它将这些专注的运行时组合进同一个进程: -- **harness-agent** — Agent 运行时:模型路由、工具、技能与对话检查点。 -- **harness-gateway** — 多平台 IM 通道桥接,将各类入站消息归一为统一的处理管线。 -- **harness-memory** — 分层记忆与全文检索,让 Agent 的记忆随工作区一同迁移。 -- **harness-browser** — 基于 CDP 的浏览器自动化,支持持久化配置,用于网页类任务。 +- **[Octop Harness](https://github.com/TencentCloud/octop-harness)** — Agent 运行时:模型路由、工具、技能与对话检查点。 +- **[Octop Gateway](https://github.com/TencentCloud/octop-gateway)** — 多平台 IM 通道桥接,将各类入站消息归一为统一的处理管线。 +- **[Octop Memory](https://github.com/TencentCloud/octop-memory)** — 分层记忆与全文检索,让 Agent 的记忆随工作区一同迁移。 +- **[Octop Browser](https://github.com/TencentCloud/octop-browser)** — 基于 CDP 的浏览器自动化,支持持久化配置,用于网页类任务。 Octop 不依赖外部消息队列或中间件,而是通过进程内的 `HarnessProcessor` 统一路由所有入口——Web UI、IM 与定时任务。最终呈现为一个可重启恢复的单进程:启动时整个状态都从控制面数据库重建(默认 `~/.octop/octop.db`,亦可配置 PostgreSQL)。 @@ -109,24 +113,27 @@ Octop 不依赖外部消息队列或中间件,而是通过进程内的 `Harnes - 首次运行向导(`octop init`) - 交互式 API 文档:`/api/docs`(默认关闭 — 在 `config.json` 中设置 `"enable_api_docs": true` 开启) -### Agent -- 每位用户可创建多个 Agent;各自拥有独立工作区、供应商、通道和定时任务 +### 专家 +- 每位用户可创建多个专家;各自拥有独立工作区、供应商、通道和定时任务 - 16 种 MBTI 人格模板 + 自定义系统提示词 -- 启动时扫描专家库(`infra/agents/experts/library/`) -- 工作区后端:本地磁盘、COS、S3 及其他远程存储 +- 启动时扫描专家库(`infra/agents/experts/library/`);支持专家市场与同实例专家共享 +- **AgentTeams** *(Beta)* — 协调者 + 成员专家协作完成多步骤任务([docs/expert-teams.md](docs/expert-teams.md)) +- 工作区后端(专家文件):本地磁盘、Docker 沙箱、PostgreSQL、COS/S3 及其他远程存储 — 与控制面数据库分离 ### 通道与自动化 -- IM 通道:飞书、钉钉、QQ、Telegram、企业微信等 +- IM 通道:飞书、钉钉、QQ、微信、企业微信等 - 主动定时任务,支持自然语言和斜杠命令触发 - Web UI、IM、定时任务共用同一套消息处理链路 ### 使用入口 -- **Web 控制台** — 对话、Agent 管理、连接器、通道、定时任务、设置 -- **CLI** — `octop run`、`octop chat`、`octop acp`、管理命令 +- **Web 控制台** — 对话、专家 / 团队、连接器、通道、定时任务、知识库、插件、设置 +- **桌面客户端** — Windows / macOS / Linux 原生应用;飞牛(FnOS)安装包 +- **CLI** — `octop run`、`octop chats`、`octop acp`、管理命令 - **HTTP/SSE/WebSocket API** — 完整的程序化访问能力 +- **远程桌面** — 在控制台操控本机桌面会话 ### 知识库与插件 -- **知识库** — 基于文档的 RAG 检索;上传文件后,语义检索让 Agent 的回答锚定你的私有知识库 +- **知识库** — 基于文档的 RAG 检索;支持同实例内共享语料 - **插件** — 安装并管理第三方插件(`octop plugin`);内置插件随安装注入,按需在控制台一键启用 ### ACP(Agent Client Protocol) @@ -150,12 +157,23 @@ Octop 支持两个方向的 ACP 集成: 以下是我们的中长期规划: -- [ ] **资源共享池** — 构建技能 / 子智能体共享池,用户在创建专家时可直接取用,无需从零搭建。 -- [ ] **专家共享** — 支持用户将自有专家共享给其他用户使用,沉淀优秀配置、避免重复造轮子。 +**已完成** +- [x] **资源共享池** — 构建技能 / 子智能体共享池,用户在创建专家时可直接取用,无需从零搭建。 +- [x] **专家共享** — 支持用户将自有专家共享给其他用户使用,沉淀优秀配置、避免重复造轮子。 +- [x] **PC 端客户端** — Windows / macOS / Linux 原生桌面应用已发布,可与 Web 控制台、IM 通道并行使用。 + +**进行中** +- [ ] **AgentTeams** *(Beta)* — 支持协调者自主调度、编排多位专家,协同完成复杂多步骤任务。 +- [ ] **移动端客户端** *(内测中)* — 原生移动端应用,目前处于内部测试阶段。 + +**规划中** - [ ] **浏览器与终端能力补全** — 增强浏览器技能*录制*(把操作流程录制为可复用技能),并完善终端 AI 助手能力。 -- [ ] **AgentTeams** — 支持协调者自主调度、编排多位专家,协同完成复杂多步骤任务。 - [ ] **自进化能力** — 将日常对话自动沉淀为技能,让助手随使用不断成长。 -- [ ] **PC / 移动端客户端** — 在 Web 控制台与 IM 通道之外,提供原生桌面与移动端应用。 +- [ ] **Managed Agents** — 平台托管的 Agent 生命周期(开通、伸缩与运维),无需自行维护完整自托管栈。 +- [ ] **Project** — 以项目为边界组织 Agent、文件与会话,围绕同一目标协作。 +- [ ] **云边端一体** — 本地运行 Octop,同时可将选定任务调度到云端执行:轻量工作留在本地,重活按需上云。 +- [ ] **插件市场** — 搭建插件市场,支持在 Octop 内发现、安装与更新第三方插件。 +- [ ] **对话式控制面** — 完善 Octop 自身 Skill,让对话即可覆盖当前控制台的完整能力:创建专家、配置通道、管理知识库,并支持开发插件。 规划会随社区发展动态调整,以上仅供参考。 @@ -199,11 +217,8 @@ source ~/.bashrc # Bash 安装脚本会将 `octop` 加入 PATH(`~/.octop/bin`)。可选附加组件: ```bash -# 浏览器自动化(Playwright Chromium) +# 下载 Playwright Chromium 用于浏览器自动化(若系统已有 Chrome / Chromium 则会跳过) curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash -s -- --extras browser - -# 飞书通道支持 -curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash -s -- --extras channels-feishu ``` 完整安装选项见 [scripts/README.md](scripts/README.md)(`--version`、`--from-source`、`--mirror` 及 Windows 参数)。 @@ -215,7 +230,7 @@ curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install | 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)— 通过应用中心安装 | +| 飞牛 NAS(FnOS) | `Octop-fnos-docker-.fpk`(推荐,尤其是 ARM;amd64/arm64 镜像)/ `Octop-fnos-native-.fpk`(x86_64,无需 Docker)/ `Octop-fnos-native-arm64-.fpk`(ARM64,无 Docker 时再用)— 通过应用中心安装 | 桌面客户端说明见 [desktop/README.md](desktop/README.md),飞牛打包指南见 [fnos/README.md](fnos/README.md)。 @@ -223,9 +238,10 @@ curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install ```bash pip install octop -# 可选:pip install "octop[browser]" # 可选本地 ONNX Embedding 模型缓存(设置 → 模型 → 本地):pip install "octop[local-embedding]" # 仅下载目录模型到 ~/.octop/embedding_models;不用于对话,也不接入 Memory。 +# 浏览器自动化依赖已内置 Playwright;Chromium 可通过安装脚本 --extras browser、 +# 控制台,或执行:python -m playwright install chromium ``` 从源码用 uv 开发时: @@ -255,7 +271,7 @@ octop run --host 0.0.0.0 --port 8088 octop service start ``` -打开 **http://127.0.0.1:8088**。Docker 首次初始化会自动生成随机管理员密码(写入 `/data/.octop/credential.txt`),除非设置了 `OCTOP_DEFAULT_PASSWORD`。交互式 `octop init` / 设置向导会让你自行设置密码(至少 8 位,且同时包含字母和数字)。 +打开 **http://127.0.0.1:8088**。Docker 若设置了合格的 `OCTOP_DEFAULT_PASSWORD`,首次启动会创建管理员(凭据写入 `/data/.octop/credential.txt`);未设置或密码不合格则打开设置向导(向导口令在 `/data/.octop/octop-login.txt`)。交互式 `octop init` / 设置向导会让你自行设置密码(至少 8 位,且同时包含字母和数字)。 ### Docker(推荐用于生产部署) @@ -269,18 +285,18 @@ docker run -d \ -p 8088:8088 \ -v octop-data:/data/.octop \ -e HOME=/data \ - -e OCTOP_DEFAULT_PASSWORD="<自定义强密码,留空则自动生成随机密码>" \ + -e OCTOP_DEFAULT_PASSWORD="<自定义强密码,留空则进入设置向导>" \ octop:latest ``` -打开 `http://localhost:8088`。首次初始化会创建管理员账号,并把凭据写入容器内 `/data/.octop/credential.txt`。未设置 `OCTOP_DEFAULT_PASSWORD` 时自动生成随机强密码;自行设置的密码须 ≥8 位且同时包含字母和数字(被应用密码策略拒绝的常见弱密码会自动回退为随机密码)。可通过 `OCTOP_ADMIN_USERNAME` 覆盖用户名。 +打开 `http://localhost:8088`。`OCTOP_DEFAULT_PASSWORD` 合格时首次启动会创建管理员,并把凭据写入 `/data/.octop/credential.txt`。未设置或密码被策略拒绝时不自动建账号,打开页面走设置向导(向导口令在 `/data/.octop/octop-login.txt`)。可通过 `OCTOP_ADMIN_USERNAME` 覆盖用户名。 > **密码策略:** 至少 8 位,且同时包含字母和数字。 | 变量 | 默认值 | 说明 | |------|--------|------| | `OCTOP_PORT` | `8088` | HTTP 监听端口 | -| `OCTOP_DEFAULT_PASSWORD` | _(未设置)_ | 首次运行管理员密码(Docker 引导)。未设置 = 自动生成随机密码并写入 `credential.txt` | +| `OCTOP_DEFAULT_PASSWORD` | _(未设置)_ | 首次运行管理员密码(Docker 引导)。合格则建账号;未设置或不合格则走设置向导 | | `OCTOP_ADMIN_USERNAME` | `admin` | 首次运行管理员用户名 | | `OCTOP_DATA` | `~/.octop` | 宿主机数据目录(compose 挂载) | @@ -289,29 +305,29 @@ docker run -d \ ## 📑 目录 -- [亮点](#亮点) -- [概述](#概述) -- [核心技术](#核心技术) -- [功能特性](#功能特性) -- [规划](#规划) -- [快速开始](#快速开始) +- [亮点](#-亮点) +- [概述](#-概述) +- [核心技术](#-核心技术) +- [功能特性](#-功能特性) +- [规划](#-规划) +- [快速开始](#-快速开始) - **部署与使用** - - [安装方式](#安装方式) - - [配置](#配置) - - [CLI 参考](#cli-参考) - - [Web 控制台](#web-控制台) - - [数据目录](#数据目录) + - [安装方式](#-安装方式) + - [配置](#️-配置) + - [CLI 参考](#-cli-参考) + - [Web 控制台](#️-web-控制台) + - [数据目录](#-数据目录) - **架构与开发** - - [架构](#架构) - - [项目结构](#项目结构) - - [开发](#开发) + - [架构](#️-架构) + - [项目结构](#-项目结构) + - [开发](#️-开发) - **项目信息** - - [安全与隐私](#安全与隐私) - - [参与贡献](#参与贡献) - - [更新日志](#更新日志) - - [相关项目](#相关项目) - - [客户企业微信群](#客户企业微信群) - - [许可证](#许可证) + - [安全与隐私](#-安全与隐私) + - [参与贡献](#-参与贡献) + - [更新日志](#-更新日志) + - [相关项目](#-相关项目) + - [客户企业微信群](#-客户企业微信群) + - [许可证](#-许可证) ## 📦 安装方式 @@ -321,7 +337,7 @@ docker run -d \ | 远程一键安装 | Windows | `irm …/octop/install.ps1 \| iex` 或 `install.bat` | | 本地脚本 | macOS / Linux | `bash scripts/install.sh` | | 本地脚本 | Windows | `scripts\install.bat` 或 `install.ps1` | -| PyPI | 全平台 | `pip install octop` 或 `pip install "octop[browser]"` | +| PyPI | 全平台 | `pip install octop`(可选 extras 如 `local-embedding`) | | Docker | 全平台 | `docker/docker-compose.yml` | 所有安装脚本均在 `~/.octop/venv` 创建隔离环境,并通过 `~/.octop/bin/octop` 包装 CLI,不会影响系统 Python。 @@ -371,11 +387,12 @@ OpenAI 兼容 API、DashScope(千问)、Ollama 等预设 — 在控制台或 | **飞书** | App ID、App Secret | | **钉钉** | App Key、App Secret | | **QQ** | Bot AppID、Token | -| **Telegram** | Bot Token | -| **Discord** | Bot Token;默认允许所有可访问频道,可配置频道/私聊用户允许列表;[配置与验收](docs/discord-channel.md) | +| **微信** | 扫码绑定 / 账号凭证;见 [CLI](docs/cli.md) | | **企业微信** | Corp ID、Agent Secret | | **Web 控制台** | 默认启用 | +其他通道类型(如元宝、小艺、MQTT 等)可通过网关接入 — 详见控制台或 CLI 的通道配置。 + ## 📖 CLI 参考 | 命令 | 说明 | @@ -394,8 +411,16 @@ OpenAI 兼容 API、DashScope(千问)、Ollama 等预设 — 在控制台或 | `octop plugin` | 安装并管理第三方插件 | | `octop backup` | 导出 / 恢复备份 | | `octop clean` | 清理 CLI 状态或清空 `~/.octop/` | +| `octop memory list` | 列出可参与记忆维护的运行中 Agent;不改动数据库。 | +| `octop memory slim [--agent ID]` | 通过运行中的宿主备份并精简 SQLite 记忆;使用指定 Agent 或按提示选择。在终端与控制台显示进度。[详情](docs/memory-slim.md) | +| `octop memory slim --all` | 依次维护所有符合条件的运行中 Agent,逐个显示进度;首个失败即停止。 | | `octop update` | 检查并安装更新 | +在已登录的 Web 控制台或本地 CLI 聊天中,`/memory slim` 会说明当前 Agent 的维护方式; +`/memory slim --all` 列出符合条件的 Agent。审阅影响后加 `--confirm` 开始维护。 +使用 `/memory status` 查看进度/结果。维护确认并开始之前,聊天始终可用。 +外部 IM 的记忆维护需要已验证的发送者权限,暂未开放。 + 完整参考:**[docs/cli.md](docs/cli.md)**。 ## 🖥️ Web 控制台 @@ -406,13 +431,15 @@ OpenAI 兼容 API、DashScope(千问)、Ollama 等预设 — 在控制台或 Octop Web 控制台

-- **对话** — 与 Agent 实时聊天 -- **Agent** — 创建 Agent,选择专家库 / MBTI 人格,配置供应商 +- **对话** — 与专家 / 团队实时聊天 +- **专家** — 创建专家、选择专家库 / MBTI、共享或发布专家、配置供应商 +- **AgentTeams** *(Beta)* — 协调者 + 成员专家协作多步骤任务 - **Connector** — OAuth 应用与 MCP 网关 - **通道** — IM 平台配置 - **定时任务** — 可视化 Cron 管理 -- **知识库** — 管理文档语料与语义检索 +- **知识库** — 管理文档语料、语义检索与同实例共享 - **插件** — 安装、启用与配置插件 +- **远程桌面** — 控制台内实时看屏与键鼠操控 - **ACP** — 配置出站编程 Agent Runner - **设置** — 用户、安全、TLS、系统 @@ -446,9 +473,9 @@ OctopServer ├─ UserManager │ └─ HarnessAgentManager(按用户) │ └─ AgentRuntime(按 Agent) - │ ├─ HarnessAgent Agent 运行时(harness-agent) + │ ├─ HarnessAgent Agent 运行时(octop-harness) │ ├─ HarnessProcessor IM / UI / 定时任务入口 - │ ├─ ChannelManager IM 连接(harness-gateway) + │ ├─ ChannelManager IM 连接(octop-gateway) │ └─ CronManager APScheduler └─ FastAPI app (uvicorn) ``` @@ -521,12 +548,10 @@ cd dashboard && npx tsc -b | 项目 | 描述 | |------|------| -| harness-agent | Agent 运行时 — 模型路由、工具、Skill、检查点 | -| harness-gateway | 多平台 IM 通道桥接 | -| harness-memory | 层级召回与全文搜索 | -| harness-browser | CDP 浏览器自动化,支持 profile 持久登录 | - -> 这些 `harness-*` 项目正在筹备开源中,仓库地址将在发布后补充。 +| [Octop Harness](https://github.com/TencentCloud/octop-harness) | Agent 运行时 — 模型路由、工具、Skill、检查点 | +| [Octop Gateway](https://github.com/TencentCloud/octop-gateway) | 多平台 IM 通道桥接 | +| [Octop Memory](https://github.com/TencentCloud/octop-memory) | 层级召回与全文搜索 | +| [Octop Browser](https://github.com/TencentCloud/octop-browser) | CDP 浏览器自动化,支持 profile 持久登录 | ## 💬 客户企业微信群 diff --git a/dashboard/index.html b/dashboard/index.html index 23836049ea..364eb80f53 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -6,7 +6,7 @@ { - const section = resolveSelectedKey(location.pathname); - const label = t(`nav.${section}`, ""); - document.title = label - ? `${label} · ${t("app.appName", "Octop")}` - : t("app.pageTitle"); - }, [location.pathname, t]); - const themeConfig = { algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, token: { @@ -102,7 +89,7 @@ function ThemedApp() { colorBorderSecondary: "rgba(255,255,255,0.05)", colorText: "rgba(255, 255, 255, 0.92)", colorTextSecondary: "rgba(255, 255, 255, 0.64)", - colorTextTertiary: "rgba(255, 255, 255, 0.55)", + colorTextTertiary: "rgba(255, 255, 255, 0.38)", colorTextQuaternary: "rgba(255, 255, 255, 0.22)", colorFill: "rgba(255, 255, 255, 0.06)", colorFillSecondary: "rgba(255, 255, 255, 0.04)", diff --git a/dashboard/src/api/index.ts b/dashboard/src/api/index.ts index 9f730291bb..7482dbca10 100644 --- a/dashboard/src/api/index.ts +++ b/dashboard/src/api/index.ts @@ -5,6 +5,9 @@ export { setAuthToken, getAuthToken, clearAuthToken, + setRememberLoginPreference, + getRememberLoginPreference, + isSessionOnlyAuth, markSetupRequired, clearSetupRequired, isSetupRequiredKnown, diff --git a/dashboard/src/api/modules/acp.ts b/dashboard/src/api/modules/acp.ts index d459944264..1379210122 100644 --- a/dashboard/src/api/modules/acp.ts +++ b/dashboard/src/api/modules/acp.ts @@ -5,15 +5,29 @@ function agentPath(agentId: string, suffix = ""): string { return `/agents/${encodeURIComponent(agentId)}/acp${suffix}`; } +function optionalAgentHeaders( + agentId?: string | null, +): HeadersInit | undefined { + const id = (agentId ?? "").trim(); + if (!id) return undefined; + return { "X-Octop-Agent-Id": id }; +} + export const acpApi = { /** Global runner definitions (shared by all agents for the current user). */ - getGlobalRunners: () => - request<{ runners: Record }>("/acp"), + getGlobalRunners: (agentId?: string | null) => + request<{ runners: Record }>("/acp", { + headers: optionalAgentHeaders(agentId), + }), - updateGlobalRunners: (runners: Record) => + updateGlobalRunners: ( + runners: Record, + agentId?: string | null, + ) => request<{ runners: Record }>("/acp", { method: "PUT", body: JSON.stringify({ runners }), + headers: optionalAgentHeaders(agentId), }), getRunner: (runnerName: string) => diff --git a/dashboard/src/api/modules/auth.ts b/dashboard/src/api/modules/auth.ts index abb7e67d13..4a77a0a6f9 100644 --- a/dashboard/src/api/modules/auth.ts +++ b/dashboard/src/api/modules/auth.ts @@ -1,4 +1,9 @@ -import { clearSetupRequired, markSetupRequired, request } from "../request"; +import { + clearSetupRequired, + markSetupRequired, + request, + requestUpload, +} from "../request"; /** * Auth + setup module — adapted to octop's multi-user backend. @@ -30,6 +35,8 @@ export interface AuthStatus { wizard_password_exists: boolean; /** When false, the wizard skips the CLI bootstrap password step. */ wizard_password_required: boolean; + /** Absolute path to the one-time bootstrap password file on the server. */ + wizard_password_path?: string; /** True after `/setup/database` has bound the control-plane pool. */ database_bound: boolean; /** Active control-plane driver when bound (`sqlite` | `postgresql`). */ @@ -43,7 +50,8 @@ export interface SsoIdentity { export interface OctopUser { id: number; username: string; - role: "admin" | "user"; + /** Role-template public id: admin | user | custom ULID. */ + role: string; display_name: string | null; locale: string; /** Module permission keys; admin responses include the full catalog. */ @@ -53,6 +61,10 @@ export interface OctopUser { /** Linked SSO providers (multi-identity). */ sso_identities?: SsoIdentity[]; has_password?: boolean; + /** Preset character id. Empty means the built-in default portrait. */ + avatar_icon?: string | null; + /** Uploaded portrait. Shown ahead of the preset when present. */ + avatar_url?: string | null; } export interface LoginResponse { @@ -84,6 +96,12 @@ export interface OauthStatus { providers: OauthProviderStatus[]; } +/** Public LDAP login availability probe (no auth required). */ +export interface LdapStatus { + enabled: boolean; + display_name: string; +} + export interface SetupBody { username: string; password: string; @@ -94,6 +112,7 @@ interface RawSetupStatus { setup_required: boolean; wizard_password_required?: boolean; wizard_password_exists?: boolean; + wizard_password_path?: string; database_bound?: boolean; database_driver?: string | null; } @@ -137,6 +156,7 @@ export const authApi = { has_password: true, wizard_password_exists: raw.wizard_password_exists ?? false, wizard_password_required: raw.wizard_password_required ?? true, + wizard_password_path: raw.wizard_password_path, database_bound: raw.database_bound ?? false, database_driver: raw.database_driver ?? null, }; @@ -190,6 +210,9 @@ export const authApi = { /** Return enabled dashboard SSO providers for the login page. */ getOauthStatus: () => request("/auth/oauth/status"), + /** Return whether directory (LDAP) logins are available, and its label. */ + getLdapStatus: () => request("/auth/ldap/status"), + /** Start an OIDC authorization-code login flow. */ startOidc: (redirect_after?: string) => request<{ authorization_url: string }>("/auth/oidc/start", { @@ -251,6 +274,24 @@ export const authApi = { body: JSON.stringify({ display_name: displayName }), }), + /** Choose a preset icon. Null restores the built-in default and clears an upload. */ + setAvatarIcon: (icon: string | null) => + request("/auth/me", { + method: "PATCH", + body: JSON.stringify({ avatar_icon: icon }), + }), + + uploadAvatar: (file: File) => { + const body = new FormData(); + body.append("file", file); + return requestUpload<{ avatar_url: string | null }>( + "/auth/me/avatar", + body, + ); + }, + + deleteAvatar: () => request("/auth/me/avatar", { method: "DELETE" }), + // --- Legacy stubs kept so finnie-era components compile ---------------- // These call paths are removed in octop's data model; the actual // settings UI is rewritten in phase 14.6. Stubs return rejected diff --git a/dashboard/src/api/modules/bridge.ts b/dashboard/src/api/modules/bridge.ts new file mode 100644 index 0000000000..4859c7fc9d --- /dev/null +++ b/dashboard/src/api/modules/bridge.ts @@ -0,0 +1,207 @@ +import { request } from "../request"; +import type { ResolvedModel } from "../types"; +import type { KnowledgeBase, KnowledgeCapability } from "./knowledgeBases"; + +export interface BridgeConnection { + connection_id: string; + peer_base_url: string; + peer_username: string; + display_name: string; + notes: string | null; + icon_name?: string | null; + status: string; + last_error: string | null; + last_seen_at: number | null; + auto_reconnect: boolean; + created_at: number; + updated_at: number; + has_password: boolean; + /** Peer-dialed reverse row: no stored password, this side cannot redial. */ + inbound?: boolean; +} + +export interface BridgeRemoteAgent { + id: string; + agent_id?: string; + name?: string; + description?: string | null; + icon_url?: string | null; + icon_name?: string | null; + color?: string | null; + state?: string | null; + kind?: string | null; + status?: string; + bridge?: boolean; + bridge_connection_id?: string; + remote_agent_id?: string; + [key: string]: unknown; +} + +export interface BridgeProbeAgent { + agent_id: string; + name: string; + description: string | null; + icon_url: string | null; + icon_name?: string | null; + color?: string | null; + state?: string | null; + kind?: string | null; +} + +export interface BridgeProbeResult { + peer_base_url: string; + peer_username: string; + peer_display_name: string; + agent_count: number; + agents: BridgeProbeAgent[]; +} + +export const bridgeApi = { + list: () => request("/bridge/connections"), + + probe: (body: { + peer_base_url: string; + peer_username: string; + password: string; + }) => + request("/bridge/probe", { + method: "POST", + body: JSON.stringify(body), + }), + + probeConnection: ( + connectionId: string, + body: { + peer_base_url: string; + peer_username: string; + password?: string; + }, + ) => + request( + `/bridge/connections/${encodeURIComponent(connectionId)}/probe`, + { + method: "POST", + body: JSON.stringify(body), + }, + ), + + create: (body: { + peer_base_url: string; + peer_username: string; + password: string; + display_name: string; + notes?: string; + icon_name?: string; + connect?: boolean; + }) => + request("/bridge/connections", { + method: "POST", + body: JSON.stringify(body), + }), + + get: (connectionId: string) => + request(`/bridge/connections/${connectionId}`), + + connect: (connectionId: string) => + request(`/bridge/connections/${connectionId}/connect`, { + method: "POST", + }), + + disconnect: (connectionId: string) => + request( + `/bridge/connections/${connectionId}/disconnect`, + { method: "POST" }, + ), + + patch: ( + connectionId: string, + body: { + auto_reconnect?: boolean; + display_name?: string; + notes?: string; + icon_name?: string; + peer_base_url?: string; + peer_username?: string; + /** Omit or empty to keep the stored password. */ + password?: string; + }, + ) => + request(`/bridge/connections/${connectionId}`, { + method: "PATCH", + body: JSON.stringify(body), + }), + + remove: (connectionId: string) => + request(`/bridge/connections/${connectionId}`, { method: "DELETE" }), + + listAgents: (connectionId: string) => + request(`/bridge/connections/${connectionId}/agents`), + + listResolvedModels: (connectionId: string) => + request( + `/bridge/connections/${connectionId}/providers/resolved`, + ), + + getActiveModel: (connectionId: string) => + request<{ provider_name: string; model: string }>( + `/bridge/connections/${connectionId}/providers/active-model`, + ), + + listKnowledgeBases: (connectionId: string) => + request( + `/bridge/connections/${connectionId}/knowledge-bases`, + ), + + getKnowledgeCapability: (connectionId: string) => + request( + `/bridge/connections/${connectionId}/knowledge-bases/capability`, + ), + + getBrowserEnvStatus: (connectionId: string) => + request<{ + playwright: boolean; + browsers_ok: boolean; + harness_browser: boolean; + playwright_chromium?: boolean; + chrome_path?: string | null; + chrome_source?: string | null; + error?: string | null; + }>(`/bridge/connections/${connectionId}/browser/env-status`), + + getBrowserSessions: (connectionId: string) => + request<{ + ok: boolean; + environment: "desktop" | "headless-server"; + sessions: Array<{ + session_id: string; + profile_name: string; + conversation_id: string; + channel_source: string; + state: string; + control_owner: "agent" | "user"; + current_url: string; + created_at: number; + last_activity_at: number; + }>; + }>(`/bridge/connections/${connectionId}/browser/harness-sessions`), + + browserHandoff: ( + connectionId: string, + sessionId: string, + target: "agent" | "user", + reason = "user_button", + ) => + request<{ ok: boolean; session: unknown }>( + `/bridge/connections/${connectionId}/browser/sessions/${encodeURIComponent( + sessionId, + )}/handoff`, + { + method: "POST", + body: JSON.stringify({ target, reason }), + }, + ), + + /** Dashboard screencast WS relayed to the peer browser harness. */ + browserStreamWsPath: (connectionId: string) => + `/bridge/connections/${encodeURIComponent(connectionId)}/browser-stream/ws`, +}; diff --git a/dashboard/src/api/modules/browser.ts b/dashboard/src/api/modules/browser.ts index 26287bc854..4cc5ff9225 100644 --- a/dashboard/src/api/modules/browser.ts +++ b/dashboard/src/api/modules/browser.ts @@ -1,4 +1,4 @@ -import { request, getAuthToken } from "../request"; +import { bridgeAgentHeaders, request, getAuthToken } from "../request"; import { getApiUrl, getWsUrl } from "../config"; import type { BrowserReplayRequest, @@ -99,7 +99,10 @@ export const browserApi = { // -- Environment (Setup Wizard) -- /** Check if a usable browser is available on the host. */ - checkEnvStatus: () => request("/browser/env-status"), + checkEnvStatus: (agentId?: string | null) => + request("/browser/env-status", { + headers: bridgeAgentHeaders(agentId), + }), /** * Start Playwright Chromium installation via SSE stream (POST). diff --git a/dashboard/src/api/modules/connectors.ts b/dashboard/src/api/modules/connectors.ts index 9008ca5b7c..df2e397089 100644 --- a/dashboard/src/api/modules/connectors.ts +++ b/dashboard/src/api/modules/connectors.ts @@ -190,13 +190,26 @@ export interface FeishuUserAuthCompleteResult { cli_config_key: string; } +export interface AgentlyAuthStatus { + status: "idle" | "pending" | "authorized" | "expired" | "error"; + verification_url: string | null; + user_code: string | null; + expires_at: number | null; + error: string | null; +} + export const connectorsApi = { catalog: () => request("/connectors/catalog"), detectLocalWeKnora: () => request("/connectors/weknora/detect-local"), - listInstances: () => request("/connector-instances"), + listInstances: (agentId?: string | null) => + request("/connector-instances", { + headers: agentId?.trim() + ? { "X-Octop-Agent-Id": agentId.trim() } + : undefined, + }), getInstance: (instanceId: string) => request( @@ -312,6 +325,17 @@ export const connectorsApi = { { method: "POST" }, ), + agentlyAuth: ( + instanceId: string, + action: "start" | "status" | "logout" | "refresh", + ) => + request( + `/connector-instances/${encodeURIComponent( + instanceId, + )}/agently-auth/${action}`, + { method: action === "status" ? "GET" : "POST" }, + ), + feishuUserAuthStart: (body: { app_id: string; app_secret: string; diff --git a/dashboard/src/api/modules/invites.ts b/dashboard/src/api/modules/invites.ts index b343bd9b1e..25ca093b4e 100644 --- a/dashboard/src/api/modules/invites.ts +++ b/dashboard/src/api/modules/invites.ts @@ -15,11 +15,15 @@ export interface InviteRow { status: InviteStatus; invite_path: string; invite_url: string; + role_name?: string | null; + /** Role-template public id applied at redeem time. */ + role?: string | null; } export interface InviteCreateBody { note?: string | null; expires_in_days?: number; + role?: string | null; } export interface InviteRedeemBody { @@ -37,7 +41,7 @@ export interface InviteRedeemResponse { user: { id: number; username: string; - role: "admin" | "user"; + role: string; display_name: string | null; locale: string; permissions?: string[]; diff --git a/dashboard/src/api/modules/knowledgeBases.test.ts b/dashboard/src/api/modules/knowledgeBases.test.ts index 3c9eff6a05..87fe7eb2d7 100644 --- a/dashboard/src/api/modules/knowledgeBases.test.ts +++ b/dashboard/src/api/modules/knowledgeBases.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { request, requestUpload } = vi.hoisted(() => ({ +const { request, requestUpload, bridgeAgentHeaders } = vi.hoisted(() => ({ request: vi.fn(), requestUpload: vi.fn(), + bridgeAgentHeaders: vi.fn((agentId?: string | null) => + agentId ? { "X-Octop-Agent-Id": agentId } : undefined, + ), })); -vi.mock("../request", () => ({ request, requestUpload })); +vi.mock("../request", () => ({ request, requestUpload, bridgeAgentHeaders })); import { knowledgeBasesApi } from "./knowledgeBases"; @@ -22,7 +25,9 @@ describe("knowledgeBasesApi", () => { knowledgeBasesApi.getOnnxDownloadStatus(); knowledgeBasesApi.activateOnnx("BAAI/bge-small-zh-v1.5"); - expect(request).toHaveBeenNthCalledWith(1, "/knowledge-bases/capability"); + expect(request).toHaveBeenNthCalledWith(1, "/knowledge-bases/capability", { + headers: undefined, + }); expect(request).toHaveBeenNthCalledWith(2, "/knowledge-bases/feature", { method: "PUT", body: JSON.stringify({ enabled: true, model: "BAAI/bge-small" }), diff --git a/dashboard/src/api/modules/knowledgeBases.ts b/dashboard/src/api/modules/knowledgeBases.ts index a7b1ba9f7a..8b71bd0a4f 100644 --- a/dashboard/src/api/modules/knowledgeBases.ts +++ b/dashboard/src/api/modules/knowledgeBases.ts @@ -1,4 +1,9 @@ -import { request, requestBlob, requestUpload } from "../request"; +import { + bridgeAgentHeaders, + request, + requestBlob, + requestUpload, +} from "../request"; export interface KnowledgeLimits { max_bases_per_owner: number; @@ -114,8 +119,10 @@ export const DEFAULT_KNOWLEDGE_LIMITS: KnowledgeLimits = { }; export const knowledgeBasesApi = { - getCapability: () => - request("/knowledge-bases/capability"), + getCapability: (agentId?: string | null) => + request("/knowledge-bases/capability", { + headers: bridgeAgentHeaders(agentId), + }), setFeature: (body: { enabled: boolean; @@ -132,7 +139,10 @@ export const knowledgeBasesApi = { body: JSON.stringify(body), }), - list: () => request("/knowledge-bases"), + list: (agentId?: string | null) => + request("/knowledge-bases", { + headers: bridgeAgentHeaders(agentId), + }), getEmbeddingOptions: (opts?: { allOnnx?: boolean }) => request( opts?.allOnnx diff --git a/dashboard/src/api/modules/memoryDashboard.ts b/dashboard/src/api/modules/memoryDashboard.ts index ef93ddad73..915c24f2f4 100644 --- a/dashboard/src/api/modules/memoryDashboard.ts +++ b/dashboard/src/api/modules/memoryDashboard.ts @@ -1,9 +1,9 @@ /** - * memoryDashboard — talks to the per-agent harness-memory dashboard + * memoryDashboard — talks to the per-agent octop-memory dashboard * surface mounted at ``/api/agents/{aid}/memory/*`` (octop router). * * Each function maps one-to-one to a backend endpoint. Type defs - * mirror the wire shape the harness-memory bridge handlers emit + * mirror the wire shape the octop-memory bridge handlers emit * (see ``harness_memory.application.dashboard_data``); we deliberately * keep the shapes loose / partial and rely on the BE to never break * field names, so this client doesn't need to round-trip every CI run. @@ -101,6 +101,26 @@ export interface ExtractConfig { * (follow the chat model); send "" to reset back to AUTO. */ aux_model?: string | null; + /** + * Read timeout (seconds) for light memory calls (extraction). null/absent = + * harness default (120s); send 0 to reset back to the default. + */ + extract_light_timeout_s?: number | null; + /** + * Read timeout (seconds) for heavy memory calls (promotion / page regen). + * null/absent = harness default (300s); send 0 to reset. + */ + extract_heavy_timeout_s?: number | null; + /** + * Completion budget for memory calls that don't pass their own. null/absent + * = automatic; send 0 to reset. + */ + extract_max_tokens?: number | null; + /** + * Vendor-specific request extras for OpenAI-compatible endpoints (thinking + * controls). null/absent = unset; send {} to reset. + */ + extract_extra_body?: Record | null; } export interface EntityItem { diff --git a/dashboard/src/api/modules/memoryPortable.ts b/dashboard/src/api/modules/memoryPortable.ts index 01ef32f2e7..0dc8200f63 100644 --- a/dashboard/src/api/modules/memoryPortable.ts +++ b/dashboard/src/api/modules/memoryPortable.ts @@ -9,6 +9,7 @@ */ import { requestBlob, requestUpload } from "../request"; +import { saveBlobAsFile } from "../../utils/saveBlobAsFile"; // --------------------------------------------------------------------------- // Wire types @@ -83,14 +84,7 @@ async function packAndDownload(agentId: string): Promise { "0", )}${String(now.getMinutes()).padStart(2, "0")}`; const filename = `${agentId}-${ts}.hmpkg`; - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + await saveBlobAsFile(blob, filename); } /** Upload an .hmpkg file and import it into the target host. */ diff --git a/dashboard/src/api/modules/mobile.test.ts b/dashboard/src/api/modules/mobile.test.ts new file mode 100644 index 0000000000..f460d2d7d6 --- /dev/null +++ b/dashboard/src/api/modules/mobile.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../request", () => ({ + request: vi.fn(), + getAuthToken: () => "test-token", +})); + +vi.mock("../config", () => ({ + getApiUrl: (path: string) => `/api${path}`, +})); + +import { mobileApi } from "./mobile"; + +interface StubReader { + read: () => Promise<{ done: boolean; value?: Uint8Array }>; +} + +function sseBody(chunks: string[]): { getReader: () => StubReader } { + const encoder = new TextEncoder(); + let index = 0; + return { + getReader: () => ({ + read: async () => { + if (index >= chunks.length) return { done: true }; + const value = encoder.encode(chunks[index]); + index += 1; + return { done: false, value }; + }, + }), + }; +} + +function sseResponse(chunks: string[], status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + body: sseBody(chunks), + } as unknown as Response; +} + +function stubFetch(chunks: string[], status = 200): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => sseResponse(chunks, status)), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("mobileApi.install", () => { + it("reports failure when the body closes without a terminal frame", async () => { + stubFetch(['data: {"log":"installing adb"}\n\n']); + const logs: string[] = []; + const onDone = vi.fn(); + + mobileApi.install((line) => logs.push(line), onDone); + + await vi.waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + expect(logs).toEqual(["installing adb"]); + expect(onDone).toHaveBeenCalledWith(false, expect.any(String)); + }); + + it("settles once even when a terminal frame is followed by another frame", async () => { + stubFetch([ + 'data: {"done":true}\n\n', + 'data: {"done":false,"error":"boom"}\n\n', + ]); + const onDone = vi.fn(); + + mobileApi.install(() => {}, onDone); + + await vi.waitFor(() => expect(onDone).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onDone.mock.calls[0]?.[0]).toBe(true); + }); + + it("reports the terminal success frame together with its log line", async () => { + stubFetch(['data: {"log":"ok","done":true}\n\n']); + const logs: string[] = []; + const onDone = vi.fn(); + + mobileApi.install((line) => logs.push(line), onDone); + + await vi.waitFor(() => expect(onDone).toHaveBeenCalled()); + expect(onDone.mock.calls[0]?.[0]).toBe(true); + expect(logs).toEqual(["ok"]); + }); + + it("reports a non-2xx response instead of hanging", async () => { + stubFetch([], 500); + const onDone = vi.fn(); + + mobileApi.install(() => {}, onDone); + + await vi.waitFor(() => + expect(onDone).toHaveBeenCalledWith(false, "HTTP 500"), + ); + }); +}); diff --git a/dashboard/src/api/modules/mobile.ts b/dashboard/src/api/modules/mobile.ts index 374f09af98..a6a849a8c3 100644 --- a/dashboard/src/api/modules/mobile.ts +++ b/dashboard/src/api/modules/mobile.ts @@ -35,6 +35,21 @@ function streamMobileSse( const controller = new AbortController(); const url = getApiUrl(path); const token = getAuthToken(); + + // ``onDone`` is the only thing that moves the caller out of its "installing" + // phase, so it has to fire exactly once per stream - including the two ways + // this endpoint can end without a clean terminal frame: a terminal frame + // followed by another frame, and the body closing with no terminal frame at + // all (``infra/mobile/setup.py`` runs ``bash`` unguarded, so a missing shell + // or a dying subprocess truncates the body). desktop.ts/browser.ts already + // settle after their read loop; this one returned straight into the caller. + let settled = false; + const settle = (ok: boolean, error?: string) => { + if (settled) return; + settled = true; + onDone(ok, error); + }; + fetch(url, { method: "POST", headers: { @@ -44,7 +59,7 @@ function streamMobileSse( }) .then(async (res) => { if (!res.ok || !res.body) { - onDone(false, `HTTP ${res.status}`); + settle(false, `HTTP ${res.status}`); return; } const reader = res.body.getReader(); @@ -66,17 +81,24 @@ function streamMobileSse( error?: string; }; if (payload.log) onLog(payload.log); - if (payload.done === true) onDone(true); - if (payload.done === false) onDone(false, payload.error); + if (payload.done === true) { + settle(true); + return; + } + if (payload.done === false) { + settle(false, payload.error); + return; + } } catch { /* ignore */ } } } + settle(false, "SSE stream ended before completion"); }) .catch((err: unknown) => { if ((err as Error).name !== "AbortError") { - onDone(false, String(err)); + settle(false, String(err)); } }); return controller; diff --git a/dashboard/src/api/modules/octopThreads.ts b/dashboard/src/api/modules/octopThreads.ts index 778ef1b682..81d5b0e921 100644 --- a/dashboard/src/api/modules/octopThreads.ts +++ b/dashboard/src/api/modules/octopThreads.ts @@ -1,6 +1,47 @@ import type { HitlPendingPayload, HitlSessionPolicy } from "../types/hitl"; import { request } from "../request"; +/** Workspace file produced in a thread; ``agent_id`` is the producer. */ +export type ThreadArtifact = { + path: string; + agent_id?: string; +}; + +/** + * Prefer ``artifact_refs``; fall back to legacy ``artifacts`` (strings or + * objects) so older servers / clients keep working. + */ +export function normalizeThreadArtifacts( + raw: unknown, + fallbackAgentId?: string | null, + refs?: unknown, +): ThreadArtifact[] { + const source = Array.isArray(refs) && refs.length > 0 ? refs : raw; + if (!Array.isArray(source)) return []; + const out: ThreadArtifact[] = []; + const seen = new Set(); + const fallback = (fallbackAgentId || "").trim(); + for (const item of source) { + let path = ""; + let agentId = fallback; + if (typeof item === "string") { + path = item.trim(); + } else if (item && typeof item === "object") { + const row = item as { path?: unknown; agent_id?: unknown }; + if (typeof row.path === "string") path = row.path.trim(); + if (typeof row.agent_id === "string" && row.agent_id.trim()) { + agentId = row.agent_id.trim(); + } + } + if (!path) continue; + const key = `${agentId}\0${path}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(agentId ? { path, agent_id: agentId } : { path }); + } + return out; +} + export interface OctopThread { thread_id: string; title: string | null; @@ -18,7 +59,14 @@ export interface OctopThread { conversation_mode?: "ask" | "plan" | "craft" | null; pending_plan_path?: string | null; hitl_policy?: HitlSessionPolicy | null; - artifacts?: string[]; + /** Legacy path list (compat). Prefer ``artifact_refs`` when present. */ + artifacts?: Array; + /** Structured refs with producer ``agent_id``. */ + artifact_refs?: ThreadArtifact[]; + /** True while a turn is still streaming server-side for this thread. */ + turn_active?: boolean; + /** True when this thread is paused on a HITL approval or question. */ + awaiting_user?: boolean; } export interface OctopThreadHistory { @@ -53,7 +101,16 @@ export interface OctopThreadHistory { turn_active?: boolean; /** Pending tool approval for this thread (survives page reload). */ hitl_pending?: HitlPendingPayload | null; - artifacts?: string[]; + /** Legacy path list (compat). Prefer ``artifact_refs`` when present. */ + artifacts?: Array; + /** Structured refs with producer ``agent_id``. */ + artifact_refs?: ThreadArtifact[]; +} + +/** Result of deleting a thread when the client asks about compaction. */ +export interface ThreadDeleteResult { + compacted?: boolean; + scheduled?: boolean; } export interface OctopThreadPatch { @@ -191,11 +248,11 @@ export const octopThreadsApi = { { method: "PATCH", body: JSON.stringify(body) }, ), - delete: (agentId: string, threadId: string) => - request( + delete: (agentId: string, threadId: string, compact: boolean) => + request( `/agents/${encodeURIComponent(agentId)}/threads/${encodeURIComponent( threadId, - )}`, + )}?compact=${compact ? "true" : "false"}`, { method: "DELETE" }, ), diff --git a/dashboard/src/api/modules/ollamaModel.ts b/dashboard/src/api/modules/ollamaModel.ts index 4d330b53de..25259c503e 100644 --- a/dashboard/src/api/modules/ollamaModel.ts +++ b/dashboard/src/api/modules/ollamaModel.ts @@ -3,6 +3,7 @@ import type { OllamaModelResponse, OllamaDownloadRequest, OllamaDownloadTaskResponse, + OllamaServiceStatus, } from "../types"; export const ollamaModelApi = { @@ -29,12 +30,17 @@ export const ollamaModelApi = { { method: "DELETE" }, ), - getService: () => - request<{ enabled: boolean; running: boolean }>("/ollama-models/service"), + getService: () => request("/ollama-models/service"), setService: (enabled: boolean) => - request<{ enabled: boolean; running: boolean }>("/ollama-models/service", { + request("/ollama-models/service", { method: "PUT", body: JSON.stringify({ enabled }), }), + + setModelsDir: (modelsDir: string) => + request("/ollama-models/service", { + method: "PUT", + body: JSON.stringify({ models_dir: modelsDir }), + }), }; diff --git a/dashboard/src/api/modules/preferences.ts b/dashboard/src/api/modules/preferences.ts index 5df775fda8..3ee76e4bc3 100644 --- a/dashboard/src/api/modules/preferences.ts +++ b/dashboard/src/api/modules/preferences.ts @@ -5,6 +5,22 @@ export interface RemoteBrowserBookmark { title: string; } +export interface SidebarNavGroupPref { + id: string; + name?: string | null; +} + +export interface SidebarNavItemPref { + key: string; + group?: string | null; + hidden?: boolean; +} + +export interface SidebarNavLayoutPref { + groups: SidebarNavGroupPref[]; + items: SidebarNavItemPref[]; +} + export interface UserPreferences { locale: string; remote_browser_bookmarks: RemoteBrowserBookmark[]; @@ -13,6 +29,7 @@ export interface UserPreferences { string, { mode: "auto" | "enabled" | "disabled"; effort?: string | null } >; + sidebar_nav?: SidebarNavLayoutPref | null; } export type PatchPreferencesBody = { @@ -20,6 +37,7 @@ export type PatchPreferencesBody = { remote_browser_bookmarks?: RemoteBrowserBookmark[]; preferred_model?: string | null; model_reasoning?: UserPreferences["model_reasoning"]; + sidebar_nav?: SidebarNavLayoutPref | null; }; export const preferencesApi = { diff --git a/dashboard/src/api/modules/provider.ts b/dashboard/src/api/modules/provider.ts index b5ef73b033..f30187f5fc 100644 --- a/dashboard/src/api/modules/provider.ts +++ b/dashboard/src/api/modules/provider.ts @@ -1,4 +1,4 @@ -import { request } from "../request"; +import { bridgeAgentHeaders, request } from "../request"; import type { ProviderInfo, ProviderConfigRequest, @@ -24,7 +24,16 @@ export interface ModelTemplate { export const providerApi = { listProviders: () => request("/models"), - listResolvedModels: () => request("/providers/resolved"), + listResolvedModels: (agentId?: string | null) => + request("/providers/resolved", { + headers: bridgeAgentHeaders(agentId), + }), + + getActiveModel: (agentId?: string | null) => + request<{ provider_name: string; model: string }>( + "/providers/active-model", + { headers: bridgeAgentHeaders(agentId) }, + ), configureProvider: (providerId: string, body: ProviderConfigRequest) => request(`/models/${encodeURIComponent(providerId)}/config`, { diff --git a/dashboard/src/api/modules/publishedExperts.test.ts b/dashboard/src/api/modules/publishedExperts.test.ts index ecad37c646..d65261533a 100644 --- a/dashboard/src/api/modules/publishedExperts.test.ts +++ b/dashboard/src/api/modules/publishedExperts.test.ts @@ -43,6 +43,11 @@ describe("publishedExpertsApi", () => { "/experts/published/expert%2F1/refresh", { method: "POST", + body: JSON.stringify({ + name: "Updated", + description: "New description", + welcome_message: { zh: "欢迎", en: "Welcome" }, + }), }, ); expect(request).toHaveBeenNthCalledWith( diff --git a/dashboard/src/api/modules/settings.ts b/dashboard/src/api/modules/settings.ts index 0ca48740fc..943aaafbd7 100644 --- a/dashboard/src/api/modules/settings.ts +++ b/dashboard/src/api/modules/settings.ts @@ -9,6 +9,12 @@ export interface OctopUploadSettings { max_upload_bytes: number; } +export interface OctopHitlSettings { + enabled: boolean; + tool_guard_require_approval: boolean; + show_approval_ui: boolean; +} + export interface OctopCapabilitiesSettings { mobile: { enabled: boolean; backend: string }; } @@ -43,6 +49,7 @@ export interface CaptchaSettingsPut { export const octopSettingsApi = { timezone: () => request("/settings/timezone"), upload: () => request("/settings/upload"), + hitl: () => request("/settings/hitl"), capabilities: () => request("/settings/capabilities", { cache: "no-store", diff --git a/dashboard/src/api/modules/sso.test.ts b/dashboard/src/api/modules/sso.test.ts index 3615509bcf..5e631f8fec 100644 --- a/dashboard/src/api/modules/sso.test.ts +++ b/dashboard/src/api/modules/sso.test.ts @@ -34,4 +34,42 @@ describe("ssoApi", () => { method: "POST", }); }); + it("uses the LDAP directory endpoints", async () => { + const body = { + enabled: true, + display_name: "Corp Directory", + server_url: "ldap://directory.example.org", + start_tls: true, + verify_tls: false, + bind_dn: "uid=svc,dc=example,dc=org", + bind_password: "secret", + user_base_dn: "dc=example,dc=org", + user_filter: "(uid={username})", + username_attribute: "uid", + email_attribute: "mail", + display_name_attribute: "givenName", + subject_attribute: "entryUUID", + group_attribute: "memberOf", + group_search: false, + group_search_base: "", + group_member_attribute: "member", + admin_groups: "admin", + allowed_groups: "staff", + auto_provision: true, + timeout_seconds: 10, + }; + + await ssoApi.getLdapConfig(); + await ssoApi.putLdapConfig(body); + await ssoApi.testLdapConfig(); + + expect(request).toHaveBeenNthCalledWith(1, "/auth/ldap/config"); + expect(request).toHaveBeenNthCalledWith(2, "/auth/ldap/config", { + method: "PUT", + body: JSON.stringify(body), + }); + expect(request).toHaveBeenNthCalledWith(3, "/auth/ldap/config/test", { + method: "POST", + }); + }); }); diff --git a/dashboard/src/api/modules/sso.ts b/dashboard/src/api/modules/sso.ts index 78d582b509..711ef572a5 100644 --- a/dashboard/src/api/modules/sso.ts +++ b/dashboard/src/api/modules/sso.ts @@ -26,6 +26,63 @@ export interface OidcConfigTestResult { detail?: string; } +export interface LdapConfig { + enabled: boolean; + display_name: string; + server_url: string; + start_tls: boolean; + verify_tls: boolean; + bind_dn: string; + has_bind_password: boolean; + user_base_dn: string; + user_filter: string; + username_attribute: string; + email_attribute: string; + display_name_attribute: string; + subject_attribute: string; + group_attribute: string; + group_search: boolean; + group_search_base: string; + group_member_attribute: string; + admin_groups: string; + allowed_groups: string; + auto_provision: boolean; + timeout_seconds: number; + warnings: string[]; +} + +export interface LdapConfigPut { + enabled?: boolean; + display_name?: string; + server_url?: string; + start_tls?: boolean; + verify_tls?: boolean; + bind_dn?: string; + bind_password?: string; + user_base_dn?: string; + user_filter?: string; + username_attribute?: string; + email_attribute?: string; + display_name_attribute?: string; + subject_attribute?: string; + group_attribute?: string; + group_search?: boolean; + group_search_base?: string; + group_member_attribute?: string; + admin_groups?: string; + allowed_groups?: string; + auto_provision?: boolean; + timeout_seconds?: number; +} + +export interface LdapConfigTestResult { + ok: boolean; + detail: string; + warnings?: string[]; + /** Identity-key attribute the probe found; suggest it when subject_attribute is blank. */ + detected_subject_attribute?: string | null; +} + export interface OauthAppConfig { kind: string; enabled: boolean; @@ -64,6 +121,20 @@ export const ssoApi = { method: "POST", }); }, + getLdapConfig(): Promise { + return request("/auth/ldap/config"); + }, + putLdapConfig(body: LdapConfigPut): Promise { + return request("/auth/ldap/config", { + method: "PUT", + body: JSON.stringify(body), + }); + }, + testLdapConfig(): Promise { + return request("/auth/ldap/config/test", { + method: "POST", + }); + }, getOauthProvider(kind: string): Promise { return request(`/auth/oauth/providers/${kind}`); }, diff --git a/dashboard/src/api/modules/subagents.ts b/dashboard/src/api/modules/subagents.ts index 60e20e2319..169f45b006 100644 --- a/dashboard/src/api/modules/subagents.ts +++ b/dashboard/src/api/modules/subagents.ts @@ -34,28 +34,44 @@ export interface AgentSubagentSummary { color?: string | null; } -export function listSubagentDivisions(): Promise { - return request("/subagent-catalog/divisions"); +function agentHeaders(agentId?: string | null): HeadersInit | undefined { + const id = (agentId ?? "").trim(); + if (!id) return undefined; + return { "X-Octop-Agent-Id": id }; } -export function listSubagentCatalog(params?: { - division?: string; - q?: string; -}): Promise { +export function listSubagentDivisions( + agentId?: string | null, +): Promise { + return request("/subagent-catalog/divisions", { + headers: agentHeaders(agentId), + }); +} + +export function listSubagentCatalog( + params?: { + division?: string; + q?: string; + }, + agentId?: string | null, +): Promise { const search = new URLSearchParams(); if (params?.division) search.set("division", params.division); if (params?.q) search.set("q", params.q); const qs = search.toString(); return request( `/subagent-catalog${qs ? `?${qs}` : ""}`, + { headers: agentHeaders(agentId) }, ); } export function getSubagentCatalogItem( slug: string, + agentId?: string | null, ): Promise { return request( `/subagent-catalog/${encodeURIComponent(slug)}`, + { headers: agentHeaders(agentId) }, ); } @@ -63,7 +79,7 @@ export function installSubagent( agentId: string, slug: string, ): Promise<{ installed: boolean; slug: string; path: string }> { - return request(`/agents/${agentId}/subagents/install`, { + return request(`/agents/${encodeURIComponent(agentId)}/subagents/install`, { method: "POST", body: JSON.stringify({ slug }), }); @@ -72,5 +88,7 @@ export function installSubagent( export function listAgentSubagents( agentId: string, ): Promise { - return request(`/agents/${agentId}/subagents`); + return request( + `/agents/${encodeURIComponent(agentId)}/subagents`, + ); } diff --git a/dashboard/src/api/modules/userRoles.ts b/dashboard/src/api/modules/userRoles.ts new file mode 100644 index 0000000000..463b6335b6 --- /dev/null +++ b/dashboard/src/api/modules/userRoles.ts @@ -0,0 +1,159 @@ +import type { TFunction } from "i18next"; +import { request, requestUpload } from "../request"; + +export interface RolePolicy { + name: string; + value: string; +} + +export interface UserRole { + user_role_id: string; + user_role_name: string; + description: string | null; + system_role: "admin" | "user"; + permissions: string[]; + policies: RolePolicy[]; + deletable: boolean; + immutable: boolean; + workspace_root_dir: string | null; + token_quota: number | null; + max_agents: number | null; + created_at: number; + updated_at: number; + avatar_url?: string | null; + avatar_icon?: string | null; +} + +export interface UserRoleWriteBody { + user_role_name: string; + description?: string | null; + permissions?: string[]; + policies?: RolePolicy[]; + avatar_icon?: string | null; +} + +export const SEEDED_ADMIN_ROLE_ID = "admin"; +export const SEEDED_USER_ROLE_ID = "user"; +const SEEDED_ADMIN_ROLE_NAME = "管理员"; +const SEEDED_USER_ROLE_NAME = "用户"; + +export const userRolesApi = { + list: () => request("/users/roles"), + + create: (body: UserRoleWriteBody) => + request("/users/roles", { + method: "POST", + body: JSON.stringify(body), + }), + + update: (userRoleId: string, body: Partial) => + request(`/users/roles/${encodeURIComponent(userRoleId)}`, { + method: "PATCH", + body: JSON.stringify(body), + }), + + remove: (userRoleId: string) => + request(`/users/roles/${encodeURIComponent(userRoleId)}`, { + method: "DELETE", + }), + + uploadAvatar: (userRoleId: string, file: File) => { + const body = new FormData(); + body.append("file", file); + return requestUpload<{ avatar_url: string | null }>( + `/users/roles/${encodeURIComponent(userRoleId)}/avatar`, + body, + ); + }, + + deleteAvatar: (userRoleId: string) => + request(`/users/roles/${encodeURIComponent(userRoleId)}/avatar`, { + method: "DELETE", + }), +}; + +/** Seeded admin/user names stay translatable until an admin renames them. */ +export function userRoleLabel( + role: Pick, + t: TFunction, +): string { + if ( + role.user_role_id === SEEDED_ADMIN_ROLE_ID && + role.user_role_name === SEEDED_ADMIN_ROLE_NAME + ) { + return t("adminUsers.seedRoleAdmin"); + } + if ( + role.user_role_id === SEEDED_USER_ROLE_ID && + role.user_role_name === SEEDED_USER_ROLE_NAME + ) { + return t("adminUsers.seedRoleUser"); + } + return role.user_role_name; +} + +export function userRoleDescription(role: UserRole, t: TFunction): string { + if (role.description) return role.description; + if (role.user_role_id === SEEDED_ADMIN_ROLE_ID) { + return t("adminUsers.seedRoleAdminDescription"); + } + if (role.user_role_id === SEEDED_USER_ROLE_ID) { + return t("adminUsers.seedRoleUserDescription"); + } + return t("adminUsers.roleDescriptionEmpty"); +} + +export function snapshotRoleLabel( + name: string | null | undefined, + roles: UserRole[], + t: TFunction, + roleId?: string | null, +): string { + const trimmed = name?.trim(); + if (!trimmed) return ""; + const match = roleId + ? roles.find((role) => role.user_role_id === roleId) + : roles.find((role) => role.user_role_name === trimmed); + return match ? userRoleLabel(match, t) : trimmed; +} + +export type UserRoleDrift = "missing" | "changed"; + +export function userRoleDrift( + row: { + role?: string | null; + role_name?: string | null; + permissions?: string[]; + workspace_root_dir?: string | null; + token_quota?: number | null; + max_agents?: number | null; + }, + roles: UserRole[], +): UserRoleDrift | null { + const roleId = row.role?.trim() || null; + if ( + roleId && + roleId !== SEEDED_ADMIN_ROLE_ID && + roleId !== SEEDED_USER_ROLE_ID && + !roles.some((item) => item.user_role_id === roleId) + ) { + return "missing"; + } + const name = row.role_name?.trim(); + if (!name) return null; + const role = roleId + ? roles.find((item) => item.user_role_id === roleId) + : roles.find((item) => item.user_role_name === name); + if (!role) return "missing"; + if (role.system_role === "admin") return null; + const left = [...(row.permissions ?? [])].sort().join("\0"); + const right = [...role.permissions].sort().join("\0"); + if (left !== right) return "changed"; + const root = row.workspace_root_dir?.trim() || null; + const roleRoot = role.workspace_root_dir?.trim() || null; + if (root !== roleRoot) return "changed"; + if ((row.token_quota ?? null) !== (role.token_quota ?? null)) + return "changed"; + if ((row.max_agents ?? null) !== (role.max_agents ?? null)) return "changed"; + return null; +} diff --git a/dashboard/src/api/modules/voice.ts b/dashboard/src/api/modules/voice.ts index 8562a6414a..530c0b83cb 100644 --- a/dashboard/src/api/modules/voice.ts +++ b/dashboard/src/api/modules/voice.ts @@ -26,6 +26,8 @@ export interface VoiceProviderRow { export interface ActiveVoice { stt: string; tts: string; + /** True when the active STT provider streams results over WebSocket. */ + stt_realtime?: boolean; } export interface VoiceProviderInput { diff --git a/dashboard/src/api/modules/wsChat.ts b/dashboard/src/api/modules/wsChat.ts index af313db2b6..e75ad72c19 100644 --- a/dashboard/src/api/modules/wsChat.ts +++ b/dashboard/src/api/modules/wsChat.ts @@ -3,7 +3,8 @@ import { getAuthToken } from "../request"; export function buildDashboardChatWsUrl(agentId: string): string { const token = getAuthToken(); const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const base = `${protocol}://${window.location.host}/api/agents/${agentId}/chat/ws`; + const encoded = encodeURIComponent(agentId); + const base = `${protocol}://${window.location.host}/api/agents/${encoded}/chat/ws`; const params = new URLSearchParams(); if (token) params.set("token", token); return params.toString() ? `${base}?${params.toString()}` : base; diff --git a/dashboard/src/api/request.authToken.test.ts b/dashboard/src/api/request.authToken.test.ts new file mode 100644 index 0000000000..3331daee85 --- /dev/null +++ b/dashboard/src/api/request.authToken.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./config", () => ({ + getApiUrl: (path: string) => `/api${path}`, +})); + +vi.mock("../i18n", () => ({ + default: { language: "zh" }, +})); + +describe("auth token remember / session-only", () => { + let mod: typeof import("./request"); + + beforeEach(async () => { + vi.resetModules(); + localStorage.clear(); + sessionStorage.clear(); + mod = await import("./request"); + }); + + it("defaults to a remembered (persistent) token in localStorage", () => { + mod.setAuthToken("tok"); + expect(mod.getAuthToken()).toBe("tok"); + expect(mod.isSessionOnlyAuth()).toBe(false); + expect(localStorage.getItem("auth_token")).toBe("tok"); + expect(sessionStorage.getItem("auth_token")).toBeNull(); + }); + + it("stores a session-only token in sessionStorage", () => { + mod.setAuthToken("tok", false); + expect(mod.getAuthToken()).toBe("tok"); + expect(mod.isSessionOnlyAuth()).toBe(true); + expect(sessionStorage.getItem("auth_token")).toBe("tok"); + expect(localStorage.getItem("auth_token")).toBeNull(); + }); + + it("clears the other store when switching remember mode", () => { + mod.setAuthToken("a", false); + mod.setAuthToken("b", true); + expect(localStorage.getItem("auth_token")).toBe("b"); + expect(sessionStorage.getItem("auth_token")).toBeNull(); + expect(mod.isSessionOnlyAuth()).toBe(false); + }); + + it("preserves session-only mode when renewing the access token", () => { + mod.setAuthToken("old", false); + const response = new Response(null, { + headers: { "X-Octop-Access-Token": "renewed" }, + }); + mod.applyRenewedAccessToken(response); + expect(mod.getAuthToken()).toBe("renewed"); + expect(mod.isSessionOnlyAuth()).toBe(true); + expect(sessionStorage.getItem("auth_token")).toBe("renewed"); + }); + + it("reads and writes the SSO remember preference", () => { + expect(mod.getRememberLoginPreference()).toBe(true); + mod.setRememberLoginPreference(false); + expect(mod.getRememberLoginPreference()).toBe(false); + }); + + it("clearAuthToken removes both stores", () => { + mod.setAuthToken("tok", false); + mod.clearAuthToken(); + expect(mod.getAuthToken()).toBe(""); + expect(sessionStorage.getItem("auth_token")).toBeNull(); + }); +}); diff --git a/dashboard/src/api/request.blockedStorage.test.ts b/dashboard/src/api/request.blockedStorage.test.ts new file mode 100644 index 0000000000..3686a3d023 --- /dev/null +++ b/dashboard/src/api/request.blockedStorage.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** + * Some embedded webviews and private-mode browsers throw on *accessing* + * window.localStorage, not just on calling a method on it. When that happens + * every authenticated request used to throw before it was ever sent, so the + * dashboard showed a blank error instead of asking for a login. + */ +describe("blocked web storage", () => { + const originals: Array<[string, PropertyDescriptor]> = []; + + function block(name: "localStorage" | "sessionStorage") { + const original = Object.getOwnPropertyDescriptor(window, name); + if (original) originals.push([name, original]); + Object.defineProperty(window, name, { + configurable: true, + get() { + throw new DOMException("storage is blocked", "SecurityError"); + }, + }); + } + + afterEach(() => { + for (const [name, descriptor] of originals) { + Object.defineProperty(window, name, descriptor); + } + originals.length = 0; + vi.resetModules(); + }); + + it("reads an empty token instead of throwing when localStorage is blocked", async () => { + block("localStorage"); + const mod = await import("./request"); + + expect(() => mod.getAuthToken()).not.toThrow(); + expect(mod.getAuthToken()).toBe(""); + }); + + it("still stores the token when localStorage is blocked", async () => { + block("localStorage"); + const mod = await import("./request"); + + expect(() => mod.setAuthToken("a-token")).not.toThrow(); + }); + + it("reports a session-only login without consulting localStorage", async () => { + const mod = await import("./request"); + mod.setAuthToken("session-token", false); + + // localStorage now throws; the answer must come from sessionStorage alone. + block("localStorage"); + const reloaded = await import("./request"); + expect(reloaded.isSessionOnlyAuth()).toBe(true); + }); + + it("clears the token without throwing when both storages are blocked", async () => { + block("localStorage"); + block("sessionStorage"); + const mod = await import("./request"); + + expect(() => mod.clearAuthToken()).not.toThrow(); + }); +}); diff --git a/dashboard/src/api/request.setup.test.ts b/dashboard/src/api/request.setup.test.ts index 2507f1b897..a247c92d63 100644 --- a/dashboard/src/api/request.setup.test.ts +++ b/dashboard/src/api/request.setup.test.ts @@ -23,6 +23,7 @@ describe("setup lockdown handling", () => { beforeEach(async () => { vi.resetModules(); localStorage.clear(); + sessionStorage.clear(); replace.mockClear(); Object.defineProperty(window, "location", { configurable: true, diff --git a/dashboard/src/api/request.ts b/dashboard/src/api/request.ts index ab5e430cb2..49b9713031 100644 --- a/dashboard/src/api/request.ts +++ b/dashboard/src/api/request.ts @@ -3,6 +3,8 @@ import i18n from "../i18n"; import { markNavigatingAway } from "../utils/reloadOnStaleChunk"; const AUTH_TOKEN_KEY = "auth_token"; +/** sessionStorage handoff for SSO redirects / popups (default = remember). */ +const AUTH_REMEMBER_PREF_KEY = "auth_remember_pref"; /** * Fired when the session is no longer valid. Cancelable: a listener inside the @@ -42,35 +44,102 @@ export function isSetupRequiredKnown(): boolean { return _setupRequiredKnown; } -/** Save JWT token to localStorage */ -export function setAuthToken(token: string) { +/** + * Web storage can throw on *access*, not only on use: embedded webviews and + * private-mode browsers raise SecurityError from the property itself. Every + * auth-storage touch goes through these so a blocked store degrades to "no + * token" instead of taking down every request before it is sent. + */ +function readItem( + store: "localStorage" | "sessionStorage", + key: string, +): string | null { try { - localStorage.setItem(AUTH_TOKEN_KEY, token); + return window[store].getItem(key); } catch { - /* FE-14: storage-restricted environments — login still succeeds in-page */ + return null; } - clearSetupRequired(); - // FE-1: a successful login/refresh re-arms the one-shot redirect latch — - // without this, one 401 anywhere permanently breaks later 401 handling. - _redirectingToLogin = false; } -/** Get JWT token from localStorage */ -export function getAuthToken(): string { - // FE-14: storage-restricted environments (Safari private mode, enterprise - // policy) throw SecurityError on localStorage access. Callers treat "" as - // "no token", so swallow instead of breaking every request/stream. +function writeItem( + store: "localStorage" | "sessionStorage", + key: string, + value: string, +): void { + try { + window[store].setItem(key, value); + } catch { + // Storage unavailable — the login simply will not survive a reload. + } +} + +function dropItem(store: "localStorage" | "sessionStorage", key: string): void { try { - return localStorage.getItem(AUTH_TOKEN_KEY) || ""; + window[store].removeItem(key); } catch { - return ""; + // Nothing to clear if the store is unreachable. } } -/** Remove JWT token from localStorage */ +/** Stash the login-page "remember me" choice for SSO redirects / popups. */ +export function setRememberLoginPreference(remember: boolean): void { + writeItem("sessionStorage", AUTH_REMEMBER_PREF_KEY, remember ? "1" : "0"); +} + +/** Read the stashed remember preference (defaults to true). */ +export function getRememberLoginPreference(): boolean { + return readItem("sessionStorage", AUTH_REMEMBER_PREF_KEY) !== "0"; +} + +/** + * Whether the current token is session-only (not "remember me"). + * Session-only tokens live in ``sessionStorage`` so a new tab does not + * share — or erase — another tab's login. + */ +export function isSessionOnlyAuth(): boolean { + return ( + readItem("sessionStorage", AUTH_TOKEN_KEY) != null && + readItem("localStorage", AUTH_TOKEN_KEY) == null + ); +} + +/** + * Save JWT token. When ``remember`` is false the token is kept in + * ``sessionStorage`` (cleared when the tab/window closes). SSO popups must + * post the token back to the opener — ``sessionStorage`` is not shared. + */ +export function setAuthToken(token: string, remember: boolean = true) { + if (remember) { + writeItem("localStorage", AUTH_TOKEN_KEY, token); + dropItem("sessionStorage", AUTH_TOKEN_KEY); + } else { + writeItem("sessionStorage", AUTH_TOKEN_KEY, token); + dropItem("localStorage", AUTH_TOKEN_KEY); + } + // Holding a fresh token means the previous login redirect already served its + // purpose, so re-arm the latch. Without this the one-shot guard in + // handleUnauthorized() stays set for the life of the page and every later + // expiry is swallowed: the user signs back in and is then left staring at + // failing requests instead of being sent to the login screen again. + _redirectingToLogin = false; + clearSetupRequired(); +} + +/** Get JWT token from localStorage or (session-only) sessionStorage. */ +export function getAuthToken(): string { + return ( + readItem("localStorage", AUTH_TOKEN_KEY) || + readItem("sessionStorage", AUTH_TOKEN_KEY) || + "" + ); +} + +/** Remove JWT token from both storages. */ export function clearAuthToken() { - localStorage.removeItem(AUTH_TOKEN_KEY); - localStorage.removeItem("octop:active-agent"); + dropItem("localStorage", AUTH_TOKEN_KEY); + dropItem("sessionStorage", AUTH_TOKEN_KEY); + dropItem("sessionStorage", AUTH_REMEMBER_PREF_KEY); + dropItem("localStorage", "octop:active-agent"); setActiveAgentId(null); } @@ -78,7 +147,7 @@ export function clearAuthToken() { export function applyRenewedAccessToken(response: Response): void { const renewed = response.headers.get(ACCESS_TOKEN_RESPONSE_HEADER); if (renewed) { - setAuthToken(renewed); + setAuthToken(renewed, !isSessionOnlyAuth()); } } @@ -179,11 +248,27 @@ export function getActiveAgentId(): string | null { return activeAgentId; } +/** + * Optional ``X-Octop-Agent-Id`` for caller-opted header tunnels (composer + * GETs, connector lists). Do not auto-attach these on settings pages. + */ +export function bridgeAgentHeaders( + agentId?: string | null, +): Record | undefined { + const id = (agentId ?? "").trim(); + if (!id) return undefined; + return { "X-Octop-Agent-Id": id }; +} + /** * Decide whether a request path is "agent-scoped" — i.e. talking to a * concrete agent's resource — and therefore should carry the * ``X-Octop-Agent-Id`` header. Health, admin, auth, setup, providers, and * personas don't need it. + * + * Composer / knowledge / browser host GETs are NOT auto-scoped: those + * settings pages must keep talking to this instance. Callers that need a + * remote hop pass ``bridgeAgentHeaders(agentId)`` explicitly. */ function isAgentScopedPath(path: string): boolean { // Match `/agents//...` (one trailing segment after the id). @@ -191,6 +276,10 @@ function isAgentScopedPath(path: string): boolean { if (/^\/agents\/[^/]+(\/|$)/.test(path)) return true; // MBTI endpoints that read/write the active agent's persona config. if (/^\/mbti\//.test(path)) return true; + if (path === "/cron/settings") return true; + if (path === "/subagent-catalog" || path.startsWith("/subagent-catalog/")) { + return true; + } return false; } diff --git a/dashboard/src/api/request.unauthorized.test.ts b/dashboard/src/api/request.unauthorized.test.ts index e9e72c7391..ff14b17f5d 100644 --- a/dashboard/src/api/request.unauthorized.test.ts +++ b/dashboard/src/api/request.unauthorized.test.ts @@ -22,6 +22,7 @@ describe("401 handling", () => { beforeEach(async () => { vi.resetModules(); localStorage.clear(); + sessionStorage.clear(); replace.mockClear(); Object.defineProperty(window, "location", { configurable: true, @@ -54,6 +55,24 @@ describe("401 handling", () => { expect(replace).toHaveBeenCalledWith("/login"); }); + it("redirects again after the user signs back in", async () => { + // First expiry navigates to the login screen. + await expect(mod.request("/agents")).rejects.toThrow(); + expect(replace).toHaveBeenCalledWith("/login"); + + // The user authenticates again; the module now holds a usable token. + mod.setAuthToken("fresh-token"); + expect(mod.getAuthToken()).toBe("fresh-token"); + + // A later expiry must navigate again. The latch exists to collapse a burst + // of parallel 401s into ONE navigation, not to disable the redirect for the + // rest of the session: a user who signs back in and then has their token + // expire again is otherwise left staring at failed requests forever. + await expect(mod.request("/agents")).rejects.toThrow(); + expect(replace).toHaveBeenCalledTimes(2); + expect(replace).toHaveBeenLastCalledWith("/login"); + }); + it("redirects only once when several requests fail concurrently", async () => { const listener = vi.fn((event: Event) => event.preventDefault()); window.addEventListener(mod.UNAUTHORIZED_EVENT, listener); diff --git a/dashboard/src/api/types/hitl.test.ts b/dashboard/src/api/types/hitl.test.ts new file mode 100644 index 0000000000..f0616f3891 --- /dev/null +++ b/dashboard/src/api/types/hitl.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + extractAskQuestions, + normalizeHitlRequest, + questionsFromUnknown, +} from "./hitl"; + +describe("hitl ask payload", () => { + it("unwraps a LangGraph interrupt envelope", () => { + const raw = normalizeHitlRequest({ + id: "int-1", + value: { + action_requests: [ + { + name: "ask_user_question", + args: { questions: [{ question: "Which DB?" }] }, + }, + ], + }, + }); + expect(extractAskQuestions(raw.action_requests as never)).toEqual([ + { + question: "Which DB?", + header: undefined, + multi_select: false, + options: [], + }, + ]); + }); + + it("parses questions from a JSON argument string", () => { + expect( + questionsFromUnknown( + JSON.stringify({ + questions: [{ question: "Ship it?", options: [{ label: "Yes" }] }], + }), + ), + ).toEqual([ + { + question: "Ship it?", + header: undefined, + multi_select: false, + options: [{ label: "Yes", description: undefined }], + }, + ]); + }); +}); diff --git a/dashboard/src/api/types/hitl.ts b/dashboard/src/api/types/hitl.ts index b5072415d8..f04da1ab13 100644 --- a/dashboard/src/api/types/hitl.ts +++ b/dashboard/src/api/types/hitl.ts @@ -44,6 +44,93 @@ export interface AskQuestion { multi_select?: boolean; } +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function parseJsonObject(raw: unknown): unknown { + if (typeof raw !== "string") return raw; + const text = raw.trim(); + if (!text) return raw; + try { + return JSON.parse(text) as unknown; + } catch { + return raw; + } +} + +/** Parse HITL ``args`` whether the provider sent an object or a JSON string. */ +export function parseActionArgs(raw: unknown): Record { + return asRecord(parseJsonObject(raw)) ?? {}; +} + +/** Unwrap LangGraph Interrupt envelopes so `action_requests` is top-level. */ +export function normalizeHitlRequest(raw: unknown): Record { + const fallback = asRecord(raw) ?? ({} as Record); + let current: unknown = raw; + for (let i = 0; i < 3; i += 1) { + const obj = asRecord(current); + if (!obj) return fallback; + if (Array.isArray(obj.action_requests)) return obj; + if (asRecord(obj.value)) { + current = obj.value; + continue; + } + break; + } + return fallback; +} + +function normalizeAskQuestion( + item: Record, +): AskQuestion | null { + const question = + typeof item.question === "string" + ? item.question + : typeof item.prompt === "string" + ? item.prompt + : typeof item.text === "string" + ? item.text + : ""; + if (!question.trim()) return null; + return { + question, + header: typeof item.header === "string" ? item.header : undefined, + multi_select: item.multi_select === true, + options: Array.isArray(item.options) + ? item.options + .filter((opt): opt is Record => + Boolean(asRecord(opt)), + ) + .map((opt) => ({ + label: typeof opt.label === "string" ? opt.label : "", + description: + typeof opt.description === "string" ? opt.description : undefined, + })) + .filter((opt) => opt.label) + : [], + }; +} + +/** Parse a `questions` array from tool args or a HITL interrupt value. */ +export function questionsFromUnknown(raw: unknown): AskQuestion[] { + const parsed = parseJsonObject(raw); + if (Array.isArray(parsed)) { + return parsed + .map((item) => asRecord(item)) + .filter((item): item is Record => item !== null) + .map(normalizeAskQuestion) + .filter((item): item is AskQuestion => item !== null); + } + const obj = asRecord(parsed); + if (!obj) return []; + if (obj.questions !== undefined) return questionsFromUnknown(obj.questions); + const single = normalizeAskQuestion(obj); + return single ? [single] : []; +} + /** Extract the `questions` payload from an `ask_user_question` pause. */ export function extractAskQuestions( actions: HitlActionRequest[] | undefined, @@ -51,32 +138,9 @@ export function extractAskQuestions( if (!actions?.length) return []; for (const action of actions) { if (action.name !== ASK_USER_TOOL_NAME) continue; - const raw = action.args?.questions; - if (!Array.isArray(raw)) continue; - return raw - .filter((item): item is Record => - Boolean(item && typeof item === "object" && !Array.isArray(item)), - ) - .map((item) => ({ - question: typeof item.question === "string" ? item.question : "", - header: typeof item.header === "string" ? item.header : undefined, - multi_select: item.multi_select === true, - options: Array.isArray(item.options) - ? item.options - .filter((opt): opt is Record => - Boolean(opt && typeof opt === "object" && !Array.isArray(opt)), - ) - .map((opt) => ({ - label: typeof opt.label === "string" ? opt.label : "", - description: - typeof opt.description === "string" - ? opt.description - : undefined, - })) - .filter((opt) => opt.label) - : [], - })) - .filter((q) => q.question); + const args = parseActionArgs(action.args); + const questions = questionsFromUnknown(args.questions ?? args); + if (questions.length > 0) return questions; } return []; } diff --git a/dashboard/src/api/types/provider.ts b/dashboard/src/api/types/provider.ts index 868a2f0881..b8c790785d 100644 --- a/dashboard/src/api/types/provider.ts +++ b/dashboard/src/api/types/provider.ts @@ -156,6 +156,12 @@ export interface TestModelDirectRequest { headers?: Record; } +export interface OllamaServiceStatus { + enabled: boolean; + running: boolean; + models_dir: string; +} + export interface OllamaDownloadTaskResponse { task_id: string; status: "pending" | "downloading" | "completed" | "failed" | "cancelled"; diff --git a/dashboard/src/api/types/skillPackage.ts b/dashboard/src/api/types/skillPackage.ts index 6e9540d617..2908b076df 100644 --- a/dashboard/src/api/types/skillPackage.ts +++ b/dashboard/src/api/types/skillPackage.ts @@ -6,6 +6,8 @@ export interface SkillPackage { creator_username?: string | null; creator_display_name?: string | null; can_write?: boolean; + can_copy?: boolean; + copy_policy?: "snapshot" | "lock" | "deny"; skill_count: number; icon_name?: string; icon_url?: string; @@ -51,6 +53,7 @@ export interface UpdateSkillPackageBody { description?: string; icon_name?: string; icon_url?: string; + copy_policy?: "snapshot" | "lock" | "deny"; } export interface CreateSkillPackageSkillBody { diff --git a/dashboard/src/assets/avatars/admin.png b/dashboard/src/assets/avatars/admin.png new file mode 100644 index 0000000000..06311aeb05 Binary files /dev/null and b/dashboard/src/assets/avatars/admin.png differ diff --git a/dashboard/src/assets/avatars/boss.png b/dashboard/src/assets/avatars/boss.png new file mode 100644 index 0000000000..7597a523e7 Binary files /dev/null and b/dashboard/src/assets/avatars/boss.png differ diff --git a/dashboard/src/assets/avatars/doctor.png b/dashboard/src/assets/avatars/doctor.png new file mode 100644 index 0000000000..b004e999f7 Binary files /dev/null and b/dashboard/src/assets/avatars/doctor.png differ diff --git a/dashboard/src/assets/avatars/finance.png b/dashboard/src/assets/avatars/finance.png new file mode 100644 index 0000000000..c03befe2ae Binary files /dev/null and b/dashboard/src/assets/avatars/finance.png differ diff --git a/dashboard/src/assets/avatars/nurse.png b/dashboard/src/assets/avatars/nurse.png new file mode 100644 index 0000000000..fcf250a840 Binary files /dev/null and b/dashboard/src/assets/avatars/nurse.png differ diff --git a/dashboard/src/assets/avatars/ops.png b/dashboard/src/assets/avatars/ops.png new file mode 100644 index 0000000000..19af662a79 Binary files /dev/null and b/dashboard/src/assets/avatars/ops.png differ diff --git a/dashboard/src/assets/avatars/staff.png b/dashboard/src/assets/avatars/staff.png new file mode 100644 index 0000000000..bdeebcb0a0 Binary files /dev/null and b/dashboard/src/assets/avatars/staff.png differ diff --git a/dashboard/src/assets/avatars/student.png b/dashboard/src/assets/avatars/student.png new file mode 100644 index 0000000000..ddbb44a437 Binary files /dev/null and b/dashboard/src/assets/avatars/student.png differ diff --git a/dashboard/src/assets/avatars/support.png b/dashboard/src/assets/avatars/support.png new file mode 100644 index 0000000000..3b9166b10a Binary files /dev/null and b/dashboard/src/assets/avatars/support.png differ diff --git a/dashboard/src/assets/avatars/teacher.png b/dashboard/src/assets/avatars/teacher.png new file mode 100644 index 0000000000..5432a850cd Binary files /dev/null and b/dashboard/src/assets/avatars/teacher.png differ diff --git a/dashboard/src/assets/channels/yuanbao.svg b/dashboard/src/assets/channels/yuanbao.svg index 5574bf720a..e9ac3c4491 100644 --- a/dashboard/src/assets/channels/yuanbao.svg +++ b/dashboard/src/assets/channels/yuanbao.svg @@ -1,17 +1 @@ - - - - - - - - - - - - - - - - - + diff --git a/dashboard/src/assets/connectors/agently-cli.png b/dashboard/src/assets/connectors/agently-cli.png new file mode 100644 index 0000000000..1491754baa Binary files /dev/null and b/dashboard/src/assets/connectors/agently-cli.png differ diff --git a/dashboard/src/assets/connectors/index.ts b/dashboard/src/assets/connectors/index.ts index dc0016387a..1847f2fb93 100644 --- a/dashboard/src/assets/connectors/index.ts +++ b/dashboard/src/assets/connectors/index.ts @@ -4,6 +4,7 @@ import ctripWendao from "./ctrip-wendao.png"; import didi from "./didi.svg"; import dida365 from "./dida365.png"; import dify from "./dify.svg"; +import agentlyCli from "./agently-cli.png"; import feishuCli from "./feishu-cli.png"; import fliggy from "./fliggy.png"; import meituanTravel from "./meituan-travel.png"; @@ -36,6 +37,7 @@ export const CONNECTOR_LOGOS: Record = { "meituan-travel": meituanTravel, yuandian, "tencent-ima": tencentIma, + "agently-cli": agentlyCli, "feishu-cli": feishuCli, "wecom-cli": wecomCli, "tencent-lexiang": tencentLexiang, diff --git a/dashboard/src/assets/skills/cloud.svg b/dashboard/src/assets/skills/cloud.svg new file mode 100644 index 0000000000..f99c16d232 --- /dev/null +++ b/dashboard/src/assets/skills/cloud.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/src/assets/skills/code.svg b/dashboard/src/assets/skills/code.svg new file mode 100644 index 0000000000..9bb74647ef --- /dev/null +++ b/dashboard/src/assets/skills/code.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dashboard/src/assets/skills/files.svg b/dashboard/src/assets/skills/files.svg new file mode 100644 index 0000000000..a38e62595b --- /dev/null +++ b/dashboard/src/assets/skills/files.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/src/assets/skills/generic.svg b/dashboard/src/assets/skills/generic.svg new file mode 100644 index 0000000000..33c84e993e --- /dev/null +++ b/dashboard/src/assets/skills/generic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dashboard/src/assets/skills/install-skill.svg b/dashboard/src/assets/skills/install-skill.svg new file mode 100644 index 0000000000..0fa5a993b1 --- /dev/null +++ b/dashboard/src/assets/skills/install-skill.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dashboard/src/assets/skills/memory.svg b/dashboard/src/assets/skills/memory.svg new file mode 100644 index 0000000000..2111d10668 --- /dev/null +++ b/dashboard/src/assets/skills/memory.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dashboard/src/assets/skills/news.svg b/dashboard/src/assets/skills/news.svg new file mode 100644 index 0000000000..b96fbd562d --- /dev/null +++ b/dashboard/src/assets/skills/news.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/src/assets/skills/settings.svg b/dashboard/src/assets/skills/settings.svg new file mode 100644 index 0000000000..364ff91d5e --- /dev/null +++ b/dashboard/src/assets/skills/settings.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/dashboard/src/assets/skills/sheets.svg b/dashboard/src/assets/skills/sheets.svg new file mode 100644 index 0000000000..6e3c96e524 --- /dev/null +++ b/dashboard/src/assets/skills/sheets.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/src/assets/skills/shield.svg b/dashboard/src/assets/skills/shield.svg new file mode 100644 index 0000000000..33d6353937 --- /dev/null +++ b/dashboard/src/assets/skills/shield.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dashboard/src/assets/skills/skill-manager.svg b/dashboard/src/assets/skills/skill-manager.svg new file mode 100644 index 0000000000..d650608113 --- /dev/null +++ b/dashboard/src/assets/skills/skill-manager.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dashboard/src/assets/skills/slides.svg b/dashboard/src/assets/skills/slides.svg new file mode 100644 index 0000000000..302b27e4b7 --- /dev/null +++ b/dashboard/src/assets/skills/slides.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dashboard/src/assets/skills/web-search.svg b/dashboard/src/assets/skills/web-search.svg new file mode 100644 index 0000000000..190c53bc54 --- /dev/null +++ b/dashboard/src/assets/skills/web-search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/src/assets/storage/custom.svg b/dashboard/src/assets/storage/custom.svg index b5fb3933c7..c80ab732ab 100644 --- a/dashboard/src/assets/storage/custom.svg +++ b/dashboard/src/assets/storage/custom.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/dashboard/src/assets/storage/s3.svg b/dashboard/src/assets/storage/s3.svg index 4a964974aa..f01ee04b0d 100644 --- a/dashboard/src/assets/storage/s3.svg +++ b/dashboard/src/assets/storage/s3.svg @@ -1,17 +1,30 @@ - - - - - - - - - - - - - - + + + + + diff --git a/dashboard/src/components/AgentProfileDrawer.test.tsx b/dashboard/src/components/AgentProfileDrawer.test.tsx index 0b55f9241b..ab6b8ef3b7 100644 --- a/dashboard/src/components/AgentProfileDrawer.test.tsx +++ b/dashboard/src/components/AgentProfileDrawer.test.tsx @@ -87,6 +87,18 @@ vi.mock("../pages/Agent/Workspace/components/WorkspaceDrawer", () => ({ default: () => null, })); +vi.mock("../context/AgentContext", () => ({ + useAgent: () => ({ + agents: [], + activeAgent: null, + activeAgentId: null, + loading: false, + error: null, + setActiveAgent: () => undefined, + refresh: async () => undefined, + }), +})); + import AgentProfileDrawer from "./AgentProfileDrawer"; const agent = { diff --git a/dashboard/src/components/AgentProfileDrawer.tsx b/dashboard/src/components/AgentProfileDrawer.tsx index 9f41db25ec..8ac6ed793c 100644 --- a/dashboard/src/components/AgentProfileDrawer.tsx +++ b/dashboard/src/components/AgentProfileDrawer.tsx @@ -11,6 +11,7 @@ import { ChevronLeft } from "lucide-react"; import { request } from "../api/request"; import { withFromWorkspace } from "../utils/fromWorkspace"; import type { OctopAgent } from "../context/AgentContext"; +import { useAgent } from "../context/AgentContext"; import { isAgentChatReady } from "../utils/agentError"; import { isTeamAgent } from "../utils/teamAgent"; import { @@ -113,6 +114,7 @@ export default function AgentProfileDrawer({ onClose, }: AgentProfileDrawerProps) { const { t } = useTranslation(); + const { agents } = useAgent(); const skillDisplayName = useSkillDisplayName(); const [loading, setLoading] = useState(false); const [filesLoading, setFilesLoading] = useState(false); @@ -132,6 +134,22 @@ export default function AgentProfileDrawer({ () => new Set(subagents.map((s) => s.slug)), [subagents], ); + const visibleTeamMembers = useMemo( + () => + teamMembers.map((member) => { + const found = agents.find((item) => item.agent_id === member.agent_id); + if (!found) return member; + return { + ...member, + name: found.name || member.name, + color: found.color ?? member.color, + icon_name: found.icon_name ?? member.icon_name, + icon_url: found.icon_url ?? member.icon_url, + state: found.state || member.state, + }; + }), + [teamMembers, agents], + ); const reloadSubagents = useCallback(async () => { if (!agent) return; @@ -371,14 +389,14 @@ export default function AgentProfileDrawer({
{t("chat.teamProfile.members")} - {teamMembers.length > 0 + {visibleTeamMembers.length > 0 ? ` · ${t("chat.teamProfile.memberCount", { - count: teamMembers.length, + count: visibleTeamMembers.length, })}` : ""}
- {teamMembers.length === 0 ? ( + {visibleTeamMembers.length === 0 ? (
) : ( - teamMembers.map((member) => { + visibleTeamMembers.map((member) => { const accent = member.color || "#0d9488"; const stateKey = member.state || "unknown"; return ( diff --git a/dashboard/src/components/AgentSelector.module.less b/dashboard/src/components/AgentSelector.module.less index 05d44b5a54..7ff90dcff6 100644 --- a/dashboard/src/components/AgentSelector.module.less +++ b/dashboard/src/components/AgentSelector.module.less @@ -1,9 +1,17 @@ .wrap { display: flex; - flex-direction: row; + flex-direction: column; + align-items: stretch; + gap: 8px; + min-width: 0; + width: 100%; +} + +.row { + display: flex; align-items: center; - flex-wrap: wrap; - gap: 10px 12px; + gap: 12px; + min-width: 0; } .label { @@ -16,11 +24,80 @@ } .bar { + position: relative; display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; gap: 6px; min-width: 0; + flex: 1; + overflow: hidden; +} + +.measure { + position: absolute; + left: 0; + top: 0; + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 6px 14px; + visibility: hidden; + pointer-events: none; +} + +.track { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 6px 14px; + min-width: 0; +} + +.group { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.groupLabel { + font-size: 11px; + font-weight: 500; + color: var(--fn-text-tertiary); + letter-spacing: 0.02em; + flex-shrink: 0; + line-height: 1; + white-space: nowrap; +} + +.moreBtn { + display: inline-flex; + align-items: center; + gap: 2px; + height: 26px; + padding: 0 4px; + border: none; + background: none; + color: var(--fn-text-tertiary); + font-size: 12px; + line-height: 1; + cursor: pointer; + flex-shrink: 0; + + &:hover { + color: var(--fn-text-secondary); + } +} + +.moreMenu { + max-height: 360px; + overflow-y: auto; +} + +.chipDisconnected { + opacity: 0.65; } .chip { @@ -39,7 +116,7 @@ border-color 0.15s, background 0.15s, box-shadow 0.15s; - max-width: 200px; + max-width: 228px; height: 26px; &:hover { @@ -96,22 +173,20 @@ flex-shrink: 0; background: var(--fn-text-quaternary); - /* Colour-blind-safe state palette (audit A11Y-6): green/amber/red hues - distinguished by lightness, all ≥3:1 on white as graphics. */ &[data-state="running"] { - background: #047857; + background: #22c55e; } &[data-state="stopped"], &[data-state="created"], &[data-state="stopping"], &[data-state="starting"] { - background: #b45309; + background: #eab308; } &[data-state="failed"], &[data-state="error"] { - background: #b91c1c; + background: #ef4444; } } @@ -174,3 +249,9 @@ text-overflow: ellipsis; white-space: nowrap; } + +.hint { + font-size: 12px; + color: var(--fn-text-tertiary); + line-height: 1.3; +} diff --git a/dashboard/src/components/AgentSelector.rules-of-hooks.test.tsx b/dashboard/src/components/AgentSelector.rules-of-hooks.test.tsx new file mode 100644 index 0000000000..0b2b2da775 --- /dev/null +++ b/dashboard/src/components/AgentSelector.rules-of-hooks.test.tsx @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { render } from "@testing-library/react"; +import AgentSelector from "./AgentSelector"; + +/** + * Regression guard: AgentSelector must not change hook count across renders. + * + * The shell mounts this component while AgentContext is still loading, and + * `loading` starts true, so the first render returns a spinner from an early + * return that sits above every hook in the picker. When agents arrive the + * same instance renders the full bar. Any hook declared after those early + * returns therefore runs on the second render only, and React throws + * "Rendered more hooks than during the previous render" the moment agents + * load - the shell crashes rather than the picker failing softly. + * + * Upstream hoisted the hooks above the early returns (95860cc4, "show teams in + * the memory and channels agent picker", #1588). This test exists so a later + * refactor cannot quietly reintroduce the ordering bug. + */ +const state = vi.hoisted(() => ({ + current: { + agents: [] as unknown[], + activeAgentId: null as string | null, + setActiveAgent: () => {}, + loading: true, + }, +})); + +vi.mock("../context/AgentContext", () => ({ useAgent: () => state.current })); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_k: string, d?: string) => d ?? _k }), +})); + +const agent = (id: string, name: string) => ({ + agent_id: id, + name, + state: "idle", +}); + +describe("AgentSelector rules of hooks", () => { + it("keeps hook order stable when loading flips to false", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + state.current = { + agents: [], + activeAgentId: null, + setActiveAgent: () => {}, + loading: true, + }; + const view = render(); + + state.current = { + agents: [agent("a1", "Alpha")], + activeAgentId: "a1", + setActiveAgent: () => {}, + loading: false, + }; + view.rerender(); + + const logged = spy.mock.calls.map((c) => String(c[0])).join("\n"); + spy.mockRestore(); + + expect(logged).not.toMatch(/Rendered (more|fewer) hooks/); + }); + + it("keeps hook order stable when the picker empties", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + state.current = { + agents: [agent("a1", "Alpha")], + activeAgentId: "a1", + setActiveAgent: () => {}, + loading: false, + }; + const view = render(); + + state.current = { + agents: [], + activeAgentId: null, + setActiveAgent: () => {}, + loading: false, + }; + view.rerender(); + + const logged = spy.mock.calls.map((c) => String(c[0])).join("\n"); + spy.mockRestore(); + + expect(logged).not.toMatch(/Rendered (more|fewer) hooks/); + }); +}); diff --git a/dashboard/src/components/AgentSelector.test.tsx b/dashboard/src/components/AgentSelector.test.tsx new file mode 100644 index 0000000000..6e43d87cdc --- /dev/null +++ b/dashboard/src/components/AgentSelector.test.tsx @@ -0,0 +1,135 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import type { OctopAgent } from "../context/AgentContext"; + +const state = { + agents: [] as OctopAgent[], + activeAgentId: "writer" as string | null, + loading: false, +}; + +const setActiveAgent = vi.fn((id: string | null) => { + state.activeAgentId = id; +}); + +vi.mock("../context/AgentContext", () => ({ + useAgent: () => ({ + get agents() { + return state.agents; + }, + get activeAgentId() { + return state.activeAgentId; + }, + get loading() { + return state.loading; + }, + setActiveAgent, + }), +})); + +import AgentSelector from "./AgentSelector"; + +function fakeAgent( + partial: Pick & Partial, +): OctopAgent { + return { + id: 1, + description: null, + persona_mbti: null, + default_model: null, + system_prompt: null, + template_name: null, + state: "running", + last_error: null, + icon: null, + icon_name: null, + icon_url: null, + color: "#2563eb", + config: {}, + is_owner: true, + kind: "expert", + ...partial, + }; +} + +const writer = fakeAgent({ agent_id: "writer", name: "写手" }); +const crew = fakeAgent({ + agent_id: "crew", + name: "调研团", + kind: "team", +}); +const remoteWriter = fakeAgent({ + agent_id: "bridge:c1:writer", + name: "对端写手", + bridge: true, + bridge_connection_id: "c1", + bridge_connection_name: "云端", +}); +const remoteCrew = fakeAgent({ + agent_id: "bridge:c1:crew", + name: "对端调研团", + kind: "team", + bridge: true, + bridge_connection_id: "c1", + bridge_connection_name: "云端", +}); + +describe("AgentSelector groups", () => { + beforeEach(() => { + setActiveAgent.mockClear(); + state.agents = [writer, crew]; + state.activeAgentId = "writer"; + state.loading = false; + }); + + it("hides teams unless showTeams is on", () => { + const { rerender } = render(); + const chips = () => within(screen.getByTestId("agent-selector-chips")); + expect(chips().getByText("写手")).toBeInTheDocument(); + expect(chips().queryByText("调研团")).not.toBeInTheDocument(); + expect(screen.getByText("专家")).toBeInTheDocument(); + expect(chips().queryByText("团队")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "更多" }), + ).not.toBeInTheDocument(); + expect(setActiveAgent).not.toHaveBeenCalled(); + + rerender(); + expect(chips().getByText("写手")).toBeInTheDocument(); + expect(chips().getByText("调研团")).toBeInTheDocument(); + expect(chips().getByText("专家")).toBeInTheDocument(); + expect(chips().getByText("团队")).toBeInTheDocument(); + expect(screen.queryByText("此页可管理专家和团队")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "更多" }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + }); + + it("keeps groups in one chip row, including remotes", () => { + state.agents = [writer, crew, remoteWriter, remoteCrew]; + render(); + const chips = within(screen.getByTestId("agent-selector-chips")); + expect(chips.getByText("专家")).toBeInTheDocument(); + expect(chips.getByText("团队")).toBeInTheDocument(); + expect(chips.getByText("云端·专家")).toBeInTheDocument(); + expect(chips.getByText("云端·团队")).toBeInTheDocument(); + expect(chips.getByText("对端写手")).toBeInTheDocument(); + expect(chips.getByText("对端调研团")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "更多" }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + }); + + it("keeps a hidden team selection and asks the user to pick an expert", () => { + state.activeAgentId = "crew"; + render(); + expect(setActiveAgent).not.toHaveBeenCalled(); + expect(screen.queryByText("调研团")).not.toBeInTheDocument(); + expect(screen.getByText("专家")).toBeInTheDocument(); + expect( + screen.getByText("当前是团队「调研团」,此页请选择专家"), + ).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/components/AgentSelector.tsx b/dashboard/src/components/AgentSelector.tsx index ece012e3d3..78e808247e 100644 --- a/dashboard/src/components/AgentSelector.tsx +++ b/dashboard/src/components/AgentSelector.tsx @@ -1,18 +1,34 @@ -import { Select, Spin } from "antd"; -import { useEffect, useMemo, useRef } from "react"; -import type { KeyboardEvent } from "react"; +import { Dropdown, Select, Spin } from "antd"; +import type { DefaultOptionType } from "antd/es/select"; +import type { MenuProps } from "antd"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { ChevronDown } from "lucide-react"; import { useAgent, type OctopAgent } from "../context/AgentContext"; -import { ownedSoloExperts } from "../utils/sharedExpert"; +import { ownedSoloExperts, ownedTeamAgents } from "../utils/sharedExpert"; +import { + labelSelectorGroups, + nextPickerSelection, + showSelectorGroupLabels, + splitBarOverflow, + type BarChipSize, +} from "../utils/agentSelector"; +import { isTeamAgent } from "../utils/teamAgent"; import { ExpertIcon } from "../pages/Experts/components/iconForName"; +import RemoteExpertHint from "../pages/Chat/components/RemoteExpertHint"; import styles from "./AgentSelector.module.less"; interface AgentSelectorProps { style?: React.CSSProperties; className?: string; - /** auto = chips when ≤6 agents, otherwise select */ + /** bar = chip row, more only on overflow; select = dropdown. */ variant?: "auto" | "select" | "bar"; showLabel?: boolean; + /** + * Include owned team hosts (memory / channels). Other pages stay + * expert-only. + */ + showTeams?: boolean; } function agentAccent(agent: OctopAgent): string { @@ -44,42 +60,97 @@ function AgentIcon({ ); } +function readBarChipSizes( + measure: HTMLElement, + showLabels: boolean, +): BarChipSize[] { + const labelWidth = new Map(); + if (showLabels) { + for (const el of measure.querySelectorAll( + "[data-group-label]", + )) { + labelWidth.set(el.dataset.groupKey ?? "", el.offsetWidth); + } + } + return [...measure.querySelectorAll("[data-chip-id]")].map( + (el) => { + const groupKey = el.dataset.groupKey ?? ""; + return { + id: el.dataset.chipId ?? "", + width: el.offsetWidth, + groupKey, + groupLabelWidth: labelWidth.get(groupKey) ?? 0, + }; + }, + ); +} + function AgentChip({ agent, active, onSelect, - tabRef, + measure, + groupKey, }: { agent: OctopAgent; active: boolean; onSelect: (id: string) => void; - tabRef?: Map; + measure?: boolean; + groupKey?: string; }) { + const { t } = useTranslation(); const accent = agentAccent(agent); + const disconnected = Boolean(agent.bridge_disconnected); + const title = disconnected + ? `${agent.name} · ${t("agentSelector.disconnected")}` + : agent.description ?? agent.name; return ( ); } +function moreItemLabel(agent: OctopAgent, disconnectedLabel: string) { + const accent = agentAccent(agent); + const disconnected = Boolean(agent.bridge_disconnected); + return ( + + + {agent.name} + {disconnected ? ( + {disconnectedLabel} + ) : null} + + + + ); +} + /** * Agent picker for agent-scoped pages. Persists selection via AgentContext. */ @@ -88,21 +159,95 @@ export default function AgentSelector({ className, variant = "auto", showLabel = true, + showTeams = false, }: AgentSelectorProps) { const { t } = useTranslation(); const { agents, activeAgentId, setActiveAgent, loading } = useAgent(); - const selectable = useMemo(() => ownedSoloExperts(agents), [agents]); + const soloSelectable = useMemo(() => ownedSoloExperts(agents), [agents]); + const allTeams = useMemo(() => ownedTeamAgents(agents), [agents]); + const teamSelectable = useMemo( + () => (showTeams ? allTeams : []), + [allTeams, showTeams], + ); + const selectable = useMemo( + () => [...soloSelectable, ...teamSelectable], + [soloSelectable, teamSelectable], + ); + const expertsLabel = t("agentSelector.expertsGroup", "专家"); + const teamsLabel = t("agentSelector.teamsGroup", "团队"); + const expertGroups = useMemo( + () => + labelSelectorGroups(soloSelectable, expertsLabel, (name) => + t("agentSelector.remoteKindGroup", { + name, + kind: expertsLabel, + defaultValue: "{{name}}·{{kind}}", + }), + ), + [expertsLabel, soloSelectable, t], + ); + const teamGroups = useMemo( + () => + labelSelectorGroups(teamSelectable, teamsLabel, (name) => + t("agentSelector.remoteKindGroup", { + name, + kind: teamsLabel, + defaultValue: "{{name}}·{{kind}}", + }), + ), + [t, teamSelectable, teamsLabel], + ); + const showGroupLabels = showSelectorGroupLabels( + expertGroups, + teamSelectable.length, + ); + const labeledGroups = [...expertGroups, ...teamGroups]; + const activeAgent = agents.find((agent) => agent.agent_id === activeAgentId); + const activeIsHiddenTeam = Boolean( + activeAgent && isTeamAgent(activeAgent) && !showTeams, + ); + const useBar = variant !== "select"; + const currentId = activeAgentId ?? selectable[0]?.agent_id; + const barRef = useRef(null); + const measureRef = useRef(null); + const [hiddenIds, setHiddenIds] = useState([]); useEffect(() => { - if (loading || selectable.length === 0) return; - if ( - activeAgentId && - selectable.some((agent) => agent.agent_id === activeAgentId) - ) { - return; - } - setActiveAgent(selectable[0]?.agent_id ?? null); - }, [activeAgentId, loading, selectable, setActiveAgent]); + if (loading) return; + const next = nextPickerSelection(activeAgentId, agents, selectable); + if (next === undefined) return; + setActiveAgent(next); + }, [activeAgentId, agents, loading, selectable, setActiveAgent]); + + useLayoutEffect(() => { + if (!useBar) return; + const bar = barRef.current; + const measure = measureRef.current; + if (!bar || !measure) return; + + const read = () => { + const moreWidth = + measure.querySelector("[data-more-sizer]")?.offsetWidth ?? + 0; + const { hiddenIds: next } = splitBarOverflow( + readBarChipSizes(measure, showGroupLabels), + currentId, + bar.clientWidth, + moreWidth, + ); + setHiddenIds((prev) => + prev.length === next.length && prev.every((id, i) => id === next[i]) + ? prev + : next, + ); + }; + + read(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(read); + observer.observe(bar); + return () => observer.disconnect(); + }, [currentId, showGroupLabels, selectable, useBar]); if (loading) { return ( @@ -112,117 +257,208 @@ export default function AgentSelector({ ); } - if (selectable.length === 0) return null; + if (selectable.length === 0 && allTeams.length === 0) return null; - const currentId = activeAgentId ?? selectable[0]?.agent_id; - const useBar = - variant === "bar" || (variant === "auto" && selectable.length <= 6); - const barTabRefs = useRef>(new Map()); + const disconnectedLabel = t("agentSelector.disconnected"); + const selectOptions: DefaultOptionType[] = showGroupLabels + ? labeledGroups.map((group) => ({ + label: group.disconnected + ? `${group.label} · ${disconnectedLabel}` + : group.label, + options: group.agents.map((agent) => + selectOption(agent, disconnectedLabel), + ), + })) + : selectable.map((agent) => selectOption(agent, disconnectedLabel)); + const hint = activeIsHiddenTeam + ? t("agentSelector.teamNeedsExpert", { + name: activeAgent?.name ?? "", + defaultValue: "当前是团队「{{name}}」,此页请选择专家", + }) + : soloSelectable.length === 0 && allTeams.length > 0 + ? t("agentSelector.expertsRequired", "此页需要专家") + : null; + const heading = t("agentSelector.label", "专家"); + const moreLabel = t("agentSelector.more", "更多"); + const hidden = new Set(hiddenIds); + const visibleGroups = labeledGroups + .map((group) => ({ + ...group, + agents: group.agents.filter((agent) => !hidden.has(agent.agent_id)), + })) + .filter((group) => group.agents.length > 0); + const overflowGroups = labeledGroups + .map((group) => ({ + ...group, + agents: group.agents.filter((agent) => hidden.has(agent.agent_id)), + })) + .filter((group) => group.agents.length > 0); + const showMore = overflowGroups.length > 0; + const moreItems: MenuProps["items"] = showGroupLabels + ? overflowGroups.map((group) => ({ + type: "group", + key: `${group.key}-${group.label}`, + label: group.disconnected + ? `${group.label} · ${disconnectedLabel}` + : group.label, + children: group.agents.map((agent) => ({ + key: agent.agent_id, + label: moreItemLabel(agent, disconnectedLabel), + })), + })) + : overflowGroups.flatMap((group) => + group.agents.map((agent) => ({ + key: agent.agent_id, + label: moreItemLabel(agent, disconnectedLabel), + })), + ); + + const renderGroup = ( + group: (typeof labeledGroups)[number], + measure: boolean, + ) => { + const groupKey = `${group.key}-${group.label}`; + return ( +
+ {showGroupLabels ? ( + + {group.label} + {group.disconnected ? ` · ${disconnectedLabel}` : ""} + + ) : null} + {group.agents.map((agent) => ( + + ))} +
+ ); + }; return (
- {showLabel && ( - {t("agentSelector.label")} - )} - - {useBar ? ( -
) => { - // Roving focus: Arrow/Home/End move selection without leaving - // the list (audit A11Y-9 — the tablist previously had no - // keyboard navigation and no exposed selection). - const keys = ["ArrowRight", "ArrowLeft", "Home", "End"]; - if (!keys.includes(event.key) || selectable.length === 0) return; - event.preventDefault(); - const idx = selectable.findIndex((a) => a.agent_id === currentId); - const next = - event.key === "ArrowRight" - ? (idx + 1) % selectable.length - : event.key === "ArrowLeft" - ? (idx - 1 + selectable.length) % selectable.length - : event.key === "Home" - ? 0 - : selectable.length - 1; - const target = selectable[next]; - if (!target) return; - setActiveAgent(target.agent_id); - barTabRefs.current.get(target.agent_id)?.focus(); - }} - > - {selectable.map((agent) => ( - - ))} -
- ) : ( - agent.agent_id === currentId) + ? currentId + : undefined + } + placeholder={heading} + onChange={(id) => setActiveAgent(id)} + listHeight={360} + popupMatchSelectWidth={320} + optionLabelProp="label" + options={selectOptions} + optionRender={(opt) => { + const agent = selectable.find((a) => a.agent_id === opt.value); + if (!agent) return opt.label; + const accent = agentAccent(agent); + const disconnected = Boolean(agent.bridge_disconnected); + return ( +
- {agent.name} - - ), - title: agent.name, - }; - })} - optionRender={(opt) => { - const agent = selectable.find((a) => a.agent_id === opt.value); - if (!agent) return opt.label; - const accent = agentAccent(agent); - return ( -
- -
-
- {agent.name} +
+
{agent.name}
+ {disconnected ? ( +
+ {disconnectedLabel} +
+ ) : agent.description ? ( +
+ {agent.description} +
+ ) : null}
- {agent.description ? ( -
- {agent.description} -
- ) : null} + +
- -
- ); - }} - /> - )} + ); + }} + /> + )} +
+ {hint ? {hint} : null}
); } + +function selectOption(agent: OctopAgent, disconnectedLabel: string) { + const accent = agentAccent(agent); + const disconnected = Boolean(agent.bridge_disconnected); + return { + value: agent.agent_id, + label: ( + + + {agent.name} + + + ), + title: disconnected ? `${agent.name} · ${disconnectedLabel}` : agent.name, + }; +} diff --git a/dashboard/src/components/AppDialog.tsx b/dashboard/src/components/AppDialog.tsx deleted file mode 100644 index d6b850b4c3..0000000000 --- a/dashboard/src/components/AppDialog.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; -import { createPortal } from "react-dom"; - -const FOCUSABLE = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - '[tabindex]:not([tabindex="-1"])', -].join(","); - -export type AppDialogProps = { - open: boolean; - onClose: () => void; - /** Accessible name for the dialog surface. */ - ariaLabel: string; - /** `dialog` for informational sheets, `alertdialog` for interrupting ones. */ - role?: "dialog" | "alertdialog"; - /** Dismiss on Escape and on overlay clicks. Alert dialogs may opt out. */ - dismissable?: boolean; - children: ReactNode; - className?: string; - style?: CSSProperties; -}; - -/** - * Minimal modal-dialog contract shared by the hand-rolled overlays that - * declare `aria-modal` (audit A11Y-5): moves focus in on open, traps - * Tab/Shift+Tab inside the subtree, closes on Escape, and restores focus to - * the trigger on unmount. - */ -export function AppDialog({ - open, - onClose, - ariaLabel, - role = "dialog", - dismissable = true, - children, - className, - style, -}: AppDialogProps) { - const containerRef = useRef(null); - const restoreFocusRef = useRef(null); - - useEffect(() => { - if (!open) return; - restoreFocusRef.current = document.activeElement; - const container = containerRef.current; - const first = container?.querySelector(FOCUSABLE); - (first ?? container)?.focus(); - return () => { - const prev = restoreFocusRef.current; - if (prev instanceof HTMLElement && document.contains(prev)) { - prev.focus(); - } - }; - }, [open]); - - const onKeyDown = useCallback( - (event: KeyboardEvent) => { - if (dismissable && event.key === "Escape") { - event.stopPropagation(); - onClose(); - return; - } - if (event.key !== "Tab") return; - const container = containerRef.current; - if (!container) return; - const focusables = Array.from( - container.querySelectorAll(FOCUSABLE), - ).filter((el) => el.offsetParent !== null || el === container); - if (focusables.length === 0) { - event.preventDefault(); - return; - } - const firstEl = focusables[0]; - const lastEl = focusables[focusables.length - 1]; - const active = document.activeElement; - if (event.shiftKey) { - if (active === firstEl || !container.contains(active)) { - event.preventDefault(); - lastEl.focus(); - } - } else if (active === lastEl || !container.contains(active)) { - event.preventDefault(); - firstEl.focus(); - } - }, - [dismissable, onClose], - ); - - if (!open) return null; - - return createPortal( -
e.target === e.currentTarget && onClose() - : undefined - } - > -
- {children} -
-
, - document.body, - ); -} diff --git a/dashboard/src/components/AuthFileDownloadLink.tsx b/dashboard/src/components/AuthFileDownloadLink.tsx index 116d818786..dc791c2f5e 100644 --- a/dashboard/src/components/AuthFileDownloadLink.tsx +++ b/dashboard/src/components/AuthFileDownloadLink.tsx @@ -12,6 +12,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { requestBlob } from "../api/request"; +import { saveBlobAsFile } from "../utils/saveBlobAsFile"; import { isDataUrl, needsAuthBlobFetch } from "../utils/toolMediaBlocks"; import { message as antMessage } from "@/utils/antdMessage"; @@ -72,14 +73,10 @@ export async function downloadAuthFile( blob = await requestBlob(apiPathForBlobFetch(url)); } - const objUrl = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = objUrl; - a.download = options?.filename || filenameFromUrl(url) || "download"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(objUrl), 100); + await saveBlobAsFile( + blob, + options?.filename || filenameFromUrl(url) || "download", + ); } const defaultLinkStyle: CSSProperties = { diff --git a/dashboard/src/components/AvatarDropdown.displayName.test.tsx b/dashboard/src/components/AvatarDropdown.displayName.test.tsx new file mode 100644 index 0000000000..430bc593f8 --- /dev/null +++ b/dashboard/src/components/AvatarDropdown.displayName.test.tsx @@ -0,0 +1,125 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "zh" }, + }), +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => vi.fn(), +})); + +vi.mock("../hooks/useUserRole", () => ({ + useUserRole: () => "user", +})); + +vi.mock("../hooks/useIsMobile", () => ({ + useIsMobile: () => false, +})); + +vi.mock("../context/LayoutModeContext", () => ({ + useLayoutMode: () => ({ + layoutMode: "classic", + setLayoutMode: vi.fn(), + }), +})); + +vi.mock("../api/modules/auth", () => ({ + authApi: { + me: () => Promise.resolve({ id: 1, username: "ada" }), + getOauthStatus: () => Promise.resolve({ providers: [] }), + updateProfile: vi.fn(), + logout: vi.fn(), + }, +})); + +vi.mock("../api/modules/preferences", () => ({ + preferencesApi: { setLocale: vi.fn() }, +})); + +vi.mock("./ThemeSwitcher", () => ({ default: () => null })); +vi.mock("./PaletteSwitcher", () => ({ default: () => null })); + +vi.mock("../pages/Admin/Users/ProfileAvatar", () => ({ + ProfileAvatar: () => , + ProfileAvatarPicker: () =>
, +})); + +import AvatarDropdown from "./AvatarDropdown"; + +const user = { + id: 1, + username: "ada", + role: "user", + display_name: "Ada", + locale: "zh", +}; + +async function openSettings() { + const view = userEvent.setup(); + render(); + await view.click(screen.getByRole("button", { name: /Ada/ })); + await view.click(screen.getByRole("button", { name: "account.settings" })); + return view; +} + +describe("AvatarDropdown personal settings", () => { + it("puts save and cancel in the drawer footer", async () => { + await openSettings(); + + const cancel = await screen.findByRole("button", { name: "common.cancel" }); + const save = screen.getByRole("button", { name: "common.save" }); + const footer = cancel.closest(".ant-drawer-footer"); + expect(footer).toContainElement(save); + expect( + screen.queryByRole("button", { name: "account.saveDisplayName" }), + ).not.toBeInTheDocument(); + }); + + it("shows display name, login name, and role on one line without a duplicate avatar", async () => { + await openSettings(); + + const identity = await waitFor(() => { + const handle = screen.getByText("@ada"); + const row = handle.parentElement; + expect(row).toBeTruthy(); + return row as HTMLElement; + }); + + expect(identity.textContent).toMatch( + /Ada[\s\S]*@ada[\s\S]*account\.roleUser/, + ); + expect(identity.querySelector("[data-testid='profile-avatar']")).toBeNull(); + expect(screen.getByTestId("avatar-picker")).toBeInTheDocument(); + }); + + it("opens settings and password as right drawers", async () => { + const view = userEvent.setup(); + render(); + + await view.click(screen.getByRole("button", { name: /Ada/ })); + await view.click(screen.getByRole("button", { name: "account.settings" })); + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + expect(document.querySelector(".ant-drawer-right")).toBeTruthy(); + expect(document.querySelector(".ant-modal")).toBeNull(); + + await view.click(screen.getByRole("button", { name: /close/i })); + await view.click(screen.getByRole("button", { name: /Ada/ })); + await view.click( + screen.getByRole("button", { name: "account.changePassword" }), + ); + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + expect(document.querySelector(".ant-drawer-right")).toBeTruthy(); + expect(document.querySelector(".ant-modal")).toBeNull(); + + const cancel = screen.getByRole("button", { name: "common.cancel" }); + const submit = screen.getByRole("button", { + name: "account.changePassword", + }); + expect(cancel.closest(".ant-drawer-footer")).toContainElement(submit); + }); +}); diff --git a/dashboard/src/components/AvatarDropdown.module.less b/dashboard/src/components/AvatarDropdown.module.less index 4a93a0aba6..2102a4163d 100644 --- a/dashboard/src/components/AvatarDropdown.module.less +++ b/dashboard/src/components/AvatarDropdown.module.less @@ -115,6 +115,30 @@ } } +.accountAvatar { + width: 32px; + height: 32px; + border-radius: 10px; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + img, + svg { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + + svg { + width: 18px; + height: 18px; + } +} + .triggerExpanded { display: flex; align-items: center; @@ -150,41 +174,24 @@ display: inline-flex; } -.settingsModal { - :global(.ant-modal-content) { - border-radius: 14px; - overflow: hidden; - } - - :global(.ant-modal-header) { - margin-bottom: 0; - padding-bottom: 12px; - } - - :global(.ant-modal-body) { - padding-top: 4px; - } -} - .settingsDrawer { - :global(.ant-drawer-content-wrapper) { - max-height: 92dvh; - } - - :global(.ant-drawer-content) { - border-radius: 16px 16px 0 0; - overflow: hidden; - } - - :global(.ant-drawer-header) { - padding: 14px 16px; - } - :global(.ant-drawer-body) { overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; } + + :global(.ant-drawer-footer) { + border-top: 1px solid var(--fn-border-primary); + padding: 12px 16px; + } +} + +.settingsDrawerFooter { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; } .settingsBody { @@ -196,43 +203,38 @@ .settingsIdentity { display: flex; align-items: center; - gap: 12px; - padding: 4px 0 16px; + gap: 8px; + min-width: 0; + padding: 0 0 8px; } -.settingsIdentityText { - min-width: 0; - flex: 1; +.settingsIdentityHandle { + flex-shrink: 0; + font-size: 13px; + color: var(--fn-text-tertiary); } .settingsIdentityName { - display: flex; - align-items: center; - gap: 8px; min-width: 0; - font-size: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; font-weight: 650; color: var(--fn-text-primary); line-height: 1.3; - - > span:first-child { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -.settingsIdentityHandle { - display: block; - margin-top: 2px; - font-size: 13px; - color: var(--fn-text-tertiary); } .settingsSection { padding: 4px 0; } +.settingsAvatarPicker { + > * { + margin-bottom: 0 !important; + } +} + .settingsSectionHead { margin-bottom: 12px; } diff --git a/dashboard/src/components/AvatarDropdown.tsx b/dashboard/src/components/AvatarDropdown.tsx index 52aebaf4ec..c4be017db0 100644 --- a/dashboard/src/components/AvatarDropdown.tsx +++ b/dashboard/src/components/AvatarDropdown.tsx @@ -1,7 +1,5 @@ import { useState, useCallback, useEffect, type ReactNode } from "react"; import { - Avatar, - Modal, Drawer, Form, Input, @@ -24,6 +22,9 @@ import { Github, RefreshCw, KeyRound, + Lock, + LockOpen, + SlidersHorizontal, } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -48,12 +49,21 @@ import { userCan } from "../utils/permissions"; import feishuIcon from "../assets/channels/feishu.svg"; import dingtalkIcon from "../assets/channels/dingtalk.svg"; import wecomIcon from "../assets/channels/wecom.svg"; +import { + ProfileAvatar, + ProfileAvatarPicker, +} from "../pages/Admin/Users/ProfileAvatar"; import styles from "./AvatarDropdown.module.less"; const GITHUB_URL = "https://github.com/TencentCloud/Octop"; const HELP_FEEDBACK_URL = "https://octop.cloud"; const APP_OAUTH_KINDS = new Set(["feishu", "dingtalk", "wecom"]); +const PASSWORD_FIELD_ICON_PROPS = { + size: 14 as const, + style: { color: "var(--fn-text-tertiary)" }, +}; + function oauthProviderIcon(kind: string): ReactNode { const src = kind === "feishu" @@ -79,6 +89,8 @@ interface AvatarDropdownProps { compact?: boolean; /** Called before opening settings / password panels (e.g. close mobile nav drawer). */ onBeforeOpenSettings?: () => void; + /** Opens the sidebar layout editor. Omitted when that editor is unavailable. */ + onCustomizeNav?: () => void; } export default function AvatarDropdown({ @@ -87,6 +99,7 @@ export default function AvatarDropdown({ placement = "default", compact = false, onBeforeOpenSettings, + onCustomizeNav, }: AvatarDropdownProps) { const { t, i18n } = useTranslation(); const navigate = useNavigate(); @@ -251,9 +264,6 @@ export default function AvatarDropdown({ role === "admin" ? t("account.roleAdmin") : t("account.roleUser"); const displayName = user?.display_name || user?.username || "—"; - const initials = (user?.display_name || user?.username || "?") - .charAt(0) - .toUpperCase(); /** Defer panel open so the account Popover / mobile sidebar can unmount first. */ const deferOpen = (open: () => void) => { @@ -274,6 +284,12 @@ export default function AvatarDropdown({ deferOpen(() => setPasswordOpen(true)); }; + const openCustomizeNav = () => { + setMenuOpen(false); + onBeforeOpenSettings?.(); + deferOpen(() => onCustomizeNav?.()); + }; + const closeSettings = () => setSettingsOpen(false); const closePassword = () => setPasswordOpen(false); @@ -317,17 +333,13 @@ export default function AvatarDropdown({ }, [onUserChange, t]); const avatar = ( - - {initials} - + ); const menuContent = ( @@ -379,6 +391,17 @@ export default function AvatarDropdown({ {t("account.projectUrl")} + {onCustomizeNav ? ( + + ) : null} + @@ -674,7 +739,10 @@ export default function AvatarDropdown({ }, ]} > - + } + /> - + } + /> - + } + /> -
@@ -752,71 +822,53 @@ export default function AvatarDropdown({ {triggerButton} - {isMobile ? ( - - {settingsBody} - - ) : ( - - {settingsBody} - - )} + + + +
+ } + > + {settingsBody} + - {isMobile ? ( - - {passwordBody} - - ) : ( - - {passwordBody} - - )} + + + +
+ } + > + {passwordBody} + ); } diff --git a/dashboard/src/components/BackendBuilder.tsx b/dashboard/src/components/BackendBuilder.tsx index 384dd9b511..ee2202773f 100644 --- a/dashboard/src/components/BackendBuilder.tsx +++ b/dashboard/src/components/BackendBuilder.tsx @@ -1,7 +1,7 @@ /** - * BackendBuilder — visual editor for harness-agent backend specs. + * BackendBuilder — visual editor for octop-harness backend specs. * - * The harness-agent backend system supports several types + * The octop-harness backend system supports several types * (local_shell, filesystem, state, store, s3, cos, postgres) and a * special ``composite`` type that routes by path-prefix to nested * sub-backends. This component lets the user pick one of: @@ -16,7 +16,7 @@ * is uniform. * * Composite nesting is intentionally **one level only**: nested - * composites are technically legal in harness-agent but rare in + * composites are technically legal in octop-harness but rare in * practice, and an unconstrained tree editor is hard to use without * dedicated UX. If a user needs nesting they can hand-edit the JSON * preview at the bottom (a planned follow-up). diff --git a/dashboard/src/components/BootOfflinePanel/BootOfflinePanel.module.less b/dashboard/src/components/BootOfflinePanel/BootOfflinePanel.module.less new file mode 100644 index 0000000000..7adda90e47 --- /dev/null +++ b/dashboard/src/components/BootOfflinePanel/BootOfflinePanel.module.less @@ -0,0 +1,16 @@ +.wrap { + height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + background: var(--fn-bg-layout, #f7f8fa); + padding: 24px 24px 18vh; + box-sizing: border-box; +} + +.result { + :global(.ant-result-icon) { + display: flex; + justify-content: center; + } +} diff --git a/dashboard/src/components/BootOfflinePanel/index.tsx b/dashboard/src/components/BootOfflinePanel/index.tsx index 1fa25e42b2..314a1cf216 100644 --- a/dashboard/src/components/BootOfflinePanel/index.tsx +++ b/dashboard/src/components/BootOfflinePanel/index.tsx @@ -1,5 +1,7 @@ import { Button, Result } from "antd"; import { useTranslation } from "react-i18next"; +import { OctopEmptyMascot } from "../EmptyState"; +import styles from "./BootOfflinePanel.module.less"; interface BootOfflinePanelProps { /** Defaults to a full page reload. */ @@ -22,20 +24,10 @@ export default function BootOfflinePanel({ onRetry }: BootOfflinePanelProps) { }; return ( -
+
} title={t("errors.offlineTitle")} subTitle={t("errors.offlineSubtitle")} extra={ diff --git a/dashboard/src/components/BrowserAiPanel.tsx b/dashboard/src/components/BrowserAiPanel.tsx index 9ab87afc3b..f9f3c75f2c 100644 --- a/dashboard/src/components/BrowserAiPanel.tsx +++ b/dashboard/src/components/BrowserAiPanel.tsx @@ -193,13 +193,10 @@ export default function BrowserAiPanel({ if (onSkillNameSet) onSkillNameSet(objective); setRecordingPhase("awaitDescription"); chatStore.appendPushMessage( - `✅ 任务目标已设定:**${objective}**\n\n` + - "接下来请描述你想让AI助手在浏览器中执行的具体操作。\n\n" + - `例如:"打开B站、小红书和百度,搜索今天天气"。\n\n` + - '描述完成后AI会自动执行操作;操作结束后输入"结束"即可停止录制。', + t("remoteBrowser.ai.objectiveSet", { objective }), ); }, - [onSkillNameSet], + [onSkillNameSet, t], ); const handleDescriptionInput = useCallback( @@ -239,25 +236,32 @@ export default function BrowserAiPanel({ const previewText = previewLines.join("\n"); const truncationNotice = data.skillContent.split("\n").length > 20 - ? `\n\n... (共 ${ - data.skillContent.split("\n").length - } 行,完整内容将在确认后保存)` + ? t("skillRecord.previewTruncation", { + lines: data.skillContent.split("\n").length, + }) : ""; chatStore.appendPushMessage( - `✅ 录制完成!已生成 ${data.steps ?? 0} 个回放步骤。\n\n` + - `📝 **技能「${effectiveSkillName}」脚本预览:**\n\n${previewText}${truncationNotice}\n\n` + - '回复"确认"即可应用此技能。\n\n' + - `应用后,只需输入 **"${effectiveSkillName}"** 即可一键触发回放相同操作流程。`, + t("remoteBrowser.ai.previewMessage", { + steps: data.steps ?? 0, + name: effectiveSkillName, + preview: previewText, + truncation: truncationNotice, + }), ); } else { chatStore.appendPushMessage( - `✅ 录制完成!已生成 ${data.steps ?? 0} 个回放步骤。\n\n` + - `⚠️ 技能脚本生成失败,请稍后重试。`, + t("remoteBrowser.ai.previewNoScript", { steps: data.steps ?? 0 }), ); } - antMessage.success(`录制完成,已生成 ${data.steps ?? 0} 个回放步骤`); + antMessage.success( + t("skillRecord.recordingStopped", { steps: data.steps ?? 0 }), + ); } catch (err) { - antMessage.error(err instanceof Error ? err.message : "停止录制失败"); + antMessage.error( + err instanceof Error + ? err.message + : t("skillRecord.stopFailed", "停止录制失败"), + ); } finally { workflowBusyRef.current = false; } @@ -268,6 +272,7 @@ export default function BrowserAiPanel({ setBrowserRecording, setBrowserRecordingId, setBrowserLastRecordingId, + t, ]); const handleConfirmSkill = useCallback(async () => { @@ -299,16 +304,19 @@ export default function BrowserAiPanel({ prev.includes(finalName) ? prev : [...prev, finalName], ); chatStore.appendPushMessage( - `🎉 技能 **「${finalName}」** 已成功应用!\n\n` + - `之后只需输入 **"${finalName}"** 即可一键触发回放相同的浏览器操作流程。`, + t("remoteBrowser.ai.skillAppliedMessage", { name: finalName }), ); - antMessage.success(`技能 "${finalName}" 已成功应用`); + antMessage.success(t("skillRecord.skillApplied", { name: finalName })); } catch (err) { - antMessage.error(err instanceof Error ? err.message : "应用技能失败"); + antMessage.error( + err instanceof Error + ? err.message + : t("skillRecord.applyFailed", "应用技能失败"), + ); } finally { workflowBusyRef.current = false; } - }, [pendingSkillContent, pendingSkillName, agentId]); + }, [pendingSkillContent, pendingSkillName, agentId, t]); const handleSkillReplay = useCallback( async (skillKeyword: string) => { @@ -326,9 +334,11 @@ export default function BrowserAiPanel({ const recordingId = recordingIdMatch?.[1] ?? browserLastRecordingId; if (!recordingId) { chatStore.appendPushMessage( - `⚠️ 无法找到技能「${skillKeyword}」的录制记录,回放失败。`, + t("remoteBrowser.ai.noRecordingFound", { name: skillKeyword }), + ); + antMessage.error( + t("remoteBrowser.ai.recordingNotFound", "找不到录制记录"), ); - antMessage.error("找不到录制记录"); return; } const data = await browserApi.replayRecording({ @@ -340,26 +350,44 @@ export default function BrowserAiPanel({ t("browser.recordReplay.replayPassed", "回放完成"), ); chatStore.appendPushMessage( - `🎬 Browser-Skill「${skillKeyword}」回放完成!`, + t("remoteBrowser.ai.replayDone", { name: skillKeyword }), ); } else { const globalError = data.error || ""; const failedSteps = (data.steps || []) .filter((s) => s.status === "failed") - .map((s) => `步骤 ${s.id} (${s.kind}): ${s.error || "执行失败"}`); + .map((s) => + t("remoteBrowser.ai.stepLine", { + id: s.id, + kind: s.kind, + error: s.error || t("remoteBrowser.ai.stepFailed", "执行失败"), + }), + ); const detailMsg = globalError || (failedSteps.length > 0 ? failedSteps.join("\n") : "") || t("browser.recordReplay.replayFailed", "回放失败"); antMessage.error(detailMsg); chatStore.appendPushMessage( - `⚠️ 技能「${skillKeyword}」回放失败:\n${detailMsg}`, + t("remoteBrowser.ai.replayFailedDetail", { + name: skillKeyword, + detail: detailMsg, + }), ); } } catch (err) { - antMessage.error(err instanceof Error ? err.message : "回放失败"); + antMessage.error( + err instanceof Error + ? err.message + : t("browser.recordReplay.replayFailed", "回放失败"), + ); chatStore.appendPushMessage( - `⚠️ 技能回放出错:${err instanceof Error ? err.message : "未知错误"}`, + t("remoteBrowser.ai.replayError", { + error: + err instanceof Error + ? err.message + : t("remoteBrowser.ai.unknownError", "未知错误"), + }), ); } finally { workflowBusyRef.current = false; @@ -380,17 +408,23 @@ export default function BrowserAiPanel({ }); if (data.status === "passed") { antMessage.success(t("browser.recordReplay.replayPassed", "回放完成")); - chatStore.appendPushMessage("🔄 技能回放完成!所有步骤已成功执行。"); + chatStore.appendPushMessage(t("remoteBrowser.ai.replayComplete")); } else { antMessage.error( data.error || t("browser.recordReplay.replayFailed", "回放失败"), ); chatStore.appendPushMessage( - `⚠️ 技能回放失败:${data.error || "未知错误"}`, + t("remoteBrowser.ai.replayFailedSimple", { + error: data.error || t("remoteBrowser.ai.unknownError", "未知错误"), + }), ); } } catch (err) { - antMessage.error(err instanceof Error ? err.message : "回放失败"); + antMessage.error( + err instanceof Error + ? err.message + : t("browser.recordReplay.replayFailed", "回放失败"), + ); } finally { setBrowserReplayBusy(false); } @@ -426,7 +460,9 @@ export default function BrowserAiPanel({ return; } if (isSkillTriggerCommand(text)) { - chatStore.appendPushMessage(`🔄 正在使用技能「${text}」进行回放...`); + chatStore.appendPushMessage( + t("remoteBrowser.ai.replayingSkill", { text }), + ); void handleSkillReplay(text); return; } @@ -445,6 +481,7 @@ export default function BrowserAiPanel({ handleEndRecording, handleConfirmSkill, handleSkillReplay, + t, ]); const handleKeyDown = useCallback( @@ -485,11 +522,9 @@ export default function BrowserAiPanel({ messages.length > 0 && !workflowBusyRef.current ) { - chatStore.appendPushMessage( - '💡 录制还在进行中。如果操作已完成,输入"结束"即可停止录制并生成技能脚本。', - ); + chatStore.appendPushMessage(t("remoteBrowser.ai.recordingHint")); } - }, [isStreaming, browserRecording, recordingPhase, messages.length]); + }, [isStreaming, browserRecording, recordingPhase, messages.length, t]); const inputDisabled = isStreaming || booting || !threadId || browserReplayBusy; @@ -638,7 +673,6 @@ export default function BrowserAiPanel({ messages={messages} isStreaming={isStreaming} sessionKey={threadId ?? undefined} - onCancel={cancelStream} /> )}
@@ -691,11 +725,19 @@ export default function BrowserAiPanel({
diff --git a/dashboard/src/components/BrowserWorkspace/index.tsx b/dashboard/src/components/BrowserWorkspace/index.tsx index 10c54a7cd6..4846ce2458 100644 --- a/dashboard/src/components/BrowserWorkspace/index.tsx +++ b/dashboard/src/components/BrowserWorkspace/index.tsx @@ -16,6 +16,7 @@ import { User, } from "lucide-react"; import { api } from "../../api"; +import { bridgeApi } from "../../api/modules/bridge"; import type { BrowserSession, DisplayEnvironment, @@ -52,6 +53,8 @@ interface BrowserWorkspaceProps { hideHeaderRefresh?: boolean; /** Expose reconnect handler to the parent dock toolbar. */ onRefreshReady?: (refresh: () => void) => void; + /** When set, sessions / handoff / screencast go through the peer Bridge. */ + bridgeConnectionId?: string | null; } const BrowserWorkspace: React.FC = ({ @@ -62,6 +65,7 @@ const BrowserWorkspace: React.FC = ({ onToggleBookmark, hideHeaderRefresh = false, onRefreshReady, + bridgeConnectionId = null, }) => { const { t } = useTranslation(); // Viewport defaults to a fixed 1280×800 — suitable when the view is small @@ -114,6 +118,7 @@ const BrowserWorkspace: React.FC = ({ resolveViewport, defaultViewport: { width: 1440, height: 900 }, onError: (msg) => showApiError(msg, t("browserViewer.connectFailed"), t), + bridgeConnectionId, }); // Connect on mount and whenever the attached session or viewport mode @@ -126,7 +131,7 @@ const BrowserWorkspace: React.FC = ({ disconnect(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessionId, vpMode]); + }, [sessionId, vpMode, bridgeConnectionId]); // Keep the URL bar in sync with the active tab — but never stomp while typing. useEffect(() => { @@ -191,18 +196,23 @@ const BrowserWorkspace: React.FC = ({ // Initial HTTP fetch for session metadata (handoff buttons, current URL). useEffect(() => { - if (!sessionId) { - setSession(null); - return; - } let cancelled = false; (async () => { try { - const resp = await api.getSessions(); + const resp = bridgeConnectionId + ? await bridgeApi.getBrowserSessions(bridgeConnectionId) + : await api.getSessions(); if (cancelled) return; if (resp.ok) { - const found = resp.sessions.find((s) => s.session_id === sessionId); - if (found) setSession(found); + if (sessionId) { + const found = resp.sessions.find((s) => s.session_id === sessionId); + if (found) setSession(found); + else if (resp.sessions[0]) setSession(resp.sessions[0]); + } else if (resp.sessions[0]) { + setSession(resp.sessions[0]); + } else { + setSession(null); + } } } catch { // Ignore @@ -211,7 +221,7 @@ const BrowserWorkspace: React.FC = ({ return () => { cancelled = true; }; - }, [sessionId]); + }, [sessionId, bridgeConnectionId]); const controlOwner: "agent" | "user" = session?.control_owner ?? "agent"; const stateLabel = session?.state ?? "idle"; @@ -256,17 +266,24 @@ const BrowserWorkspace: React.FC = ({ // ------------------------------------------------------------------------- const handleHandoff = useCallback( async (target: "agent" | "user") => { - const sid = sessionId ?? sessionInfo?.session_id; + const sid = sessionId ?? sessionInfo?.session_id ?? session?.session_id; if (!sid) return; try { - const resp = await api.handoff(sid, target, "user_button"); - if (resp.ok) setSession(resp.session); + const resp = bridgeConnectionId + ? await bridgeApi.browserHandoff( + bridgeConnectionId, + sid, + target, + "user_button", + ) + : await api.handoff(sid, target, "user_button"); + if (resp.ok) setSession(resp.session as BrowserSession); } catch (err) { console.error("Handoff failed:", err); showApiError(err, t("browserWorkspace.handoffFailed"), t); } }, - [sessionId, sessionInfo, t], + [sessionId, sessionInfo, session, bridgeConnectionId, t], ); const handleRetry = useCallback(() => { diff --git a/dashboard/src/components/CatalogTypeCard/index.tsx b/dashboard/src/components/CatalogTypeCard/index.tsx index 3d8f438a16..350f9e7093 100644 --- a/dashboard/src/components/CatalogTypeCard/index.tsx +++ b/dashboard/src/components/CatalogTypeCard/index.tsx @@ -37,13 +37,7 @@ export const CatalogTypeCard = memo(function CatalogTypeCard({ onClick={() => !disabled && onClick()} role="button" tabIndex={disabled ? -1 : 0} - aria-disabled={disabled || undefined} - onKeyDown={(e) => { - if ((e.key === "Enter" || e.key === " ") && !disabled) { - e.preventDefault(); - onClick(); - } - }} + onKeyDown={(e) => e.key === "Enter" && !disabled && onClick()} > {configuredBadge} diff --git a/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx b/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx index fe481a3401..bbbe62d7ea 100644 --- a/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx +++ b/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx @@ -12,9 +12,14 @@ interface SearchablePickerPanelProps { emptyMessage: string; width?: PickerPanelWidth; renderItem: (item: T) => ReactNode; + /** Optional grouping: consecutive items with the same key get a header. */ + getGroupKey?: (item: T) => string; + renderGroupHeader?: (key: string, firstItem: T) => ReactNode; footerIcon: ReactNode; footerLabel: string; onFooterClick: () => void; + /** Soften the footer (e.g. remote-bridge “edit on peer” hint). */ + footerMuted?: boolean; /** Optional row(s) rendered between the list and the primary footer. */ beforeFooter?: ReactNode; } @@ -26,9 +31,12 @@ export default function SearchablePickerPanel({ emptyMessage, width = "wide", renderItem, + getGroupKey, + renderGroupHeader, footerIcon, footerLabel, onFooterClick, + footerMuted = false, beforeFooter, }: SearchablePickerPanelProps) { const { query, setQuery, filtered } = useFilteredList(items, filterFn); @@ -40,8 +48,8 @@ export default function SearchablePickerPanel({ : styles.panelWide; return ( -
-
+
+
({
-
+
{filtered.length === 0 ? (
{emptyMessage}
) : ( - filtered.map((item) => renderItem(item)) + filtered.map((item, index) => { + const groupKey = getGroupKey?.(item) ?? ""; + const prevKey = + index > 0 ? getGroupKey?.(filtered[index - 1]) ?? "" : null; + const showHeader = + Boolean(getGroupKey && renderGroupHeader) && groupKey !== prevKey; + return ( +
+ {showHeader ? renderGroupHeader?.(groupKey, item) : null} + {renderItem(item)} +
+ ); + }) )}
{beforeFooter} - diff --git a/dashboard/src/components/ChatPicker/picker.module.less b/dashboard/src/components/ChatPicker/picker.module.less index ed75c27738..56d06fddb4 100644 --- a/dashboard/src/components/ChatPicker/picker.module.less +++ b/dashboard/src/components/ChatPicker/picker.module.less @@ -87,6 +87,16 @@ } } +.footerMuted { + color: var(--fn-text-tertiary); + cursor: default; + + &:hover { + background: transparent; + color: var(--fn-text-tertiary); + } +} + .itemText { min-width: 0; flex: 1; diff --git a/dashboard/src/components/ChromeTabBar/index.tsx b/dashboard/src/components/ChromeTabBar/index.tsx index 1231e6ada0..2d7e542ea4 100644 --- a/dashboard/src/components/ChromeTabBar/index.tsx +++ b/dashboard/src/components/ChromeTabBar/index.tsx @@ -1,8 +1,6 @@ -import { useCallback, useRef } from "react"; -import type { KeyboardEvent, MouseEvent, ReactNode } from "react"; +import type { MouseEvent, ReactNode } from "react"; import { Tooltip } from "antd"; import { Plus, X } from "lucide-react"; -import { useTranslation } from "react-i18next"; import styles from "./index.module.less"; export type ChromeTabItem = { @@ -42,44 +40,15 @@ export function ChromeTabBar({ trailing, className, }: ChromeTabBarProps) { - const { t } = useTranslation(); - const tabRefs = useRef>(new Map()); - - const onTabKeyDown = useCallback( - (event: KeyboardEvent) => { - const idx = tabs.findIndex((tab) => tab.key === activeKey); - let next = -1; - if (event.key === "ArrowRight") next = (idx + 1) % tabs.length; - else if (event.key === "ArrowLeft") - next = (idx - 1 + tabs.length) % tabs.length; - else if (event.key === "Home") next = 0; - else if (event.key === "End") next = tabs.length - 1; - else return; - event.preventDefault(); - const target = tabs[next]; - if (!target) return; - onChange(target.key); - tabRefs.current.get(target.key)?.focus(); - }, - [tabs, activeKey, onChange], - ); - return (
{tabs.map((tab) => { const selected = tab.key === activeKey; const node = ( - +
); if (tab.tooltip != null && tab.tooltip !== "") { diff --git a/dashboard/src/components/DocumentPreviewCore.tsx b/dashboard/src/components/DocumentPreviewCore.tsx index 75b095a9db..978972299b 100644 --- a/dashboard/src/components/DocumentPreviewCore.tsx +++ b/dashboard/src/components/DocumentPreviewCore.tsx @@ -510,6 +510,13 @@ export default function DocumentPreviewCore({ if (!cancelled) setLoading(false); } catch (err) { if (cancelled || isAbortError(err)) return; + // Surface the real cause (chunk load / ZIP parse / library error) — + // the UI only shows a generic "无法加载预览". + console.error( + "[DocumentPreviewCore] preview failed", + { kind, filename }, + err, + ); setError(isNotFoundApiError(err) ? "missing" : "error"); setLoading(false); } diff --git a/dashboard/src/components/EmptyState/EmptyState.module.less b/dashboard/src/components/EmptyState/EmptyState.module.less index 9fc0e33246..3284b47a84 100644 --- a/dashboard/src/components/EmptyState/EmptyState.module.less +++ b/dashboard/src/components/EmptyState/EmptyState.module.less @@ -2,6 +2,7 @@ display: block; width: 160px; height: 160px; + margin: 0 auto; object-fit: contain; user-select: none; -webkit-user-drag: none; diff --git a/dashboard/src/components/EmptyState/index.tsx b/dashboard/src/components/EmptyState/index.tsx index e00024a5d5..c485b25b0f 100644 --- a/dashboard/src/components/EmptyState/index.tsx +++ b/dashboard/src/components/EmptyState/index.tsx @@ -42,7 +42,6 @@ export function EmptyState({ ) : variant === "mascot" ? ( @@ -51,7 +50,6 @@ export function EmptyState({ ); diff --git a/dashboard/src/components/ExpertColorPicker.tsx b/dashboard/src/components/ExpertColorPicker.tsx index a064883d54..ee4586564a 100644 --- a/dashboard/src/components/ExpertColorPicker.tsx +++ b/dashboard/src/components/ExpertColorPicker.tsx @@ -62,7 +62,6 @@ export default function ExpertColorPicker({ aria-label={t("experts.customColor")} aria-pressed={!curated} role="button" - tabIndex={0} > ({ - default: { - initialize, - }, -})); - -describe("mermaidLoader security configuration", () => { - beforeEach(() => { - vi.resetModules(); - initialize.mockClear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("initializes with securityLevel 'strict', never 'loose'", async () => { - const { loadMermaid } = await import("./mermaidLoader"); - await loadMermaid(); - expect(initialize).toHaveBeenCalledTimes(1); - const config = initialize.mock.calls[0][0] as Record; - expect(config.securityLevel).toBe("strict"); - expect(config.securityLevel).not.toBe("loose"); - }); - - it("initializes exactly once across repeated loads", async () => { - const { loadMermaid } = await import("./mermaidLoader"); - await loadMermaid(); - await loadMermaid(); - await loadMermaid(); - expect(initialize).toHaveBeenCalledTimes(1); - }); -}); diff --git a/dashboard/src/components/Markdown/mermaidLoader.ts b/dashboard/src/components/Markdown/mermaidLoader.ts index e7aacc3cfc..d8f9c75a28 100644 --- a/dashboard/src/components/Markdown/mermaidLoader.ts +++ b/dashboard/src/components/Markdown/mermaidLoader.ts @@ -12,11 +12,7 @@ export async function loadMermaid() { mermaidModule.initialize({ startOnLoad: false, theme: "default", - // Diagram content comes from LLM / IM users, so it is untrusted input. - // "loose" skips DOMPurify on the rendered SVG and permits click - // handlers — a stored-XSS vector (audit SEC-5). "strict" keeps HTML - // labels but sanitizes the SVG before it is attached to the DOM. - securityLevel: "strict", + securityLevel: "loose", fontFamily: "inherit", }); } diff --git a/dashboard/src/components/MbtiPersonaTag.tsx b/dashboard/src/components/MbtiPersonaTag.tsx index 71c90e46c9..c4e45ad0bd 100644 --- a/dashboard/src/components/MbtiPersonaTag.tsx +++ b/dashboard/src/components/MbtiPersonaTag.tsx @@ -9,57 +9,17 @@ interface MbtiPersonaTagProps { onClick?: () => void; } -const MBTI_ZH_NAMES: Record = { - INTJ: "建筑师", - INTP: "逻辑学家", - ENTJ: "指挥官", - ENTP: "辩论家", - INFJ: "提倡者", - INFP: "调停者", - ENFJ: "主人公", - ENFP: "竞选者", - ISTJ: "物流师", - ISFJ: "守卫者", - ESTJ: "总经理", - ESFJ: "执政官", - ISTP: "鉴赏家", - ISFP: "探险家", - ESTP: "企业家", - ESFP: "表演者", -}; - -const MBTI_EN_NAMES: Record = { - INTJ: "Architect", - INTP: "Logician", - ENTJ: "Commander", - ENTP: "Debater", - INFJ: "Advocate", - INFP: "Mediator", - ENFJ: "Protagonist", - ENFP: "Campaigner", - ISTJ: "Logistician", - ISFJ: "Defender", - ESTJ: "Executive", - ESFJ: "Consul", - ISTP: "Virtuoso", - ISFP: "Adventurer", - ESTP: "Entrepreneur", - ESFP: "Entertainer", -}; - export default function MbtiPersonaTag({ value, showDefault = true, onClick, }: MbtiPersonaTagProps) { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); if (!value && !showDefault) return null; const code = value?.toUpperCase() ?? ""; - const localizedName = - code && - (i18n.language === "zh" ? MBTI_ZH_NAMES[code] : MBTI_EN_NAMES[code]); + const localizedName = code && t(`mbti.${code}`, { defaultValue: "" }); const label = code ? localizedName ? `${code} ${localizedName}` diff --git a/dashboard/src/components/MobileAiPanel.tsx b/dashboard/src/components/MobileAiPanel.tsx index 2376d2a037..c9ac6b3588 100644 --- a/dashboard/src/components/MobileAiPanel.tsx +++ b/dashboard/src/components/MobileAiPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from "react"; import { Button, Spin, Tooltip } from "antd"; -import { Bot, Send, X } from "lucide-react"; +import { Bot, Send, Square, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { OctopAgent } from "../context/AgentContext"; import AgentSelector from "./AgentSelector"; @@ -183,7 +183,6 @@ export default function MobileAiPanel({ messages={messages} isStreaming={isStreaming} sessionKey={threadId ?? undefined} - onCancel={cancelStream} /> )}
@@ -212,11 +211,15 @@ export default function MobileAiPanel({
diff --git a/dashboard/src/components/PaletteSwitcher.tsx b/dashboard/src/components/PaletteSwitcher.tsx index e1917265b5..beb63a8ee2 100644 --- a/dashboard/src/components/PaletteSwitcher.tsx +++ b/dashboard/src/components/PaletteSwitcher.tsx @@ -49,7 +49,6 @@ export default function PaletteSwitcher() { aria-label={t("header.palette.custom")} aria-pressed={isCustom} role="button" - tabIndex={0} > agent.agent_id === activeAgentId && agent.bridge, + ); + if (!isPeer) return null; + return ( + + ); +} diff --git a/dashboard/src/components/PwaInstallPrompt/index.tsx b/dashboard/src/components/PwaInstallPrompt/index.tsx index 09e44d907a..65e18b3c42 100644 --- a/dashboard/src/components/PwaInstallPrompt/index.tsx +++ b/dashboard/src/components/PwaInstallPrompt/index.tsx @@ -1,8 +1,8 @@ import { useEffect, useSyncExternalStore, useState } from "react"; +import { createPortal } from "react-dom"; import { Tooltip } from "antd"; import { useTranslation } from "react-i18next"; import { Download, MonitorDown, Share, X } from "lucide-react"; -import { AppDialog } from "../AppDialog"; import { subscribePwaPrompt, getPwaInstallSnapshot, @@ -35,44 +35,52 @@ function isStandalone(): boolean { export function IosGuide({ onClose }: { onClose: () => void }) { const { t } = useTranslation(); - return ( - { + if (e.target === e.currentTarget) onClose(); + }} >
-
{t("pwaInstall.iosTitle")}
-

{t("pwaInstall.iosDesc")}

+
+ {t("pwa.addToHomeScreen", "添加到主屏幕")} +
+

+ {t("pwa.installAsAppDesc", "将 Octop 安装为 App,随时一键打开。")} +

  1. 1 - {t("pwaInstall.iosStep1Prefix")}{" "} - {" "} - {t("pwaInstall.iosStep1Suffix")} + {t("pwa.iosStep1Prefix", "点击底部工具栏的")}{" "} + {" "} + {t("pwa.iosStep1Suffix", "分享按钮")}
  2. 2 - {t("pwaInstall.iosStep2")} + {t("pwa.iosStep2", "向下滚动,点击「添加到主屏幕」")}
  3. 3 - {t("pwaInstall.iosStep3")} + {t("pwa.iosStep3", "点击右上角「添加」完成安装")}
↓
-
+
, + document.body, ); } @@ -81,54 +89,68 @@ export function IosGuide({ onClose }: { onClose: () => void }) { export function DesktopInstallGuide({ onClose }: { onClose: () => void }) { const { t } = useTranslation(); const isEdge = /edg/i.test(navigator.userAgent); - return ( - { + if (e.target === e.currentTarget) onClose(); + }} >
-
{t("pwaInstall.desktopTitle")}
-

{t("pwaInstall.desktopDesc")}

+
+ {t("pwa.installDesktopApp", "安装为桌面应用")} +
+

+ {t( + "pwa.desktopGuideDesc", + "浏览器尚未准备好一键安装。请按以下方式操作,或刷新页面后再点 Header 中的安装按钮。", + )} +

  1. 1 - {t("pwaInstall.desktopStep1Prefix")}{" "} - {" "} - {t("pwaInstall.desktopStep1Suffix")} + {t("pwa.desktopStep1Prefix", "查看地址栏右侧的")}{" "} + {" "} + {t("pwa.desktopStep1Suffix", "安装图标并点击")}
  2. 2 - {t("pwaInstall.desktopStep2Prefix")} - {isEdge - ? t("pwaInstall.browserEdge") - : t("pwaInstall.browserInstall")} - {t("pwaInstall.desktopStep2Suffix")} + {t("pwa.desktopStep2", { + action: isEdge + ? t("pwa.menuActionApply", "应用") + : t("pwa.menuActionInstall", "安装"), + })}
  3. 3 - {t("pwaInstall.desktopStep3")} + + {t("pwa.desktopStep3", "确认安装后,可从桌面或程序坞一键打开")} +
-

{t("pwaInstall.desktopHint")}

+

+ {t( + "pwa.desktopHint", + "通过局域网 IP 访问时需使用 HTTPS,否则浏览器不会提供安装选项。", + )} +

-
+
, + document.body, ); } @@ -152,6 +174,7 @@ export default function PwaInstallPrompt({ compact, appearance = "default", }: PwaInstallPromptProps) { + const { t } = useTranslation(); const installState = useSyncExternalStore( subscribePwaPrompt, getPwaInstallSnapshot, @@ -207,10 +230,8 @@ export default function PwaInstallPrompt({ }; const tooltipTitle = ios - ? "添加到主屏幕" - : installState.prompt - ? "安装为桌面应用" - : "安装为桌面应用"; + ? t("pwa.addToHomeScreen", "添加到主屏幕") + : t("pwa.installDesktopApp", "安装为桌面应用"); const btnClass = chatFloat ? styles.installBtnChatFloat @@ -222,15 +243,16 @@ export default function PwaInstallPrompt({ className={btnClass} onClick={() => void handleAndroidInstall()} disabled={installing} - aria-label="安装应用" + aria-label={t("pwa.installApp", "安装应用")} > - {!compact && !chatFloat && 安装} + {!compact && !chatFloat && ( + {t("pwa.install", "安装")} + )} ); diff --git a/dashboard/src/components/RemoteDisconnectBanner.tsx b/dashboard/src/components/RemoteDisconnectBanner.tsx new file mode 100644 index 0000000000..2b91a824f0 --- /dev/null +++ b/dashboard/src/components/RemoteDisconnectBanner.tsx @@ -0,0 +1,46 @@ +import { Alert, Button } from "antd"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +interface RemoteDisconnectBannerProps { + connectionName?: string | null; + /** Peer-initiated link: this side cannot redial. */ + inbound?: boolean; + style?: React.CSSProperties; +} + +/** Shown while a cloud-collab expert stays selected after the link drops. */ +export default function RemoteDisconnectBanner({ + connectionName, + inbound, + style, +}: RemoteDisconnectBannerProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const name = (connectionName ?? "").trim(); + const hint = inbound + ? name + ? t("chat.remoteExpert.waitForPeerNamed", { name }) + : t("chat.remoteExpert.waitForPeer") + : name + ? t("chat.remoteExpert.disconnectedHintNamed", { name }) + : t("chat.remoteExpert.disconnectedHint"); + return ( + navigate("/bridge")}> + {t( + inbound + ? "chat.remoteExpert.openBridge" + : "chat.remoteExpert.reconnect", + )} + + } + style={{ marginBottom: 12, ...style }} + /> + ); +} diff --git a/dashboard/src/components/ServiceRestartOverlay.tsx b/dashboard/src/components/ServiceRestartOverlay.tsx index 3c12f670ae..528baadd5a 100644 --- a/dashboard/src/components/ServiceRestartOverlay.tsx +++ b/dashboard/src/components/ServiceRestartOverlay.tsx @@ -1,10 +1,10 @@ import { useEffect } from "react"; import type { CSSProperties } from "react"; +import { createPortal } from "react-dom"; import { CheckCircle, AlertTriangle, Power } from "lucide-react"; import { Button, Spin } from "antd"; import { useTranslation } from "react-i18next"; import type { RestartPhase } from "../hooks/useServiceRestart"; -import { AppDialog } from "./AppDialog"; import styles from "./ServiceRestartOverlay.module.less"; const OVERLAY_Z_INDEX = 2_147_483_000; @@ -82,23 +82,18 @@ export default function ServiceRestartOverlay({ if (phase === "idle") return null; - return ( - {})} - ariaLabel={t("advancedSettings.update.restarting")} + return createPortal( +
-
+
{phase === "confirm" && ( <> - +

{t("advancedSettings.update.restartConfirmTitle")}

@@ -130,7 +125,7 @@ export default function ServiceRestartOverlay({ )} {phase === "success" && ( <> - +

{t("advancedSettings.update.restartSuccess")}

@@ -141,7 +136,7 @@ export default function ServiceRestartOverlay({ )} {phase === "timeout" && ( <> - +

{t("advancedSettings.update.restartTimeout")}

@@ -158,6 +153,7 @@ export default function ServiceRestartOverlay({ )}
- +
, + document.body, ); } diff --git a/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.module.less b/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.module.less index 4c5589037a..81a00b96b4 100644 --- a/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.module.less +++ b/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.module.less @@ -151,28 +151,43 @@ margin-top: 4px; } +.actionTooltipWrap { + display: inline-flex; +} + @media (max-width: 767px) { .wrap { - padding: 16px; + align-items: flex-start; + padding: 12px 16px 16px; } .plain { - padding: 40px 16px 32px; + padding: 12px 16px 24px; } .card { - padding: 28px 20px 24px; + padding: 20px 16px 20px; } .plain .card { padding: 0; } + .plain .title { + margin-top: 0; + } + .actions { - flex-direction: column; + display: grid; + grid-template-columns: 1fr; :global(.ant-btn) { width: 100%; } } + + .actionTooltipWrap { + display: block; + width: 100%; + } } diff --git a/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.test.tsx b/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.test.tsx new file mode 100644 index 0000000000..1a60d04fb9 --- /dev/null +++ b/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const isMobileRef = { current: false }; + +vi.mock("../../hooks/useIsMobile", () => ({ + useIsMobile: () => isMobileRef.current, +})); + +import StreamSetupGuide from "./StreamSetupGuide"; + +function renderGuide() { + return render( + } + title="Guide" + steps={[]} + primaryAction={{ + label: "启动浏览器", + onClick: () => undefined, + }} + secondaryAction={{ + label: "检查", + onClick: () => undefined, + type: "default", + title: "检查本机是否已准备好浏览器", + }} + />, + ); +} + +describe("StreamSetupGuide actions", () => { + it("keeps desktop actions content-sized", () => { + isMobileRef.current = false; + renderGuide(); + + const start = screen.getByRole("button", { name: "启动浏览器" }); + const check = screen.getByRole("button", { name: /检\s*查/ }); + expect(start.className).not.toMatch(/ant-btn-block/); + expect(check.className).not.toMatch(/ant-btn-block/); + }); + + it("stretches mobile actions to the same full width", () => { + isMobileRef.current = true; + renderGuide(); + + const start = screen.getByRole("button", { name: "启动浏览器" }); + const check = screen.getByRole("button", { name: /检\s*查/ }); + expect(start.className).toMatch(/ant-btn-block/); + expect(check.className).toMatch(/ant-btn-block/); + }); +}); diff --git a/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.tsx b/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.tsx index b8f3d5fcc1..b6f3e55909 100644 --- a/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.tsx +++ b/dashboard/src/components/StreamSetupGuide/StreamSetupGuide.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { Button, Tooltip } from "antd"; +import { useIsMobile } from "../../hooks/useIsMobile"; import styles from "./StreamSetupGuide.module.less"; export interface SetupGuideStep { @@ -39,8 +40,11 @@ interface StreamSetupGuideProps { } function ActionButton({ action }: { action: SetupGuideAction }) { + const isMobile = useIsMobile(); const button = ( + ); + })} +
+ ) : null} + {numeric != null && numeric > 0 ? ( +
+ {t("common.tokenCountPreview", { + label: formatTokenScaleLabel(numeric), + exact: formatExact(numeric), + })} +
+ ) : null} +
+ ); +} diff --git a/dashboard/src/context/AgentContext.test.ts b/dashboard/src/context/AgentContext.test.ts index c14d3fcc4a..e668987618 100644 --- a/dashboard/src/context/AgentContext.test.ts +++ b/dashboard/src/context/AgentContext.test.ts @@ -107,6 +107,8 @@ describe("projectChatAgentOption", () => { is_shared: 1, is_owner: 0, owner_username: "alice", + bridge: true, + bridge_connection_name: "lab", }), ); expect(projected).toEqual({ @@ -118,6 +120,12 @@ describe("projectChatAgentOption", () => { is_shared: true, is_owner: false, owner_username: "alice", + bridge: true, + bridge_connection_id: null, + bridge_connection_name: "lab", + bridge_connection_icon: null, + bridge_disconnected: false, + bridge_inbound: false, }); }); @@ -126,5 +134,25 @@ describe("projectChatAgentOption", () => { expect(projected.owner_username).toBeNull(); expect(projected.is_shared).toBe(false); expect(projected.is_owner).toBe(false); + expect(projected.bridge).toBe(false); + expect(projected.bridge_connection_id).toBeNull(); + expect(projected.bridge_connection_name).toBeNull(); + expect(projected.bridge_connection_icon).toBeNull(); + expect(projected.bridge_disconnected).toBe(false); + expect(projected.bridge_inbound).toBe(false); + }); + + it("keeps a disconnected remote expert in the enabled list", () => { + const agents = [ + agent("bridge:cid:aid", "stopped", { + bridge: true, + bridge_disconnected: true, + }), + agent("L1", "running"), + ]; + expect(selectEnabledExperts(agents, null).map((a) => a.agent_id)).toEqual([ + "bridge:cid:aid", + "L1", + ]); }); }); diff --git a/dashboard/src/context/AgentContext.tsx b/dashboard/src/context/AgentContext.tsx index 2e48d285f0..cb2415fbb6 100644 --- a/dashboard/src/context/AgentContext.tsx +++ b/dashboard/src/context/AgentContext.tsx @@ -9,6 +9,10 @@ import { import type { ReactNode } from "react"; import { setActiveAgentId } from "../api/request"; import { agentApi as legacyAgentApi } from "../api/modules/agent"; +import { + retainDisconnectedBridgeAgents, + toBridgeShadowAgentId, +} from "../utils/remoteExpert"; /** * Multi-Agent navigation state. @@ -64,6 +68,17 @@ export interface OctopAgent { /** Member agent ids when ``kind === "team"``. */ member_ids?: string[]; welcome_message?: string | null; + /** True when this row is a remote shadow expert via cloud collab. */ + bridge?: boolean; + bridge_connection_id?: string | null; + /** Display name of the cloud-collab link (chat group label). */ + bridge_connection_name?: string | null; + /** Icon selected for the cloud-collab link (shown on remote expert badges). */ + bridge_connection_icon?: string | null; + /** True while the cloud-collab link dropped but this shadow is still pinned. */ + bridge_disconnected?: boolean; + /** True when this shadow arrived via a peer-initiated (inbound) link. */ + bridge_inbound?: boolean; } interface AgentContextValue { @@ -115,7 +130,9 @@ export function selectEnabledExperts( options: EnabledExpertsOptions = {}, ): OctopAgent[] { const { pinActive = false } = options; - const enabled = agents.filter((a) => a.state === "running"); + const enabled = agents.filter( + (a) => a.state === "running" || Boolean(a.bridge_disconnected), + ); if (!pinActive || !resolvedAgentId) return enabled; if (enabled.some((a) => a.agent_id === resolvedAgentId)) return enabled; const pinnedActive = agents.find((a) => a.agent_id === resolvedAgentId); @@ -144,6 +161,12 @@ export function projectChatAgentOption(agent: OctopAgent): { is_shared: boolean; is_owner: boolean; owner_username: string | null; + bridge: boolean; + bridge_connection_id: string | null; + bridge_connection_name: string | null; + bridge_connection_icon: string | null; + bridge_disconnected: boolean; + bridge_inbound: boolean; } { return { agent_id: agent.agent_id, @@ -154,6 +177,12 @@ export function projectChatAgentOption(agent: OctopAgent): { is_shared: Boolean(agent.is_shared), is_owner: Boolean(agent.is_owner), owner_username: agent.owner_username ?? null, + bridge: Boolean(agent.bridge), + bridge_connection_id: agent.bridge_connection_id ?? null, + bridge_connection_name: agent.bridge_connection_name ?? null, + bridge_connection_icon: agent.bridge_connection_icon ?? null, + bridge_disconnected: Boolean(agent.bridge_disconnected), + bridge_inbound: Boolean(agent.bridge_inbound), }; } @@ -176,20 +205,135 @@ interface ListAgentsResponse { list: () => Promise; } +interface FetchAgentsResult { + agents: OctopAgent[]; + liveConnectionIds: Set; +} + /** * Fetch ``/api/agents``. Tries the orca-flavored ``listAll`` method first, * falls back to a direct request if the legacy module hasn't been * regenerated yet. */ -async function fetchAgents(): Promise { +async function fetchAgents(): Promise { const candidate = legacyAgentApi as Partial & Record; + let local: OctopAgent[] = []; if (typeof candidate.list === "function") { - return candidate.list(); + local = await candidate.list(); + } else { + // Direct fallback so 14.3 doesn't depend on 14.6's API module rewrite. + const { request } = await import("../api/request"); + local = await request("/agents"); } - // Direct fallback so 14.3 doesn't depend on 14.6's API module rewrite. - const { request } = await import("../api/request"); - return request("/agents"); + const { remote, liveConnectionIds } = await fetchBridgeShadowAgents(); + if (remote.length === 0) { + return { agents: local, liveConnectionIds }; + } + const localIds = new Set(local.map((a) => a.agent_id)); + return { + agents: [...local, ...remote.filter((a) => !localIds.has(a.agent_id))], + liveConnectionIds, + }; +} + +async function fetchBridgeShadowAgents(): Promise<{ + remote: OctopAgent[]; + liveConnectionIds: Set; +}> { + const empty = { + remote: [] as OctopAgent[], + liveConnectionIds: new Set(), + }; + try { + const { bridgeApi } = await import("../api/modules/bridge"); + const connections = await bridgeApi.list(); + const liveConnectionIds = new Set( + connections.map((c) => c.connection_id).filter(Boolean), + ); + const connected = connections.filter((c) => c.status === "connected"); + if (connected.length === 0) { + return { remote: [], liveConnectionIds }; + } + const batches = await Promise.all( + connected.map(async (conn) => { + try { + const agents = await bridgeApi.listAgents(conn.connection_id); + return agents.map((agent) => mapBridgeAgent(agent, conn)); + } catch { + return [] as OctopAgent[]; + } + }), + ); + return { remote: batches.flat(), liveConnectionIds }; + } catch { + // Non-admin users or cloud-collab-unavailable installs — ignore. + return empty; + } +} + +function mapBridgeAgent( + agent: { + id?: string; + agent_id?: string; + name?: string; + description?: string | null; + icon_url?: string | null; + icon_name?: string | null; + color?: string | null; + kind?: string | null; + state?: string | null; + member_ids?: unknown; + is_owner?: boolean; + is_shared?: boolean; + }, + conn: { + connection_id: string; + display_name: string; + icon_name?: string | null; + inbound?: boolean; + has_password?: boolean; + }, +): OctopAgent { + const agentId = String(agent.agent_id || agent.id || ""); + const memberIds = Array.isArray(agent.member_ids) + ? agent.member_ids + .map((id) => + toBridgeShadowAgentId(conn.connection_id, String(id ?? "")), + ) + .filter((id): id is string => Boolean(id)) + : undefined; + return { + id: 0, + agent_id: agentId, + name: String(agent.name || agentId), + description: + typeof agent.description === "string" ? agent.description : null, + persona_mbti: null, + default_model: null, + system_prompt: null, + template_name: null, + state: "running", + last_error: null, + icon: null, + icon_name: typeof agent.icon_name === "string" ? agent.icon_name : null, + icon_url: typeof agent.icon_url === "string" ? agent.icon_url : null, + color: typeof agent.color === "string" ? agent.color : null, + config: {}, + kind: agent.kind === "team" ? "team" : "expert", + member_ids: memberIds, + bridge: true, + is_owner: agent.is_owner !== false, + is_shared: agent.is_shared === true, + bridge_disconnected: false, + bridge_inbound: Boolean(conn.inbound) || conn.has_password === false, + bridge_connection_id: conn.connection_id, + bridge_connection_name: conn.display_name, + bridge_connection_icon: + typeof conn.icon_name === "string" && conn.icon_name.trim() + ? conn.icon_name.trim() + : null, + }; } export function AgentProvider({ children }: { children: ReactNode }) { @@ -219,16 +363,22 @@ export function AgentProvider({ children }: { children: ReactNode }) { } setError(null); try { - const list = await fetchAgents(); + const { agents: list, liveConnectionIds } = await fetchAgents(); // Only update state when content actually changed, to prevent // unnecessary re-renders of every component subscribed to this context // (the chat page polls every 10 s to refresh unread badges). + let merged: OctopAgent[] = list; setAgents((prev) => { + merged = retainDisconnectedBridgeAgents( + prev, + list, + liveConnectionIds, + ); if ( !options?.force && - prev.length === list.length && + prev.length === merged.length && prev.every((a, i) => { - const b = list[i]; + const b = merged[i]; return ( a.agent_id === b.agent_id && a.state === b.state && @@ -237,24 +387,33 @@ export function AgentProvider({ children }: { children: ReactNode }) { a.name === b.name && a.icon === b.icon && a.icon_name === b.icon_name && + a.icon_url === b.icon_url && a.color === b.color && a.kind === b.kind && + a.bridge === b.bridge && + a.is_owner === b.is_owner && + a.bridge_connection_id === b.bridge_connection_id && + a.bridge_connection_name === b.bridge_connection_name && + a.bridge_disconnected === b.bridge_disconnected && + a.bridge_inbound === b.bridge_inbound && sameMemberIds(a.member_ids, b.member_ids) ); }) ) { + merged = prev; return prev; // nothing changed — keep the same reference } - return list; + return merged; }); - // Reconcile selection with what the server reports. + // Reconcile selection with what the server reports. Keep a + // disconnected remote pin instead of jumping to the first local expert. const stored = localStorage.getItem(STORAGE_KEY); - const haveStored = stored && list.some((a) => a.agent_id === stored); + const haveStored = stored && merged.some((a) => a.agent_id === stored); if (haveStored) { persistAndApply(stored); - } else if (list.length > 0) { - persistAndApply(list[0].agent_id); + } else if (merged.length > 0) { + persistAndApply(merged[0].agent_id); } else { persistAndApply(null); } diff --git a/dashboard/src/hooks/useAgentFormResources.test.ts b/dashboard/src/hooks/useAgentFormResources.test.ts new file mode 100644 index 0000000000..34c806561d --- /dev/null +++ b/dashboard/src/hooks/useAgentFormResources.test.ts @@ -0,0 +1,53 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { listResolvedModels, request } = vi.hoisted(() => ({ + listResolvedModels: vi.fn(), + request: vi.fn(), +})); + +vi.mock("../api/modules/provider", () => ({ + providerApi: { listResolvedModels }, +})); + +vi.mock("../api/request", () => ({ request })); + +vi.mock("../pages/Admin/Storage/useStorageBackends", () => ({ + isAgentResolvableStorageKind: (kind: string) => kind === "s3", +})); + +import { useAgentFormResources } from "./useAgentFormResources"; + +describe("useAgentFormResources", () => { + beforeEach(() => { + listResolvedModels.mockReset(); + request.mockReset(); + listResolvedModels.mockResolvedValue([{ id: "peer-model" }]); + request.mockResolvedValue([ + { id: 1, name: "local-s3", kind: "s3", enabled: true }, + ]); + }); + + it("loads local models and storage backends without an agent id", async () => { + const { result } = renderHook(() => useAgentFormResources(true)); + + await waitFor(() => expect(result.current.modelsLoading).toBe(false)); + expect(listResolvedModels).toHaveBeenCalledWith(undefined); + expect(request).toHaveBeenCalledWith("/storage-backends"); + expect(result.current.models).toEqual([{ id: "peer-model" }]); + expect(result.current.backends).toEqual([ + { id: 1, name: "local-s3", kind: "s3", enabled: true }, + ]); + }); + + it("tunnels model lists for a peer expert and skips local storage backends", async () => { + const agentId = "bridge:c1:doctor"; + const { result } = renderHook(() => useAgentFormResources(true, agentId)); + + await waitFor(() => expect(result.current.modelsLoading).toBe(false)); + expect(listResolvedModels).toHaveBeenCalledWith(agentId); + expect(request).not.toHaveBeenCalled(); + expect(result.current.backends).toEqual([]); + expect(result.current.backendsLoading).toBe(false); + }); +}); diff --git a/dashboard/src/hooks/useAgentFormResources.ts b/dashboard/src/hooks/useAgentFormResources.ts index c8e4f3dfaf..33770c8912 100644 --- a/dashboard/src/hooks/useAgentFormResources.ts +++ b/dashboard/src/hooks/useAgentFormResources.ts @@ -3,6 +3,7 @@ import { providerApi } from "../api/modules/provider"; import { request } from "../api/request"; import { isAgentResolvableStorageKind } from "../pages/Admin/Storage/useStorageBackends"; import type { ModelPickerOption } from "../utils/modelOptions"; +import { isBridgeAgentId } from "../utils/remoteExpert"; export interface AgentStorageBackend { id: number; @@ -21,8 +22,12 @@ export interface AgentFormResources { /** * Load resolved models + enabled storage backends for agent create/edit drawers. + * Pass a Bridge shadow ``agentId`` so model lists hop to the peer. */ -export function useAgentFormResources(enabled: boolean): AgentFormResources { +export function useAgentFormResources( + enabled: boolean, + agentId?: string | null, +): AgentFormResources { const [models, setModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [backends, setBackends] = useState([]); @@ -40,7 +45,7 @@ export function useAgentFormResources(enabled: boolean): AgentFormResources { setBackendsLoading(true); void providerApi - .listResolvedModels() + .listResolvedModels(agentId) .then((data) => { if (!cancelled) setModels(data as ModelPickerOption[]); }) @@ -51,27 +56,33 @@ export function useAgentFormResources(enabled: boolean): AgentFormResources { if (!cancelled) setModelsLoading(false); }); - void request("/storage-backends") - .then((data) => { - if (!cancelled) { - setBackends( - data.filter( - (b) => b.enabled && isAgentResolvableStorageKind(b.kind), - ), - ); - } - }) - .catch(() => { - if (!cancelled) setBackends([]); - }) - .finally(() => { - if (!cancelled) setBackendsLoading(false); - }); + // Storage backends are not in the Bridge tunnel allowlist. + if (isBridgeAgentId(agentId)) { + setBackends([]); + setBackendsLoading(false); + } else { + void request("/storage-backends") + .then((data) => { + if (!cancelled) { + setBackends( + data.filter( + (b) => b.enabled && isAgentResolvableStorageKind(b.kind), + ), + ); + } + }) + .catch(() => { + if (!cancelled) setBackends([]); + }) + .finally(() => { + if (!cancelled) setBackendsLoading(false); + }); + } return () => { cancelled = true; }; - }, [enabled]); + }, [enabled, agentId]); return { models, modelsLoading, backends, backendsLoading }; } diff --git a/dashboard/src/hooks/useBrowserSessionState.ts b/dashboard/src/hooks/useBrowserSessionState.ts index 3cce37a782..8aadcb0a60 100644 --- a/dashboard/src/hooks/useBrowserSessionState.ts +++ b/dashboard/src/hooks/useBrowserSessionState.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; import api from "../api"; +import { bridgeApi } from "../api/modules/bridge"; import { getWsUrl } from "../api/config"; import { getAuthToken } from "../api/request"; import type { BrowserSession, DisplayEnvironment } from "../api/types/browser"; @@ -42,8 +43,8 @@ export interface BrowserSessionState { * Unified hook for tracking browser session state. * * - On mount, makes a single HTTP GET to seed state. - * - Opens a lightweight WebSocket to `/browser-stream/ws` to receive - * `session_update` events in real-time. + * - Opens a lightweight WebSocket to `/browser-stream/ws` (or the Bridge + * relay when ``bridgeConnectionId`` is set) to receive ``session_update``. * - When `conversationId` changes, re-fetches via HTTP. * - Auto-reconnects on WS disconnect with exponential backoff. * @@ -57,10 +58,15 @@ export interface BrowserSessionState { export function useBrowserSessionState( conversationId?: string, enabled = true, + bridgeConnectionId?: string | null, ): BrowserSessionState { const currentUser = useCurrentUser(); - const browserProfile = resolveBrowserProfile(currentUser?.id); - const active = enabled && browserProfile != null; + const localProfile = resolveBrowserProfile(currentUser?.id); + // Peer harness uses the connection-owner profile on the remote instance; + // local user-{id} must not filter peer session_update events. + const browserProfile = bridgeConnectionId ? null : localProfile; + const active = + enabled && (bridgeConnectionId != null || browserProfile != null); const [session, setSession] = useState(null); const [environment, setEnvironment] = useState("headless-server"); @@ -72,12 +78,17 @@ export function useBrowserSessionState( const unmountedRef = useRef(false); const conversationIdRef = useRef(conversationId); conversationIdRef.current = conversationId; + const bridgeIdRef = useRef(bridgeConnectionId); + bridgeIdRef.current = bridgeConnectionId; // ── HTTP fetch (initial + fallback) ────────────────────────────────── const fetchSessions = useCallback(async () => { try { - const resp = await api.getSessions(); + const bridgeId = bridgeIdRef.current; + const resp = bridgeId + ? await bridgeApi.getBrowserSessions(bridgeId) + : await api.getSessions(); if (unmountedRef.current) return; if (resp.ok) { setEnvironment(resp.environment); @@ -91,7 +102,7 @@ export function useBrowserSessionState( } } } catch { - // harness-browser may be unavailable — ignore + // octop-browser / bridge tunnel may be unavailable — ignore } }, []); @@ -108,7 +119,9 @@ export function useBrowserSessionState( }, []); const connectWs = useCallback(() => { - if (unmountedRef.current || !browserProfile) return; + if (unmountedRef.current) return; + const bridgeId = bridgeIdRef.current; + if (!bridgeId && !browserProfile) return; // Clean up any previous connection if (wsRef.current) { @@ -127,7 +140,10 @@ export function useBrowserSessionState( listen_only: "1", }); if (token) params.set("token", token); - const wsUrl = `${getWsUrl("/browser-stream/ws")}?${params.toString()}`; + const wsPath = bridgeId + ? `/bridge/connections/${encodeURIComponent(bridgeId)}/browser-stream/ws` + : "/browser-stream/ws"; + const wsUrl = `${getWsUrl(wsPath)}?${params.toString()}`; let ws: WebSocket; try { @@ -153,7 +169,7 @@ export function useBrowserSessionState( width: 1, height: 1, reuse_session: true, - session_id: browserProfile, + ...(browserProfile ? { session_id: browserProfile } : {}), }), ); } catch { @@ -172,20 +188,19 @@ export function useBrowserSessionState( type: string; }; if (msg.type === "session_update") { - // Browser state is scoped to the authenticated user's profile. + // Local: scoped to the authenticated user's profile. + // Bridge: accept peer session_update as-is (profile ids differ). const cid = conversationIdRef.current; const shared = + bridgeId != null || msg.session_id === browserProfile || msg.conversation_id === browserProfile; if (!cid || shared || msg.conversation_id === cid) { setSession((prev) => { if (!prev) { - // No session yet — bootstrap one from the WS event so the - // browser icon lights up immediately without waiting for the - // HTTP fetch to complete. return { session_id: msg.session_id, - profile_name: browserProfile, + profile_name: browserProfile ?? msg.session_id, conversation_id: msg.conversation_id, channel_source: msg.channel_source, state: msg.state, @@ -263,7 +278,7 @@ export function useBrowserSessionState( wsRef.current = null; } }; - }, [active, fetchSessions, connectWs]); + }, [active, fetchSessions, connectWs, bridgeConnectionId]); // Re-fetch when conversationId changes useEffect(() => { diff --git a/dashboard/src/hooks/useBrowserStream.ts b/dashboard/src/hooks/useBrowserStream.ts index 773c045307..1f4f96be36 100644 --- a/dashboard/src/hooks/useBrowserStream.ts +++ b/dashboard/src/hooks/useBrowserStream.ts @@ -31,12 +31,23 @@ interface BrowserStreamCallbacks { interface ConnectOptions { /** Harness profile for the current user. The WebSocket also binds from JWT. */ sessionId?: string | null; + /** When set, screencast is relayed to the peer Octop via Bridge. */ + bridgeConnectionId?: string | null; } -function buildWsUrl(width: number, height: number): string { +function buildWsUrl( + width: number, + height: number, + bridgeConnectionId?: string | null, +): string { const token = getAuthToken(); + const path = bridgeConnectionId + ? `/bridge/connections/${encodeURIComponent( + bridgeConnectionId, + )}/browser-stream/ws` + : "/browser-stream/ws"; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const base = `${protocol}://${window.location.host}/api/browser-stream/ws`; + const base = `${protocol}://${window.location.host}/api${path}`; const params = new URLSearchParams({ width: String(width), height: String(height), @@ -95,7 +106,7 @@ export function useBrowserStream() { wsRef.current.close(); } - const wsUrl = buildWsUrl(width, height); + const wsUrl = buildWsUrl(width, height, options.bridgeConnectionId); let ws: WebSocket; try { ws = new WebSocket(wsUrl); diff --git a/dashboard/src/hooks/useBrowserViewController.ts b/dashboard/src/hooks/useBrowserViewController.ts index 330474e873..df45643356 100644 --- a/dashboard/src/hooks/useBrowserViewController.ts +++ b/dashboard/src/hooks/useBrowserViewController.ts @@ -33,7 +33,10 @@ type ConnectStreamFn = ( onStatusChange?: (status: string) => void; onError?: (message: string) => void; }, - options?: { sessionId?: string | null }, + options?: { + sessionId?: string | null; + bridgeConnectionId?: string | null; + }, ) => void; interface UseBrowserViewControllerOptions { @@ -51,6 +54,8 @@ interface UseBrowserViewControllerOptions { onError: (message: string) => void; onBeforeConnect?: (sessionId: string) => void; onAfterConnect?: (sessionId: string) => void; + /** When set, screencast is relayed to the peer Octop via Bridge. */ + bridgeConnectionId?: string | null; } /** @@ -74,6 +79,7 @@ export function useBrowserViewController({ onError, onBeforeConnect, onAfterConnect, + bridgeConnectionId = null, }: UseBrowserViewControllerOptions) { // Hold the caller-provided callbacks/flags in a ref so ``startStream`` keeps // a stable identity even when those inline functions are recreated each @@ -85,6 +91,7 @@ export function useBrowserViewController({ onError, onBeforeConnect, onAfterConnect, + bridgeConnectionId, }); cfgRef.current = { isMobile, @@ -92,6 +99,7 @@ export function useBrowserViewController({ onError, onBeforeConnect, onAfterConnect, + bridgeConnectionId, }; const startStream = useCallback( @@ -102,6 +110,7 @@ export function useBrowserViewController({ onError: err, onBeforeConnect: before, onAfterConnect: after, + bridgeConnectionId: bridgeId, } = cfgRef.current; const containerEl = containerRef.current; const cw = containerEl?.clientWidth ?? 0; @@ -126,7 +135,10 @@ export function useBrowserViewController({ onFrame: (base64Data) => viewerRef.current?.paintFrame(base64Data), onError: err, }, - { sessionId: sessionId || undefined }, + { + sessionId: sessionId || undefined, + bridgeConnectionId: bridgeId || undefined, + }, ); after?.(sessionId); }, diff --git a/dashboard/src/hooks/useHitlEnabled.test.ts b/dashboard/src/hooks/useHitlEnabled.test.ts new file mode 100644 index 0000000000..87a887db91 --- /dev/null +++ b/dashboard/src/hooks/useHitlEnabled.test.ts @@ -0,0 +1,55 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { hitl } = vi.hoisted(() => ({ hitl: vi.fn() })); + +vi.mock("../api/modules/settings", () => ({ + octopSettingsApi: { hitl }, +})); + +import { useHitlEnabled } from "./useHitlEnabled"; + +describe("useHitlEnabled", () => { + beforeEach(() => { + hitl.mockReset(); + }); + + it("reports enabled after the server says so", async () => { + hitl.mockResolvedValue({ + enabled: true, + tool_guard_require_approval: false, + show_approval_ui: true, + }); + const hook = renderHook(() => useHitlEnabled()); + expect(hook.result.current).toBe(false); + await waitFor(() => expect(hook.result.current).toBe(true)); + }); + + it("stays off when the server reports HITL disabled", async () => { + hitl.mockResolvedValue({ + enabled: false, + tool_guard_require_approval: false, + show_approval_ui: false, + }); + const hook = renderHook(() => useHitlEnabled()); + await waitFor(() => expect(hitl).toHaveBeenCalled()); + expect(hook.result.current).toBe(false); + }); + + it("shows approval UI when only command guard requires approval", async () => { + hitl.mockResolvedValue({ + enabled: false, + tool_guard_require_approval: true, + show_approval_ui: true, + }); + const hook = renderHook(() => useHitlEnabled()); + await waitFor(() => expect(hook.result.current).toBe(true)); + }); + + it("hides the picker when the settings fetch fails", async () => { + hitl.mockRejectedValueOnce(new Error("settings endpoint offline")); + const hook = renderHook(() => useHitlEnabled()); + await waitFor(() => expect(hitl).toHaveBeenCalled()); + expect(hook.result.current).toBe(false); + }); +}); diff --git a/dashboard/src/hooks/useHitlEnabled.ts b/dashboard/src/hooks/useHitlEnabled.ts new file mode 100644 index 0000000000..8278fabb06 --- /dev/null +++ b/dashboard/src/hooks/useHitlEnabled.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; +import { octopSettingsApi } from "../api/modules/settings"; + +/** Whether tool-approval UI should show (HITL or command-guard require_approval). */ +export function useHitlEnabled(): boolean { + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + let cancelled = false; + void octopSettingsApi + .hitl() + .then((data) => { + if (!cancelled) setEnabled(Boolean(data.show_approval_ui)); + }) + .catch(() => { + if (!cancelled) setEnabled(false); + }); + return () => { + cancelled = true; + }; + }, []); + + return enabled; +} diff --git a/dashboard/src/hooks/useKeepInVisualViewport.test.ts b/dashboard/src/hooks/useKeepInVisualViewport.test.ts new file mode 100644 index 0000000000..329378d05e --- /dev/null +++ b/dashboard/src/hooks/useKeepInVisualViewport.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + measureVisualViewportOverflow, + needsComposerVisualViewportFix, +} from "./viewport"; + +describe("measureVisualViewportOverflow", () => { + it("is zero when the box ends inside the visual viewport", () => { + expect( + measureVisualViewportOverflow(700, { height: 700, offsetTop: 0 }), + ).toBe(0); + expect( + measureVisualViewportOverflow(680, { height: 700, offsetTop: 0 }), + ).toBe(0); + }); + + it("reports how far the box extends past the visual viewport", () => { + expect( + measureVisualViewportOverflow(900, { height: 700, offsetTop: 0 }), + ).toBe(200); + expect( + measureVisualViewportOverflow(900, { height: 600, offsetTop: 100 }), + ).toBe(200); + }); +}); + +describe("needsComposerVisualViewportFix", () => { + const originalMatchMedia = window.matchMedia; + const originalUa = navigator.userAgent; + + afterEach(() => { + window.matchMedia = originalMatchMedia; + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: originalUa, + }); + }); + + it("is true on narrow viewports", () => { + window.matchMedia = ((query: string) => ({ + matches: query.includes("767px"), + media: query, + addEventListener() {}, + removeEventListener() {}, + })) as typeof window.matchMedia; + expect(needsComposerVisualViewportFix()).toBe(true); + }); + + it("is true for Android Chrome desktop-site (wide layout)", () => { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + addEventListener() {}, + removeEventListener() {}, + })) as typeof window.matchMedia; + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value: + "Mozilla/5.0 (Linux; Android 14; Pixel) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + }); + expect(needsComposerVisualViewportFix()).toBe(true); + }); +}); diff --git a/dashboard/src/hooks/useKeepInVisualViewport.ts b/dashboard/src/hooks/useKeepInVisualViewport.ts new file mode 100644 index 0000000000..9cce89203a --- /dev/null +++ b/dashboard/src/hooks/useKeepInVisualViewport.ts @@ -0,0 +1,60 @@ +import { useEffect, type RefObject } from "react"; +import { measureVisualViewportOverflow } from "./viewport"; + +/** + * If `el` extends below the visual viewport, translate it up just enough to + * stay visible. Used by the chat composer on mobile browser (Android Chrome). + */ +export function useKeepInVisualViewport( + ref: RefObject, + enabled: boolean, +) { + useEffect(() => { + if (!enabled) return; + const vv = window.visualViewport; + if (!vv) return; + + let raf = 0; + + const clear = () => { + const node = ref.current; + if (!node) return; + node.style.removeProperty("translate"); + node.style.removeProperty("will-change"); + }; + + const update = () => { + cancelAnimationFrame(raf); + raf = window.requestAnimationFrame(() => { + const node = ref.current; + if (!node) return; + node.style.removeProperty("translate"); + const overflow = measureVisualViewportOverflow( + node.getBoundingClientRect().bottom, + vv, + ); + if (overflow > 0.5) { + node.style.willChange = "translate"; + node.style.translate = `0 ${-Math.ceil(overflow)}px`; + } + }); + }; + + vv.addEventListener("resize", update); + vv.addEventListener("scroll", update); + window.addEventListener("resize", update); + window.addEventListener("orientationchange", update); + update(); + + return () => { + cancelAnimationFrame(raf); + vv.removeEventListener("resize", update); + vv.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + window.removeEventListener("orientationchange", update); + clear(); + }; + }, [enabled, ref]); +} + +export { measureVisualViewportOverflow } from "./viewport"; diff --git a/dashboard/src/hooks/useKeyboardOffset.test.ts b/dashboard/src/hooks/useKeyboardOffset.test.ts index 051cae523a..fbec50aec7 100644 --- a/dashboard/src/hooks/useKeyboardOffset.test.ts +++ b/dashboard/src/hooks/useKeyboardOffset.test.ts @@ -1,8 +1,9 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { renderHook } from "@testing-library/react"; import { KEYBOARD_GAP_THRESHOLD_PX, measureKeyboardOffset, + measureLayoutHeight, useKeyboardOffset, } from "./useKeyboardOffset"; @@ -28,6 +29,20 @@ describe("measureKeyboardOffset", () => { }); }); +describe("measureLayoutHeight", () => { + it("prefers the larger of clientHeight and innerHeight", () => { + Object.defineProperty(document.documentElement, "clientHeight", { + configurable: true, + value: 900, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 800, + }); + expect(measureLayoutHeight()).toBe(900); + }); +}); + describe("useKeyboardOffset", () => { const originalMatchMedia = window.matchMedia; const originalVisualViewport = window.visualViewport; @@ -41,18 +56,54 @@ describe("useKeyboardOffset", () => { document.documentElement.style.removeProperty("--keyboard-offset"); }); - it("does not set --keyboard-offset outside PWA standalone", () => { + function stubViewport(height: number, offsetTop = 0) { + const vv = { + height, + offsetTop, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: vv, + }); + return vv; + } + + function stubMatchMedia(standalone: boolean) { window.matchMedia = ((query: string) => ({ - matches: false, + matches: query.includes("standalone") ? standalone : false, media: query, addEventListener() {}, removeEventListener() {}, })) as typeof window.matchMedia; + } + it("does nothing in a mobile browser tab", () => { + stubMatchMedia(false); + stubViewport(500, 0); renderHook(() => useKeyboardOffset()); - expect( document.documentElement.style.getPropertyValue("--keyboard-offset"), ).toBe(""); }); + + it("sets --keyboard-offset in PWA when a soft keyboard is open", () => { + stubMatchMedia(true); + Object.defineProperty(document.documentElement, "clientHeight", { + configurable: true, + value: 844, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 844, + }); + stubViewport(500, 0); + + renderHook(() => useKeyboardOffset()); + + expect( + document.documentElement.style.getPropertyValue("--keyboard-offset"), + ).toBe("344px"); + }); }); diff --git a/dashboard/src/hooks/useKeyboardOffset.ts b/dashboard/src/hooks/useKeyboardOffset.ts index 46a9488651..f4e0ba3c3c 100644 --- a/dashboard/src/hooks/useKeyboardOffset.ts +++ b/dashboard/src/hooks/useKeyboardOffset.ts @@ -1,52 +1,48 @@ import { useEffect } from "react"; - -/** Home-indicator / browser chrome is ~34–50px; real keyboards are 200px+. */ -export const KEYBOARD_GAP_THRESHOLD_PX = 80; - -function isPwa(): boolean { - if (window.matchMedia("(display-mode: standalone)").matches) return true; - const nav = navigator as Navigator & { standalone?: boolean }; - return nav.standalone === true; -} - -/** - * VisualViewport leftover after subtracting offsetTop. Tiny gaps are the - * iOS home indicator, not a keyboard — those must stay 0 or CSS that also - * adds `env(safe-area-inset-bottom)` will paint two empty bands. - */ -export function measureKeyboardOffset( - innerHeight: number, - viewport: Pick, -): number { - const raw = Math.max(0, innerHeight - viewport.height - viewport.offsetTop); - return raw > KEYBOARD_GAP_THRESHOLD_PX ? Math.round(raw) : 0; -} +import { + isPwaDisplay, + measureKeyboardOffset, + measureLayoutHeight, +} from "./viewport"; /** - * Tracks `--keyboard-offset` on the document root for PWA soft-keyboard layout. + * PWA only: keep `inset:0` shell, expose `--keyboard-offset` so the chat + * composer can clear the soft keyboard (and not the home-indicator twice). + * + * Mobile browser clipping is handled by `useKeepInVisualViewport` on the + * composer — do not also resize `#root` here (ineffective on Android Chrome). */ export function useKeyboardOffset() { useEffect(() => { - if (!isPwa()) return; + if (!isPwaDisplay()) return; const vv = window.visualViewport; if (!vv) return; + const root = document.documentElement; const update = () => { - const keyboardHeight = measureKeyboardOffset(window.innerHeight, vv); - document.documentElement.style.setProperty( - "--keyboard-offset", - `${keyboardHeight}px`, - ); + const keyboardHeight = measureKeyboardOffset(measureLayoutHeight(), vv); + root.style.setProperty("--keyboard-offset", `${keyboardHeight}px`); }; vv.addEventListener("resize", update); vv.addEventListener("scroll", update); + window.addEventListener("resize", update); update(); return () => { vv.removeEventListener("resize", update); vv.removeEventListener("scroll", update); - document.documentElement.style.removeProperty("--keyboard-offset"); + window.removeEventListener("resize", update); + root.style.removeProperty("--keyboard-offset"); }; }, []); } + +// Re-export helpers still imported by call sites / tests. +export { + isPwaDisplay, + KEYBOARD_GAP_THRESHOLD_PX, + measureKeyboardOffset, + measureLayoutHeight, + needsComposerVisualViewportFix, +} from "./viewport"; diff --git a/dashboard/src/hooks/useServerTimezone.test.ts b/dashboard/src/hooks/useServerTimezone.test.ts new file mode 100644 index 0000000000..77096d9ad6 --- /dev/null +++ b/dashboard/src/hooks/useServerTimezone.test.ts @@ -0,0 +1,54 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const { timezone } = vi.hoisted(() => ({ timezone: vi.fn() })); + +vi.mock("../api/modules/settings", () => ({ + octopSettingsApi: { timezone }, +})); + +import { + DEFAULT_SERVER_TIMEZONE, + applyTimezoneFetchResult, + useServerTimezone, +} from "./useServerTimezone"; + +describe("useServerTimezone", () => { + it("retries on the next mount after a failed fetch instead of pinning UTC", async () => { + timezone.mockRejectedValueOnce(new Error("settings endpoint offline")); + const first = renderHook(() => useServerTimezone()); + await waitFor(() => expect(first.result.current).toBe("UTC")); + first.unmount(); + + timezone.mockResolvedValueOnce({ timezone: "Asia/Shanghai" }); + const second = renderHook(() => useServerTimezone()); + await waitFor(() => expect(second.result.current).toBe("Asia/Shanghai")); + }); +}); + +describe("applyTimezoneFetchResult", () => { + it("does not cache a failed fetch so the next mount can retry", () => { + const result = applyTimezoneFetchResult({ ok: false }); + expect(result.value).toBe(DEFAULT_SERVER_TIMEZONE); + expect(result.cache).toBeNull(); + }); + + it("caches the timezone reported by the server", () => { + const result = applyTimezoneFetchResult({ + ok: true, + timezone: "Asia/Shanghai", + }); + expect(result.value).toBe("Asia/Shanghai"); + expect(result.cache).toBe("Asia/Shanghai"); + }); + + it("falls back to UTC when the server reports an empty timezone", () => { + const result = applyTimezoneFetchResult({ ok: true, timezone: " " }); + expect(result.value).toBe(DEFAULT_SERVER_TIMEZONE); + }); + + it("ignores a non-string timezone", () => { + const result = applyTimezoneFetchResult({ ok: true, timezone: 42 }); + expect(result.value).toBe(DEFAULT_SERVER_TIMEZONE); + }); +}); diff --git a/dashboard/src/hooks/useServerTimezone.ts b/dashboard/src/hooks/useServerTimezone.ts index 5df11836ff..eb3e9e84ab 100644 --- a/dashboard/src/hooks/useServerTimezone.ts +++ b/dashboard/src/hooks/useServerTimezone.ts @@ -1,21 +1,47 @@ import { useEffect, useState } from "react"; import { octopSettingsApi } from "../api/modules/settings"; +export const DEFAULT_SERVER_TIMEZONE = "UTC"; + let cachedTimezone: string | null = null; let inflight: Promise | null = null; +/** + * Map one `GET /api/settings/timezone` outcome onto the value to render and the + * value to remember for the rest of the session. + * + * A failed fetch caches nothing, so the next mount retries instead of pinning + * the fallback - the same contract as `applyUploadLimitFetchResult`. + */ +export function applyTimezoneFetchResult( + result: { ok: true; timezone: unknown } | { ok: false }, +): { cache: string | null; value: string } { + if (!result.ok) { + return { cache: null, value: DEFAULT_SERVER_TIMEZONE }; + } + const timezone = + typeof result.timezone === "string" ? result.timezone.trim() : ""; + const value = timezone || DEFAULT_SERVER_TIMEZONE; + return { cache: value, value }; +} + async function fetchServerTimezone(): Promise { if (cachedTimezone) return cachedTimezone; if (!inflight) { inflight = octopSettingsApi .timezone() .then((settings) => { - cachedTimezone = settings.timezone?.trim() || "UTC"; - return cachedTimezone; + const next = applyTimezoneFetchResult({ + ok: true, + timezone: settings.timezone, + }); + cachedTimezone = next.cache; + return next.value; }) .catch(() => { - cachedTimezone = "UTC"; - return cachedTimezone; + const next = applyTimezoneFetchResult({ ok: false }); + cachedTimezone = next.cache; + return next.value; }) .finally(() => { inflight = null; @@ -26,7 +52,9 @@ async function fetchServerTimezone(): Promise { /** Server timezone from config.json `default_timezone` (via GET /api/settings/timezone). */ export function useServerTimezone(): string { - const [timezone, setTimezone] = useState(cachedTimezone ?? "UTC"); + const [timezone, setTimezone] = useState( + cachedTimezone ?? DEFAULT_SERVER_TIMEZONE, + ); useEffect(() => { void fetchServerTimezone().then(setTimezone); diff --git a/dashboard/src/hooks/useUserRole.ts b/dashboard/src/hooks/useUserRole.ts index 86afa05d24..e4a61bfb37 100644 --- a/dashboard/src/hooks/useUserRole.ts +++ b/dashboard/src/hooks/useUserRole.ts @@ -1,11 +1,11 @@ import { useCurrentUser } from "./useCurrentUser"; /** - * Fetch the current user's role once on mount. + * Fetch the current user's role-template id once on mount. * Returns null while the request is in-flight or on failure — * callers should treat null as "not admin" to avoid info leaks. */ -export function useUserRole(): "admin" | "user" | null { +export function useUserRole(): string | null { const user = useCurrentUser(); return user?.role ?? null; } diff --git a/dashboard/src/hooks/useVoiceConfig.ts b/dashboard/src/hooks/useVoiceConfig.ts index 78c315b06b..b7fec1d29d 100644 --- a/dashboard/src/hooks/useVoiceConfig.ts +++ b/dashboard/src/hooks/useVoiceConfig.ts @@ -30,10 +30,14 @@ export function prefetchVoiceConfig(): void { } export function useVoiceConfig() { - const [active, setActive] = useState({ - stt: "browser", - tts: "browser", - }); + const [active, setActive] = useState( + () => + cachedActiveVoice() ?? { + stt: "browser", + tts: "browser", + stt_realtime: false, + }, + ); const [loading, setLoading] = useState(true); const refresh = useCallback(async () => { diff --git a/dashboard/src/hooks/useVoiceInput.ts b/dashboard/src/hooks/useVoiceInput.ts index 76d2614d58..29ad1f84b8 100644 --- a/dashboard/src/hooks/useVoiceInput.ts +++ b/dashboard/src/hooks/useVoiceInput.ts @@ -1,8 +1,16 @@ import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { getWsUrl } from "../api/config"; import { voiceApi, type ActiveVoice } from "../api/modules/voice"; +import { getAuthToken } from "../api/request"; import { speechLocaleFromUi } from "../utils/localePrefs"; -import { cachedActiveVoice, fetchActiveVoice } from "./useVoiceConfig"; +import { PcmFrameBuffer, downsampleToPcm16 } from "../utils/pcmCapture"; +import { RealtimeSttClient } from "../utils/realtimeStt"; +import { + cachedActiveVoice, + fetchActiveVoice, + useVoiceConfig, +} from "./useVoiceConfig"; import { message as antMessage } from "@/utils/antdMessage"; @@ -237,18 +245,147 @@ export function isSttAvailable(): boolean { return browserSttAvailable(); // browser STT (Chrome / Edge) } -export function useVoiceInput(onText: (text: string) => void) { +export interface VoiceInputOptions { + /** Called when a capture session starts, before any transcript arrives. */ + onStart?: () => void; + /** Cumulative transcript while the session is still being refined. */ + onInterim?: (text: string) => void; + /** Cumulative transcript of the session; the realtime path calls it per sentence. */ + onText: (text: string) => void; +} + +/** Live 16 kHz capture feeding the realtime STT WebSocket. */ +interface RealtimeCapture { + client: RealtimeSttClient; + stream: MediaStream; + ctx: AudioContext; + source: MediaStreamAudioSourceNode; + processor: ScriptProcessorNode; + sink: GainNode; +} + +function realtimeSttUrl(language: string): string { + const params = new URLSearchParams({ + locale: language.toLowerCase().startsWith("zh") ? "zh" : "en", + }); + const token = getAuthToken(); + if (token) params.set("token", token); + return `${getWsUrl("/voice/stt-stream")}?${params.toString()}`; +} + +function audioContextCtor(): typeof AudioContext | null { + return ( + window.AudioContext ?? + (window as Window & { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext ?? + null + ); +} + +export function useVoiceInput(options: VoiceInputOptions) { const { t, i18n } = useTranslation(); const [recording, setRecording] = useState(false); const [transcribing, setTranscribing] = useState(false); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); + const captureRef = useRef(null); + // A press-and-hold release can land while `startRealtime` is still awaiting + // getUserMedia; remember it so the capture is not left running. + const pendingStopRef = useRef(false); + // Keep the latest callbacks without rebuilding every capture closure. + const optionsRef = useRef(options); + optionsRef.current = options; + const { active } = useVoiceConfig(); // Follow dashboard UI locale — not navigator.language (often en-US on // machines used with Chinese UI / Chinese speech). const language = speechLocaleFromUi(i18n.language); + const teardownRealtime = useCallback(() => { + const capture = captureRef.current; + captureRef.current = null; + if (!capture) return; + capture.processor.onaudioprocess = null; + capture.processor.disconnect(); + capture.source.disconnect(); + capture.sink.disconnect(); + capture.stream.getTracks().forEach((track) => track.stop()); + void capture.ctx.close(); + }, []); + + const finishRealtime = useCallback(() => { + teardownRealtime(); + setRecording(false); + setTranscribing(false); + }, [teardownRealtime]); + + const startRealtime = useCallback(async () => { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + echoCancellation: true, + noiseSuppression: true, + }, + }); + const Ctor = audioContextCtor(); + if (!Ctor) throw new Error("AudioContext unavailable"); + const ctx = new Ctor(); + const source = ctx.createMediaStreamSource(stream); + const processor = ctx.createScriptProcessor(1024, 1, 1); + const sink = ctx.createGain(); + sink.gain.value = 0; // keep the graph running without echoing the mic + + const client = new RealtimeSttClient(realtimeSttUrl(language), { + onInterim: (text) => optionsRef.current.onInterim?.(text), + onFinal: (text) => { + if (text) optionsRef.current.onText(text); + }, + onDone: finishRealtime, + onError: (message) => { + finishRealtime(); + antMessage.error(message || t("voice.realtimeFailed")); + }, + }); + + captureRef.current = { client, stream, ctx, source, processor, sink }; + const frames = new PcmFrameBuffer(); + processor.onaudioprocess = (event) => { + const pcm = downsampleToPcm16( + event.inputBuffer.getChannelData(0), + ctx.sampleRate, + ); + for (const frame of frames.push(pcm)) client.send(frame); + }; + source.connect(processor); + processor.connect(sink); + sink.connect(ctx.destination); + + // Frames are buffered until the server reports ready; failures surface + // through `onError`, so the connect rejection needs no extra handling. + void client.connect().catch(() => undefined); + setRecording(true); + }, [finishRealtime, language, t]); + + const stopRealtime = useCallback(() => { + const capture = captureRef.current; + if (!capture) return; + capture.processor.onaudioprocess = null; + capture.processor.disconnect(); + capture.source.disconnect(); + capture.sink.disconnect(); + capture.stream.getTracks().forEach((track) => track.stop()); + setRecording(false); + setTranscribing(true); + // `end` makes the server flush its final sentences, then `onDone` clears. + capture.client.end(); + }, []); + const stopRecording = useCallback(async () => { + pendingStopRef.current = true; + if (captureRef.current) { + stopRealtime(); + return; + } const recorder = mediaRecorderRef.current; if (!recorder || recorder.state === "inactive") { setRecording(false); @@ -280,7 +417,7 @@ export function useVoiceInput(onText: (text: string) => void) { const upload = await recordedBlobToWav(blob); const result = await voiceApi.transcribe(upload, language); const text = result.text?.trim() ?? ""; - if (text) onText(text); + if (text) optionsRef.current.onText(text); else antMessage.info(t("voice.sttEmpty")); } catch (err) { const msg = err instanceof Error ? err.message : ""; @@ -292,18 +429,32 @@ export function useVoiceInput(onText: (text: string) => void) { } finally { setTranscribing(false); } - }, [onText, t, language]); + }, [t, language, stopRealtime]); const startRecording = useCallback(async () => { // Read cached config synchronously to stay in the user-gesture stack. const active: ActiveVoice | null = cachedActiveVoice(); + pendingStopRef.current = false; + optionsRef.current.onStart?.(); + + if (active?.stt_realtime && canRecordAudio() && audioContextCtor()) { + // Realtime STT: stream PCM and let results arrive while speaking. + try { + await startRealtime(); + } catch { + antMessage.error(t("voice.micDenied")); + return; + } + if (pendingStopRef.current) stopRealtime(); + return; + } if ((active?.stt ?? "browser") === "browser" && browserSttAvailable()) { // Browser STT: use the native SpeechRecognition API directly. setTranscribing(true); try { const text = await transcribeWithBrowser(language); - if (text) onText(text); + if (text) optionsRef.current.onText(text); else antMessage.info(t("voice.sttEmpty")); } catch (err) { const msg = err instanceof Error ? err.message : ""; @@ -343,7 +494,7 @@ export function useVoiceInput(onText: (text: string) => void) { } catch { antMessage.error(t("voice.micDenied")); } - }, [onText, t, language]); + }, [t, language, startRealtime, stopRealtime]); const toggle = useCallback(() => { if (transcribing) return; @@ -351,5 +502,13 @@ export function useVoiceInput(onText: (text: string) => void) { else void startRecording(); }, [recording, transcribing, startRecording, stopRecording]); - return { recording, transcribing, toggle }; + return { + recording, + transcribing, + /** True when the mic streams results live (press and hold, not click). */ + realtime: active.stt_realtime === true, + start: startRecording, + stop: stopRecording, + toggle, + }; } diff --git a/dashboard/src/hooks/viewport.ts b/dashboard/src/hooks/viewport.ts new file mode 100644 index 0000000000..7bceb99d19 --- /dev/null +++ b/dashboard/src/hooks/viewport.ts @@ -0,0 +1,69 @@ +/** + * Shared mobile / PWA viewport helpers. + * + * Two different problems, two fixes: + * - iOS/Android **PWA**: soft keyboard overlays a `position:fixed; inset:0` + * shell → pad the composer with `--keyboard-offset` (see useKeyboardOffset). + * - **Mobile browser** (esp. Android Chrome): layout viewport stays taller than + * the visible area, so the in-flow chat composer is clipped → translate the + * composer back into view (see useKeepInVisualViewport). Shrinking `#root` + * with visualViewport was tried and did not reliably fix Chrome. + */ + +/** Home-indicator / browser chrome is ~34–50px; real keyboards are 200px+. */ +export const KEYBOARD_GAP_THRESHOLD_PX = 80; + +/** Match dashboard mobile breakpoint (`useIsMobile`). */ +export const MOBILE_VIEWPORT_MQ = "(max-width: 767px)"; + +export function isPwaDisplay(): boolean { + if (typeof window === "undefined") return false; + if (window.matchMedia("(display-mode: standalone)").matches) return true; + const nav = navigator as Navigator & { standalone?: boolean }; + return nav.standalone === true; +} + +/** + * Whether the chat composer should self-correct against visualViewport. + * Narrow viewports, plus Android Chrome "desktop site" (wide layout). + */ +export function needsComposerVisualViewportFix(): boolean { + if (typeof window === "undefined" || isPwaDisplay()) return false; + if (window.matchMedia(MOBILE_VIEWPORT_MQ).matches) return true; + const ua = navigator.userAgent || ""; + return ( + /Android/i.test(ua) && + /Chrome|CriOS/i.test(ua) && + !/EdgA|OPR|SamsungBrowser|Firefox/i.test(ua) + ); +} + +/** + * Soft-keyboard inset for PWA shells. Tiny leftovers are the home indicator, + * not a keyboard — leave those at 0 so safe-area padding is not doubled. + * + * Prefer `documentElement.clientHeight` over `innerHeight`: on Android Chrome + * `innerHeight` often already tracks the visual viewport. + */ +export function measureKeyboardOffset( + layoutHeight: number, + viewport: Pick, +): number { + const raw = Math.max(0, layoutHeight - viewport.height - viewport.offsetTop); + return raw > KEYBOARD_GAP_THRESHOLD_PX ? Math.round(raw) : 0; +} + +export function measureLayoutHeight(): number { + if (typeof document === "undefined") return 0; + return Math.max( + document.documentElement.clientHeight || 0, + window.innerHeight || 0, + ); +} + +export function measureVisualViewportOverflow( + rectBottom: number, + viewport: Pick, +): number { + return Math.max(0, rectBottom - (viewport.offsetTop + viewport.height)); +} diff --git a/dashboard/src/layouts/MainLayout/index.tsx b/dashboard/src/layouts/MainLayout/index.tsx index 6101c0412e..dbf53d7370 100644 --- a/dashboard/src/layouts/MainLayout/index.tsx +++ b/dashboard/src/layouts/MainLayout/index.tsx @@ -19,6 +19,7 @@ import { } from "../../routes"; import { CHAT_HISTORY_RAIL_ID, isChatPath } from "../chatHistoryRail"; import { useIsMobile } from "../../hooks/useIsMobile"; +import { useKeyboardOffset } from "../../hooks/useKeyboardOffset"; import { useChatSidebarOpen } from "../../pages/Chat/hooks/useChatSidebarState"; import { EXPAND_CHAT_RAIL_EVENT } from "../../pages/Chat/components/ChatSidebarPanel"; import RequirePermission from "../../components/RequirePermission"; @@ -59,6 +60,7 @@ export default function MainLayout() { const isMobile = useIsMobile(); const { layoutMode } = useLayoutMode(); useDashboardPushToast(); + useKeyboardOffset(); const isMinimalLayout = layoutMode === "minimal"; const isFullscreen = FULLSCREEN_PATHS.has(currentPath) || diff --git a/dashboard/src/layouts/MinimalRecordsHost.tsx b/dashboard/src/layouts/MinimalRecordsHost.tsx index cd7b46aef2..a89e4c8b18 100644 --- a/dashboard/src/layouts/MinimalRecordsHost.tsx +++ b/dashboard/src/layouts/MinimalRecordsHost.tsx @@ -2,9 +2,10 @@ import { useCallback, useMemo } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { message as antMessage } from "@/utils/antdMessage"; import { useTranslation } from "react-i18next"; +import { apiErrorMessage } from "../utils/apiError"; import { useAgent, selectEnabledExperts } from "../context/AgentContext"; import { octopThreadsApi } from "../api/modules/octopThreads"; -import { apiErrorMessage } from "../utils/apiError"; +import { deleteConversation } from "../pages/Chat/utils/deleteConversation"; import MinimalAgentSessionNav from "../pages/Chat/components/MinimalAgentSessionNav"; import { emitSessionEvent } from "../pages/Chat/hooks/chatStore"; import { formatThreadTitle } from "../pages/Chat/utils/threadTitle"; @@ -70,17 +71,20 @@ export default function MinimalRecordsHost() { ); const handleDeleteActive = useCallback( - async (sessionId: string) => { - if (!resolvedAgentId || !sessionId) return; - try { - await octopThreadsApi.delete(resolvedAgentId, sessionId); - emitSessionEvent({ kind: "sessionDeleted", sessionId }); - if (pathThreadId === sessionId) { - navigate(`/chat/${resolvedAgentId}`, { replace: true }); - } - } catch (error) { - antMessage.error(apiErrorMessage(error, t("common.deleteFailed"), t)); + async (sessionId: string, compact: boolean) => { + if (!resolvedAgentId || !sessionId) return false; + const deleted = await deleteConversation( + resolvedAgentId, + sessionId, + compact, + t, + ); + if (!deleted) return false; + emitSessionEvent({ kind: "sessionDeleted", sessionId }); + if (pathThreadId === sessionId) { + navigate(`/chat/${resolvedAgentId}`, { replace: true }); } + return true; }, [navigate, pathThreadId, resolvedAgentId, t], ); @@ -133,7 +137,7 @@ export default function MinimalRecordsHost() { onSelect={handleSelect} onAgentSelect={handleAgentSelect} onNewChat={handleNewChat} - onDeleteActive={(id) => void handleDeleteActive(id)} + onDeleteActive={(id, compact) => void handleDeleteActive(id, compact)} onRenameActive={handleRenameActive} onPinActive={handlePinActive} onFork={(id, agentId) => void handleFork(id, agentId)} diff --git a/dashboard/src/layouts/PageShell.module.less b/dashboard/src/layouts/PageShell.module.less index f5f95e3fb1..b15b44b55f 100644 --- a/dashboard/src/layouts/PageShell.module.less +++ b/dashboard/src/layouts/PageShell.module.less @@ -1,26 +1,6 @@ -/* A11Y-10: visually hidden until focused, then pinned top-left over content. */ -.skipLink { - position: absolute; - top: 8px; - left: 8px; - z-index: 1100; - padding: 8px 16px; - border-radius: 8px; - background: var(--fn-color-brand); - color: var(--fn-color-on-brand); - font-size: 13px; - font-weight: 500; - text-decoration: none; - transform: translateY(-200%); - transition: transform 0.15s ease; - - &:focus { - transform: translateY(0); - } -} - .agentBar { flex-shrink: 0; + min-width: 0; margin-bottom: 12px; padding: 10px 16px; background: var(--fn-bg-container, var(--fn-bg-elevated)); diff --git a/dashboard/src/layouts/PageShell.tsx b/dashboard/src/layouts/PageShell.tsx index 30d64f9d33..cc3e7d8884 100644 --- a/dashboard/src/layouts/PageShell.tsx +++ b/dashboard/src/layouts/PageShell.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from "react"; import { Segmented, Typography } from "antd"; -import { useTranslation } from "react-i18next"; import AgentSelector from "../components/AgentSelector"; +import RemoteDisconnectBanner from "../components/RemoteDisconnectBanner"; +import { useAgent } from "../context/AgentContext"; import { useIsMobile } from "../hooks/useIsMobile"; import { titleRowEndPadding, @@ -37,6 +38,8 @@ interface PageShellProps { pathTabs?: PathTabsConfig; /** Render agent picker below the title row, outside the scrollable content card. */ agentScoped?: boolean; + /** Include team hosts in the agent picker (memory / channels). */ + showTeams?: boolean; /** When true, the content area does not scroll; children fill remaining height. */ fill?: boolean; children: React.ReactNode; @@ -93,11 +96,12 @@ function PageShell({ actions, pathTabs, agentScoped, + showTeams, fill, children, }: PageShellProps) { - const { t } = useTranslation(); const isMobile = useIsMobile(); + const { activeAgent } = useAgent(); const outerPad = isMobile ? 12 : 32; const outerPadTop = isMobile ? 12 : 24; const contentPad = isMobile ? 12 : 24; @@ -137,18 +141,6 @@ function PageShell({ overflow: "hidden", }} > - {/* A11Y-10: skip link — first tab stop, jumps past the sidebar to the - page content. Visually hidden until focused. */} - { - e.preventDefault(); - document.getElementById("octop-main-content")?.focus(); - }} - > - {t("common.skipToContent")} - {/* Title row — fixed, never scrolls */}
- +
)} @@ -197,8 +189,6 @@ function PageShell({ tabbed pages get more usable horizontal space. Path tabs on mobile pin above the body (same chrome as Workbench / Personalization). */}
)} + {agentScoped && activeAgent?.bridge_disconnected ? ( + + ) : null} {children} diff --git a/dashboard/src/layouts/Sidebar.module.less b/dashboard/src/layouts/Sidebar.module.less index 55ba74b566..154635bc45 100644 --- a/dashboard/src/layouts/Sidebar.module.less +++ b/dashboard/src/layouts/Sidebar.module.less @@ -125,13 +125,14 @@ right: 4px; z-index: 2; font-size: 8px; - width: 14px; + width: auto; + min-width: 14px; height: 14px; display: flex; align-items: center; justify-content: center; - border-radius: 50%; - padding: 0; + border-radius: 7px; + padding: 0 3px; pointer-events: none; } diff --git a/dashboard/src/layouts/Sidebar.tsx b/dashboard/src/layouts/Sidebar.tsx index 7166a26ca2..c4c8efdf95 100644 --- a/dashboard/src/layouts/Sidebar.tsx +++ b/dashboard/src/layouts/Sidebar.tsx @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import AvatarDropdown from "../components/AvatarDropdown"; import AppVersionBadge from "../components/AppVersionBadge"; import CurrentVersionBadge from "../components/CurrentVersionBadge"; -import { ArrowRightLeft, X, ChevronDown } from "lucide-react"; +import { ArrowRightLeft, ChevronDown, X } from "lucide-react"; import { useTheme } from "../context/ThemeContext"; import { useLayoutMode } from "../context/LayoutModeContext"; import { useUserRole } from "../hooks/useUserRole"; @@ -24,20 +24,27 @@ import type { MinimalNavPane } from "./layoutModeStorage"; import MinimalRecordsHost from "./MinimalRecordsHost"; import SidebarCollapsedIconNav from "./SidebarCollapsedIconNav"; import SidebarMinimalPaneToggle from "./SidebarMinimalPaneToggle"; +import { preferencesApi } from "../api/modules/preferences"; import { COLLAPSED_WIDTH, EXPANDED_WIDTH, buildNavSections, + navSectionLabel, type NavItem, type NavSection, } from "./sidebarNav"; +import { sectionsFromLayout, type SidebarNavLayout } from "./sidebarNavLayout"; +import SidebarNavCustomizer from "./SidebarNavCustomizer"; import styles from "./Sidebar.module.less"; import { typeSize } from "../utils/mobileTypeScale"; import { DESKTOP_DRAG_REGION_CLASS } from "../utils/desktopChrome"; const NAV_GROUPS_STORAGE_KEY = "octop:sidebar-nav-groups"; -/** Minimal settings pane: skip the "设置" group header (duplicates the pane title). */ -const MINIMAL_SETTINGS_HIDDEN_HEADERS = new Set(["nav.settings"]); +const LEGACY_COLLAPSED_GROUP_IDS: Record = { + "nav.settings": "settings", + "nav.control": "control", + "nav.admin": "admin", +}; function loadCollapsedGroups(): Set { try { @@ -45,7 +52,11 @@ function loadCollapsedGroups(): Set { if (!raw) return new Set(); const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) { - return new Set(parsed.filter((x): x is string => typeof x === "string")); + return new Set( + parsed + .filter((x): x is string => typeof x === "string") + .map((id) => LEGACY_COLLAPSED_GROUP_IDS[id] ?? id), + ); } } catch { /* ignore */ @@ -68,37 +79,36 @@ function useNavGroupCollapse(navSections: NavSection[], selectedKey: string) { const [collapsedGroups, setCollapsedGroups] = useState>(() => loadCollapsedGroups(), ); - const activeGroupKey = navSections.find( + const activeGroupId = navSections.find( (section) => - section.groupKey && - section.items.some((item) => item.key === selectedKey), - )?.groupKey; + section.id && section.items.some((item) => item.key === selectedKey), + )?.id; - const toggleGroup = useCallback((groupKey: string) => { + const toggleGroup = useCallback((groupId: string) => { setCollapsedGroups((prev) => { const next = new Set(prev); - if (next.has(groupKey)) next.delete(groupKey); - else next.add(groupKey); + if (next.has(groupId)) next.delete(groupId); + else next.add(groupId); saveCollapsedGroups(next); return next; }); }, []); const isGroupCollapsed = useCallback( - (groupKey: string) => collapsedGroups.has(groupKey), + (groupId: string) => collapsedGroups.has(groupId), [collapsedGroups], ); useEffect(() => { - if (!activeGroupKey) return; + if (!activeGroupId) return; setCollapsedGroups((prev) => { - if (!prev.has(activeGroupKey)) return prev; + if (!prev.has(activeGroupId)) return prev; const next = new Set(prev); - next.delete(activeGroupKey); + next.delete(activeGroupId); saveCollapsedGroups(next); return next; }); - }, [activeGroupKey, selectedKey]); + }, [activeGroupId, selectedKey]); return { toggleGroup, isGroupCollapsed }; } @@ -127,7 +137,7 @@ function NavItemButton({ onNavigate: (path: string) => void; onExpandChatRail?: () => void; showChatRailExpand?: boolean; - role: "admin" | "user" | null; + role: string | null; hasUpdate: boolean; t: TFunction<"translation", undefined>; }) { @@ -157,7 +167,6 @@ function NavItemButton({ type="button" className={styles.navItemMain} onClick={() => onNavigate(item.path)} - aria-current={active ? "page" : undefined} style={{ color: active ? "var(--fn-sidebar-item-active-text)" @@ -237,6 +246,7 @@ function NavItemButton({ } function NavList({ + sections, selectedKey, onNavigate, onExpandChatRail, @@ -245,32 +255,29 @@ function NavList({ isGroupCollapsed, toggleGroup, sectionFilter = "all", - /** Group keys whose section headers are omitted (items still render). */ - hideGroupHeaderKeys, + /** Group ids whose headers are omitted (items still render). */ + hideHeaderIds, }: { + sections: NavSection[]; selectedKey: string; onNavigate: (path: string) => void; onExpandChatRail?: () => void; showChatRailExpand?: boolean; isMobile?: boolean; - isGroupCollapsed: (groupKey: string) => boolean; - toggleGroup: (groupKey: string) => void; - /** all = classic; primary = top flat entries; grouped = settings/control/admin */ + isGroupCollapsed: (groupId: string) => boolean; + toggleGroup: (groupId: string) => void; + /** all = classic; primary = ungrouped; grouped = named groups */ sectionFilter?: "all" | "primary" | "grouped"; - hideGroupHeaderKeys?: ReadonlySet; + hideHeaderIds?: ReadonlySet; }) { const { t } = useTranslation(); const role = useUserRole(); - const user = useCurrentUser(); const { hasUpdate } = useUpdateStatus(); - const { mobileEnabled } = useServerCapabilities(); - const navSections = buildNavSections(user, { mobileEnabled }).filter( - (section) => { - if (sectionFilter === "primary") return !section.groupKey; - if (sectionFilter === "grouped") return Boolean(section.groupKey); - return true; - }, - ); + const navSections = sections.filter((section) => { + if (sectionFilter === "primary") return !section.id; + if (sectionFilter === "grouped") return Boolean(section.id); + return true; + }); const MOBILE_HIDDEN_KEYS = new Set(); @@ -291,13 +298,13 @@ function NavList({ : section.items; if (visibleItems.length === 0) return null; - const sectionKey = section.groupKey ?? `flat-${sectionIndex}`; - const hideHeader = - Boolean(section.groupKey) && - Boolean(hideGroupHeaderKeys?.has(section.groupKey!)); - const isFlat = !section.groupKey || hideHeader; - const groupCollapsed = section.groupKey - ? isGroupCollapsed(section.groupKey) + const sectionKey = section.id ?? `flat-${sectionIndex}`; + const hideHeader = Boolean( + section.id && hideHeaderIds?.has(section.id), + ); + const isFlat = !section.id || hideHeader; + const groupCollapsed = section.id + ? isGroupCollapsed(section.id) : false; if (isFlat) { @@ -328,11 +335,11 @@ function NavList({ + } + footer={ +
+ + + +
+ + +
+
+ } + > +
+
{t("nav.ungrouped")}
+

{t("nav.ungroupedHint")}

+
+ +
{ + event.preventDefault(); + if (dragRef.current?.type === "group") dropGroup(null); + }} + > + {visibleEditorGroups(draft, catalog).map((group) => { + const labelKey = builtinNavGroupLabelKey(group.id); + const keys = draft.itemsByGroup[group.id] ?? []; + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + const payload = dragRef.current; + if (payload?.type === "group") { + dropGroup(group.id); + } else if (payload?.type === "item") { + dropItem( + { kind: "group", groupId: group.id }, + keys[0] ?? null, + ); + } + }} + > +
+ + onDragStart(event, { type: "group", id: group.id }) + } + > + + + + edit( + renameGroup( + draft, + group.id, + event.target.value ? event.target.value : null, + ), + ) + } + /> + edit(deleteGroup(draft, group.id))} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + > +
+
+ ); + })} +
+ +
+
{t("nav.hiddenItems")}
+
+ + ); +} + +function ItemList({ + zone, + keys, + items, + hidden, + t, + onDragStart, + onDropItem, + onMove, +}: { + zone: SidebarNavItemZone; + keys: string[]; + items: Map; + hidden: boolean; + t: (key: string) => string; + onDragStart: (event: DragEvent, payload: DragPayload) => void; + onDropItem: (zone: SidebarNavItemZone, beforeKey: string | null) => void; + onMove: (key: string, zone: SidebarNavItemZone) => void; +}) { + return ( +
{ + event.preventDefault(); + onDropItem(zone, null); + }} + > + {keys.length === 0 ? ( +
+ {hidden ? t("nav.hiddenEmpty") : t("nav.dropHere")} +
+ ) : null} + {keys.map((key) => { + const item = items.get(key); + if (!item) return null; + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + onDropItem(zone, key); + }} + > + { + event.stopPropagation(); + onDragStart(event, { type: "item", key }); + }} + > + + + {item.icon} + {t(item.labelKey)} +
+ ); + })} +
+ ); +} diff --git a/dashboard/src/layouts/sidebarNav.test.ts b/dashboard/src/layouts/sidebarNav.test.ts index 4e6983fac2..b5bb4012b1 100644 --- a/dashboard/src/layouts/sidebarNav.test.ts +++ b/dashboard/src/layouts/sidebarNav.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { SIDEBAR_GROUPED_NAV_KEYS, + SIDEBAR_NAV_KEYS, buildNavSections, isGroupedNavKey, } from "./sidebarNav"; @@ -42,4 +43,19 @@ describe("sidebarNav", () => { expect(isGroupedNavKey(key)).toBe(false); } }); + + it("covers every catalog key from the admin nav", () => { + const keys = buildNavSections(adminUser, { mobileEnabled: true }).flatMap( + (section) => section.items.map((item) => item.key), + ); + expect(new Set(keys)).toEqual(new Set(SIDEBAR_NAV_KEYS)); + const grouped = buildNavSections(adminUser, { mobileEnabled: true }).filter( + (section) => section.id, + ); + expect(grouped.map((section) => section.id)).toEqual([ + "settings", + "control", + "admin", + ]); + }); }); diff --git a/dashboard/src/layouts/sidebarNav.tsx b/dashboard/src/layouts/sidebarNav.tsx index b3f8be5dcd..66450316c2 100644 --- a/dashboard/src/layouts/sidebarNav.tsx +++ b/dashboard/src/layouts/sidebarNav.tsx @@ -11,6 +11,7 @@ import { Users as UsersIcon, Activity, Share2, + Cloudy, Sparkles, Puzzle, Package, @@ -37,11 +38,69 @@ export interface NavItem { } export interface NavSection { - /** When omitted, items render flat without a group header. */ + /** Stable group id. Omitted for the ungrouped block at the top. */ + id?: string; + /** Built-in label key. Omitted when {@link title} is set or the block is ungrouped. */ groupKey?: string; + /** User-defined group title. Wins over {@link groupKey}. */ + title?: string; items: NavItem[]; } +/** Every nav item key the sidebar can show. Keep in sync with `SIDEBAR_NAV_KEYS` in `octop.infra.users.preferences`. */ +export const SIDEBAR_NAV_KEYS = [ + "chat", + "experts", + "tasks", + "token-usage", + "personalization", + "channels", + "connectors", + "skill-packages", + "knowledge-bases", + "bridge", + "workbench", + "remote-desktop", + "acp", + "admin-users", + "models", + "admin-storage", + "admin-plugins", + "admin-security", + "admin-advanced", +] as const; + +export const BUILTIN_NAV_GROUP_IDS = ["settings", "control", "admin"] as const; + +const BUILTIN_NAV_GROUP_LABEL_KEYS: Record< + (typeof BUILTIN_NAV_GROUP_IDS)[number], + string +> = { + settings: "nav.settings", + control: "nav.control", + admin: "nav.admin", +}; + +export function isBuiltinNavGroupId(id: string): boolean { + return (BUILTIN_NAV_GROUP_IDS as readonly string[]).includes(id); +} + +export function builtinNavGroupLabelKey(id: string): string | null { + if (!isBuiltinNavGroupId(id)) return null; + return BUILTIN_NAV_GROUP_LABEL_KEYS[ + id as (typeof BUILTIN_NAV_GROUP_IDS)[number] + ]; +} + +export function navSectionLabel( + section: NavSection, + translate: (key: string) => string, +): string { + if (section.title) return section.title; + if (section.groupKey) return translate(section.groupKey); + return ""; +} + /** * Catalog of nav item keys that live under settings / control / admin groups. * Permission-independent — used for pane/route helpers; visibility still @@ -53,6 +112,7 @@ export const SIDEBAR_GROUPED_NAV_KEYS = [ "connectors", "skill-packages", "knowledge-bases", + "bridge", "workbench", "remote-desktop", "acp", @@ -146,8 +206,20 @@ export function buildNavSections( labelKey: "nav.knowledgeBases", }); } + // User-scoped remote Octop links — always available (like personalization). + settingsItems.push({ + key: "bridge", + path: "/bridge", + icon: , + labelKey: "nav.bridge", + badge: "Beta", + }); if (settingsItems.length > 0) { - sections.push({ groupKey: "nav.settings", items: settingsItems }); + sections.push({ + id: "settings", + groupKey: "nav.settings", + items: settingsItems, + }); } const controlItems: NavItem[] = []; @@ -181,7 +253,11 @@ export function buildNavSections( }); } if (controlItems.length > 0) { - sections.push({ groupKey: "nav.control", items: controlItems }); + sections.push({ + id: "control", + groupKey: "nav.control", + items: controlItems, + }); } const adminItems: NavItem[] = []; @@ -234,7 +310,7 @@ export function buildNavSections( }); } if (adminItems.length > 0) { - sections.push({ groupKey: "nav.admin", items: adminItems }); + sections.push({ id: "admin", groupKey: "nav.admin", items: adminItems }); } return sections; } diff --git a/dashboard/src/layouts/sidebarNavLayout.test.ts b/dashboard/src/layouts/sidebarNavLayout.test.ts new file mode 100644 index 0000000000..0a05499f83 --- /dev/null +++ b/dashboard/src/layouts/sidebarNavLayout.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import type { OctopUser } from "../api/modules/auth"; +import { buildNavSections } from "./sidebarNav"; +import { + createGroup, + deleteGroup, + editorFromCatalog, + layoutFromEditor, + placeItem, + sectionsFromLayout, + visibleEditorGroups, +} from "./sidebarNavLayout"; + +const adminUser = { + id: 1, + username: "admin", + role: "admin", + permissions: ["*"], +} as OctopUser; + +const limitedUser = { + id: 2, + username: "ada", + role: "user", + permissions: [], +} as OctopUser; + +function catalog() { + return buildNavSections(adminUser, { mobileEnabled: true }); +} + +describe("sidebarNavLayout", () => { + it("keeps the default catalog when no layout is saved", () => { + const sections = catalog(); + expect(sectionsFromLayout(sections, null)).toBe(sections); + const editor = editorFromCatalog(sections, null); + expect(editor.ungrouped).toEqual([ + "chat", + "experts", + "tasks", + "token-usage", + ]); + expect(editor.groups.map((group) => group.id)).toEqual([ + "settings", + "control", + "admin", + ]); + expect(editor.hidden).toEqual([]); + }); + + it("omits empty groups and hidden items from the sidebar", () => { + const sections = catalog(); + const applied = sectionsFromLayout(sections, { + groups: [ + { id: "settings", name: "配置" }, + { id: "control" }, + { id: "c_emptygrp1", name: "空组" }, + ], + items: [ + { key: "chat" }, + { key: "personalization", group: "settings" }, + { key: "token-usage", hidden: true }, + { key: "workbench", group: "control" }, + ], + }); + expect(applied.map((section) => section.id ?? "front")).toEqual([ + "front", + "settings", + "control", + ]); + expect(applied[0]?.items.map((item) => item.key)).toEqual(["chat"]); + expect(applied[1]?.title).toBe("配置"); + expect(applied[1]?.items.map((item) => item.key)).toEqual([ + "personalization", + ]); + expect(applied[2]?.groupKey).toBe("nav.control"); + }); + + it("sends keys missing from a saved layout to hidden", () => { + const editor = editorFromCatalog(catalog(), { + groups: [{ id: "settings" }], + items: [{ key: "chat" }], + }); + expect(editor.ungrouped).toEqual(["chat"]); + expect(editor.hidden).toContain("experts"); + expect(editor.hidden).toContain("personalization"); + expect( + sectionsFromLayout(catalog(), { + groups: [{ id: "settings" }], + items: [{ key: "chat" }], + }) + .flatMap((section) => section.items.map((item) => item.key)) + .includes("experts"), + ).toBe(false); + }); + + it("moves a deleted group's items to the front of the ungrouped list", () => { + const editor = editorFromCatalog(catalog(), null); + const next = deleteGroup(editor, "settings"); + expect(next.groups.map((group) => group.id)).toEqual(["control", "admin"]); + expect(next.ungrouped.slice(0, 2)).toEqual(["personalization", "channels"]); + expect(next.ungrouped).toContain("chat"); + }); + + it("places an item before another and can hide it", () => { + const editor = editorFromCatalog(catalog(), null); + const moved = placeItem(editor, "tasks", { kind: "ungrouped" }, "chat"); + expect(moved.ungrouped.slice(0, 2)).toEqual(["tasks", "chat"]); + const hidden = placeItem(moved, "tasks", { kind: "hidden" }, null); + expect(hidden.ungrouped).not.toContain("tasks"); + expect(hidden.hidden).toContain("tasks"); + const layout = layoutFromEditor(hidden); + expect(layout.items.find((item) => item.key === "tasks")?.hidden).toBe( + true, + ); + }); + + it("omits nav items and built-in groups the user cannot access", () => { + const sections = buildNavSections(limitedUser, { mobileEnabled: false }); + const editor = editorFromCatalog(sections, { + groups: [ + { id: "settings" }, + { id: "admin" }, + { id: "c_aabbccdd", name: "常用" }, + ], + items: [ + { key: "chat" }, + { key: "admin-users", group: "admin" }, + { key: "channels", group: "settings" }, + { key: "personalization", group: "settings" }, + ], + }); + const shown = [ + ...editor.ungrouped, + ...Object.values(editor.itemsByGroup).flat(), + ...editor.hidden, + ]; + expect(shown).not.toContain("admin-users"); + expect(shown).not.toContain("channels"); + expect(shown).toContain("chat"); + expect(shown).toContain("personalization"); + expect( + visibleEditorGroups(editor, sections).map((group) => group.id), + ).toEqual(["settings", "c_aabbccdd"]); + }); + + it("keeps a new empty group only in the editor", () => { + const editor = createGroup( + editorFromCatalog(catalog(), null), + "c_aabbccdd", + "常用", + ); + expect(editor.groups.at(-1)?.name).toBe("常用"); + const applied = sectionsFromLayout(catalog(), layoutFromEditor(editor)); + expect(applied.some((section) => section.id === "c_aabbccdd")).toBe(false); + }); +}); diff --git a/dashboard/src/layouts/sidebarNavLayout.ts b/dashboard/src/layouts/sidebarNavLayout.ts new file mode 100644 index 0000000000..22d2fadf48 --- /dev/null +++ b/dashboard/src/layouts/sidebarNavLayout.ts @@ -0,0 +1,359 @@ +import type { NavItem, NavSection } from "./sidebarNav"; +import { + SIDEBAR_NAV_KEYS, + builtinNavGroupLabelKey, + isBuiltinNavGroupId, +} from "./sidebarNav"; + +export interface SidebarNavGroup { + id: string; + /** Empty when a built-in group still uses its translated label. */ + name?: string | null; +} + +export interface SidebarNavItemPlacement { + key: string; + group?: string | null; + hidden?: boolean; +} + +export interface SidebarNavLayout { + groups: SidebarNavGroup[]; + items: SidebarNavItemPlacement[]; +} + +export interface SidebarNavEditorState { + groups: SidebarNavGroup[]; + ungrouped: string[]; + itemsByGroup: Record; + hidden: string[]; +} + +export type SidebarNavItemZone = + | { kind: "ungrouped" } + | { kind: "hidden" } + | { kind: "group"; groupId: string }; + +const BUILTIN_ID_BY_LABEL: Record = { + "nav.settings": "settings", + "nav.control": "control", + "nav.admin": "admin", +}; + +const NAV_KEY_SET = new Set(SIDEBAR_NAV_KEYS); + +export function catalogItems(sections: NavSection[]): Map { + const items = new Map(); + for (const section of sections) { + for (const item of section.items) items.set(item.key, item); + } + return items; +} + +function defaultEditor(sections: NavSection[]): SidebarNavEditorState { + const ungrouped: string[] = []; + const groups: SidebarNavGroup[] = []; + const itemsByGroup: Record = {}; + for (const section of sections) { + const keys = section.items.map((item) => item.key); + if (!section.id && !section.groupKey && !section.title) { + ungrouped.push(...keys); + continue; + } + const id = + section.id ?? + (section.groupKey ? BUILTIN_ID_BY_LABEL[section.groupKey] : undefined) ?? + section.groupKey ?? + "group"; + groups.push({ id, name: section.title ?? null }); + itemsByGroup[id] = keys; + } + return { groups, ungrouped, itemsByGroup, hidden: [] }; +} + +function defaultKeyOrder(state: SidebarNavEditorState): string[] { + return [ + ...state.ungrouped, + ...state.groups.flatMap((group) => state.itemsByGroup[group.id] ?? []), + ]; +} + +/** Editor model for the drawer. A missing layout is the system default. */ +export function editorFromCatalog( + sections: NavSection[], + layout: SidebarNavLayout | null, +): SidebarNavEditorState { + const base = defaultEditor(sections); + if (!layout) return base; + + const catalogKeys = new Set(defaultKeyOrder(base)); + const groups: SidebarNavGroup[] = []; + const groupIds = new Set(); + for (const group of layout.groups) { + if (groupIds.has(group.id)) continue; + groupIds.add(group.id); + groups.push({ id: group.id, name: group.name ?? null }); + } + const itemsByGroup: Record = {}; + for (const group of groups) itemsByGroup[group.id] = []; + + const ungrouped: string[] = []; + const hidden: string[] = []; + const seen = new Set(); + for (const item of layout.items) { + if (!catalogKeys.has(item.key) || seen.has(item.key)) continue; + seen.add(item.key); + if (item.hidden) { + hidden.push(item.key); + continue; + } + if (item.group && groupIds.has(item.group)) { + itemsByGroup[item.group].push(item.key); + } else { + ungrouped.push(item.key); + } + } + for (const key of defaultKeyOrder(base)) { + if (!seen.has(key)) hidden.push(key); + } + return { groups, ungrouped, itemsByGroup, hidden }; +} + +/** + * Groups to show in the customizer. + * Built-in groups the user cannot access stay out. Empty custom groups stay, + * so items can be dragged back in. + */ +export function visibleEditorGroups( + state: SidebarNavEditorState, + sections: NavSection[], +): SidebarNavGroup[] { + const catalogGroupIds = new Set( + sections.flatMap((section) => (section.id ? [section.id] : [])), + ); + return state.groups.filter((group) => { + const count = state.itemsByGroup[group.id]?.length ?? 0; + if (count > 0) return true; + if (!isBuiltinNavGroupId(group.id)) return true; + return catalogGroupIds.has(group.id); + }); +} + +/** Keys stored for items the current user cannot see, so a later save does not drop them. */ +export function preservedPlacements( + layout: SidebarNavLayout | null, + sections: NavSection[], +): SidebarNavItemPlacement[] { + if (!layout) return []; + const visible = new Set(catalogItems(sections).keys()); + return layout.items.filter( + (item) => NAV_KEY_SET.has(item.key) && !visible.has(item.key), + ); +} + +export function layoutFromEditor( + state: SidebarNavEditorState, + preserved: SidebarNavItemPlacement[] = [], +): SidebarNavLayout { + const items: SidebarNavItemPlacement[] = []; + const seen = new Set(); + const push = (item: SidebarNavItemPlacement) => { + if (seen.has(item.key)) return; + seen.add(item.key); + items.push(item); + }; + for (const key of state.ungrouped) push({ key }); + for (const group of state.groups) { + for (const key of state.itemsByGroup[group.id] ?? []) { + push({ key, group: group.id }); + } + } + for (const key of state.hidden) push({ key, hidden: true }); + for (const extra of preserved) push(extra); + return { + groups: state.groups.map((group) => { + const name = group.name?.trim() ?? ""; + return name ? { id: group.id, name } : { id: group.id }; + }), + items, + }; +} + +function sectionForGroup( + group: SidebarNavGroup, + items: NavItem[], +): NavSection | null { + if (items.length === 0) return null; + const custom = group.name?.trim() ?? ""; + if (custom) return { id: group.id, title: custom, items }; + const labelKey = builtinNavGroupLabelKey(group.id); + if (labelKey) return { id: group.id, groupKey: labelKey, items }; + return null; +} + +/** Sidebar sections. Empty groups are omitted. `null` layout keeps the catalog. */ +export function sectionsFromLayout( + sections: NavSection[], + layout: SidebarNavLayout | null, +): NavSection[] { + if (!layout) return sections; + const byKey = catalogItems(sections); + const editor = editorFromCatalog(sections, layout); + const result: NavSection[] = []; + const ungrouped = editor.ungrouped + .map((key) => byKey.get(key)) + .filter((item): item is NavItem => Boolean(item)); + if (ungrouped.length > 0) result.push({ items: ungrouped }); + for (const group of editor.groups) { + const items = (editor.itemsByGroup[group.id] ?? []) + .map((key) => byKey.get(key)) + .filter((item): item is NavItem => Boolean(item)); + const section = sectionForGroup(group, items); + if (section) result.push(section); + } + return result; +} + +function stripKey( + state: SidebarNavEditorState, + key: string, +): SidebarNavEditorState { + const itemsByGroup: Record = {}; + for (const [id, keys] of Object.entries(state.itemsByGroup)) { + itemsByGroup[id] = keys.filter((item) => item !== key); + } + return { + ...state, + ungrouped: state.ungrouped.filter((item) => item !== key), + hidden: state.hidden.filter((item) => item !== key), + itemsByGroup, + }; +} + +function zoneList( + state: SidebarNavEditorState, + zone: SidebarNavItemZone, +): string[] { + if (zone.kind === "ungrouped") return state.ungrouped; + if (zone.kind === "hidden") return state.hidden; + return state.itemsByGroup[zone.groupId] ?? []; +} + +function withZoneList( + state: SidebarNavEditorState, + zone: SidebarNavItemZone, + list: string[], +): SidebarNavEditorState { + if (zone.kind === "ungrouped") return { ...state, ungrouped: list }; + if (zone.kind === "hidden") return { ...state, hidden: list }; + return { + ...state, + itemsByGroup: { ...state.itemsByGroup, [zone.groupId]: list }, + }; +} + +/** Insert `key` before `beforeKey`. `beforeKey` null appends. */ +export function placeItem( + state: SidebarNavEditorState, + key: string, + zone: SidebarNavItemZone, + beforeKey: string | null, +): SidebarNavEditorState { + if (beforeKey === key) return state; + if ( + zone.kind === "group" && + !state.groups.some((group) => group.id === zone.groupId) + ) { + return state; + } + const stripped = stripKey(state, key); + const list = [...zoneList(stripped, zone)]; + const index = beforeKey ? list.indexOf(beforeKey) : list.length; + list.splice(index < 0 ? list.length : index, 0, key); + return withZoneList(stripped, zone, list); +} + +export function moveGroup( + state: SidebarNavEditorState, + groupId: string, + beforeId: string | null, +): SidebarNavEditorState { + if (beforeId === groupId) return state; + const moving = state.groups.find((group) => group.id === groupId); + if (!moving) return state; + const groups = state.groups.filter((group) => group.id !== groupId); + const index = beforeId + ? groups.findIndex((group) => group.id === beforeId) + : groups.length; + groups.splice(index < 0 ? groups.length : index, 0, moving); + return { ...state, groups }; +} + +export function createGroup( + state: SidebarNavEditorState, + id: string, + name: string, +): SidebarNavEditorState { + return { + ...state, + groups: [...state.groups, { id, name }], + itemsByGroup: { ...state.itemsByGroup, [id]: [] }, + }; +} + +export function renameGroup( + state: SidebarNavEditorState, + id: string, + name: string | null, +): SidebarNavEditorState { + return { + ...state, + groups: state.groups.map((group) => + group.id === id ? { ...group, name } : group, + ), + }; +} + +/** Drop the group. Its items become ungrouped and move to the front. */ +export function deleteGroup( + state: SidebarNavEditorState, + groupId: string, +): SidebarNavEditorState { + const released = state.itemsByGroup[groupId] ?? []; + const itemsByGroup = { ...state.itemsByGroup }; + delete itemsByGroup[groupId]; + return { + ...state, + groups: state.groups.filter((group) => group.id !== groupId), + ungrouped: [...released, ...state.ungrouped], + itemsByGroup, + }; +} + +export function nextGroupName( + state: SidebarNavEditorState, + base: string, +): string { + const names = new Set( + state.groups.map((group) => (group.name ?? "").trim()).filter(Boolean), + ); + if (!names.has(base)) return base; + let n = 2; + while (names.has(`${base} ${n}`)) n += 1; + return `${base} ${n}`; +} + +export function newNavGroupId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)); + const body = Array.from(bytes, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + return `c_${body}`; +} + +export function groupNameMissing(state: SidebarNavEditorState): boolean { + return state.groups.some((group) => { + if (isBuiltinNavGroupId(group.id)) return false; + return !(group.name ?? "").trim(); + }); +} diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index cbf11fe176..f5a90b3f67 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -1,7 +1,6 @@ { "app": { - "pageTitle": "Octop - An intelligent partner that understands you, helps you, and grows with you", - "appName": "Octop" + "pageTitle": "Octop - An intelligent partner that understands you, helps you, and grows with you" }, "layout": { "collapsePanel": "Collapse panel", @@ -29,6 +28,7 @@ "more": "More", "upload": "Upload", "download": "Download", + "savedToDownloads": "Saved to the Downloads folder", "refresh": "Refresh", "enable": "Enable", "disable": "Disable", @@ -75,7 +75,11 @@ }, "copyErrorForOctop": "Copy error details", "installLogEmpty": "(No install log available)", - "skipToContent": "Skip to main content" + "tokenCountPresets": "Quick fill", + "tokenCountPreview": "About {{label}} tokens ({{exact}})", + "back": "Back", + "comingSoon": "Coming soon", + "notImplemented": "This page is under construction." }, "apiErrors": { "AUTH_FAILED": "Authentication failed.", @@ -86,6 +90,7 @@ "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.", + "UPDATE_TARGET_NOT_NEWER": "Target version {{target}} must be newer than installed version {{current}}. Reinstalling or downgrading is not supported.", "NOT_FOUND": "Not found.", "USER_DISABLED": "This account has been disabled.", "LOGIN_LOCKED": "Too many failed sign-in attempts. Try again in {{minutes}} minutes.", @@ -93,12 +98,14 @@ "EMAIL_TAKEN": "Email is already taken.", "EMAIL_INVALID": "Invalid email address.", "PASSWORD_SAME_AS_OLD": "New password must be different from the current password.", + "PASSWORD_NOT_SET": "This account signs in through a directory and has no local password to change.", "PASSWORD_TOO_WEAK": "Password is too weak: at least 8 characters with letters and digits.", "SETUP_PASSWORD_WRONG": "Incorrect setup password.", "SETUP_RATE_LIMITED": "Too many setup password attempts. Try again later.", "SETUP_TOKEN_INVALID": "Setup session is invalid or expired. Verify the setup password again.", "AGENT_NOT_FOUND": "Agent not found.", "AGENT_NOT_RUNNING": "This expert is not enabled.", + "CHECKPOINT_DELETE_FAILED": "Conversation data was not deleted. The thread is still here; try again.", "AGENT_FAILED": "Agent failed to start.", "AGENT_BUSY": "Agent is busy.", "AGENT_NAME_TAKEN": "This name is already in use. Please choose another.", @@ -112,6 +119,7 @@ "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.", + "STORAGE_BROWSE_FAILED": "Could not browse this storage backend.", "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.", @@ -125,6 +133,17 @@ "CONNECTOR_NOT_BOUND": "Connector is not bound to this agent.", "CONNECTOR_ALREADY_BOUND": "Connector is already bound.", "CONNECTOR_MCP_LOAD_FAILED": "Failed to load tools for MCP server(s): {{servers}}", + "BRIDGE_NOT_FOUND": "Cloud collab connection not found.", + "BRIDGE_AUTH_FAILED": "Peer Octop rejected the credentials. Check the address, username, and password — or use Probe to verify the account first.", + "BRIDGE_PEER_UNREACHABLE": "Could not reach the peer Octop instance. Confirm the URL is reachable, credentials are correct, and the peer has cloud collab support (needs /api/bridge/ws). If the peer is behind NAT, initiate the link from the side that can reach the other.", + "BRIDGE_NOT_CONNECTED": "Cloud collab is not connected. Reconnect, or check that the peer is online.", + "BRIDGE_TUNNEL_FAILED": "Cloud collab request failed. The link may have dropped — reconnect and try again.", + "BRIDGE_REMOTE_UNSUPPORTED": "This action is not available on the peer expert. Manage it on the peer Octop.", + "BRIDGE_DISPLAY_NAME_TAKEN": "Display name \"{{name}}\" is already in use. Choose another.", + "BRIDGE_PEER_ALREADY_LINKED": "This peer account is already linked as \"{{name}}\". Use the existing connection.", + "BRIDGE_CONNECTION_LIMIT": "At most {{limit}} cloud collab connections per user. Delete one to add another.", + "BRIDGE_SELF_CONNECT": "Cannot add this Octop instance as a peer of itself.", + "BRIDGE_INBOUND_PASSIVE": "This link was opened by the peer. This side cannot change the address or dial out. After disconnect, the peer must reopen it.", "CRON_TRIGGER_INVALID": "Invalid cron trigger.", "SLASH_UNKNOWN": "Unknown slash command.", "SLASH_BAD_ARGS": "Invalid arguments.", @@ -134,6 +153,7 @@ "VOICE_KIND_UNSUPPORTED": "Unsupported voice provider.", "VOICE_CAPABILITY_MISMATCH": "Voice capability does not match.", "VOICE_PROVIDER_DISABLED": "Voice provider is disabled.", + "VOICE_REALTIME_UNAVAILABLE": "Realtime speech recognition is unavailable. Check the Tencent Cloud voice settings.", "INTERNAL_ERROR": "Internal server error.", "TLS_NOT_ELIGIBLE": "TLS issuance is not available in the current server configuration.", "TLS_ISSUE_IN_PROGRESS": "A certificate issuance task is already running.", @@ -145,12 +165,16 @@ "SKILL_PACKAGE_NOT_FOUND": "Skill package not found.", "SKILL_PACKAGE_NAME_TAKEN": "Skill package {{name}} already exists.", "SKILL_PACKAGE_BACKEND_UNSUPPORTED": "Skill packages currently support only “Full local environment (filesystem + shell)” or “Local filesystem (no shell commands)”, with storage root / (POSIX) or the agent workspace root (Windows default).", - "ACP_BACKEND_UNSUPPORTED": "This agent has a sandbox enabled, so external runners can’t be used. Connecting via IDE / octop acp is unaffected.", + "ACP_BACKEND_UNSUPPORTED": "This agent has a sandbox enabled, so external runners can’t be enabled or edited here. The outbound acp_runner tool toggle is unaffected; connecting via IDE / octop acp is also unaffected.", "PUBLISHED_EXPERT_SLUG_TAKEN": "Published expert slug {{slug}} already exists.", "PUBLISHED_EXPERT_ALREADY_EXISTS": "This agent is already published as “{{name}}”. Refresh the published template instead.", "OIDC_BAD_REQUEST": "Invalid OIDC request: {{detail}}", "SSO_IDENTITY_TAKEN": "This login is already linked to another Octop account.", "SSO_UNBIND_REQUIRES_PASSWORD": "Set a password before unlinking single sign-on.", + "LDAP_BAD_REQUEST": "Invalid LDAP configuration: {{detail}}", + "LDAP_UNAVAILABLE": "The LDAP directory is unavailable. Check the server address and bind credentials.", + "LDAP_USER_NOT_PROVISIONED": "This directory account has no Octop account yet. Ask an administrator to create one or enable automatic provisioning.", + "LDAP_GROUP_NOT_ALLOWED": "This directory account is not in a group allowed to sign in. Ask an administrator to add you to an allowed group.", "EXPERT_MARKET_FAILED": "Expert market is temporarily unavailable. Please try again later.", "SKILLHUB_SSL_FAILED": "SkillHub request failed: Python SSL error detected — on macOS try `brew reinstall openssl@3`, or check your system CA certificates.", "DESKTOP_SESSION_LIMIT": "Too many concurrent remote desktop streams (max {{limit}}). Close other connections and try again.", @@ -171,6 +195,8 @@ "AVATAR_INVALID": "Avatar must be a PNG, JPEG, WebP, or GIF image.", "AVATAR_TOO_LARGE": "Avatar exceeds the 5 MB size limit.", "INVITE_INVALID": "This invite code is invalid.", + "INVITE_ROLE_MISSING": "The role on this invite was deleted or renamed, so the account cannot be created.", + "USER_ROLE_IN_USE": "This role is still assigned to users. Reassign them before deleting it.", "INVITE_USED": "This invite code has already been used.", "INVITE_EXPIRED": "This invite code has expired.", "INVITE_REVOKED": "This invite code has been revoked.", @@ -178,14 +204,18 @@ "WORKSPACE_ROOT_RESTRICTED": "The selected storage root is outside the allowed directory for this account.", "WORKSPACE_ROOT_CONTAINER_UNSUPPORTED": "Allowed storage root cannot be set while Octop is running in a container.", "TOKEN_QUOTA_EXCEEDED": "This account has reached its token quota. Please contact an administrator to raise the limit ({{used}}/{{quota}}).", + "AGENT_QUOTA_EXCEEDED": "This account has reached its expert limit. Please contact an administrator to raise the limit ({{used}}/{{quota}}).", "CAPTCHA_REQUIRED": "This server requires a captcha token to sign in.", "CAPTCHA_FAILED": "Captcha verification failed. Try again.", "CONFIG_FILE_CORRUPT": "Cannot update {{path}}: the file is not valid JSON ({{detail}}). Fix the syntax and retry — no settings were changed.", "TEAM_NOT_FOUND": "Team not found.", "TEAM_MEMBERS_TOO_FEW": "A team needs at least two members.", - "TEAM_MEMBER_INVALID": "The selected roster is not valid.", + "TEAM_MEMBER_INVALID": "Those experts cannot join a team on this instance. Peer experts, other teams, and deleted experts are not allowed.", "TEAM_MEMBER_BUSY": "This expert has an in-flight team job. Wait until it finishes.", - "TEAM_NOT_SHAREABLE": "Teams cannot be shared in this version." + "TEAM_NOT_SHAREABLE": "Teams cannot be shared in this version.", + "LOCAL_DOWNLOAD_UNAVAILABLE": "Saving to the host Downloads folder is only available when Octop is opened on this computer.", + "SKILL_PACKAGE_COPY_DENIED": "The creator of this skill package has disabled copying its skills.", + "SKILL_PACKAGE_LOCKED": "This skill was copied from a locked skill package and cannot be edited in the workspace." }, "nav": { "chat": "Conversation", @@ -194,7 +224,8 @@ "experts": "Experts", "tasks": "Automation", "connectors": "Connectors", - "acp": "ACP", + "bridge": "Cloud collab", + "acp": "ACP Collaboration", "agents": "Agents", "providers": "Providers", "models": "Model Management", @@ -231,6 +262,7 @@ "tokenUsage": "Token Usage", "checkUpdates": "App Updates", "newVersionBadge": "New version", + "newVersionBadgeShort": "NEW", "admin": "Admin", "adminUsers": "Users", "adminSso": "Single sign-on", @@ -246,11 +278,30 @@ "records": "Tasks", "paneSettings": "Settings", "paneRecords": "Tasks", - "mainNavigation": "Main navigation" + "customize": "Customize navigation", + "customizeTitle": "Customize navigation", + "ungrouped": "Ungrouped", + "ungroupedHint": "Shown at the top of the sidebar, without a heading.", + "hiddenItems": "Hidden", + "hiddenEmpty": "Nothing hidden", + "newGroup": "New group", + "newGroupDefault": "New group", + "deleteGroup": "Delete group", + "deleteGroupConfirm": "Items in this group move to the front and are no longer grouped.", + "resetLayout": "Reset", + "resetLayoutConfirm": "Restore the default navigation? Current groups and hidden items will be cleared.", + "dropHere": "Drop items here", + "showInNav": "Show in sidebar", + "hideFromNav": "Hide from sidebar", + "groupNamePlaceholder": "Group name", + "groupNameRequired": "Enter a group name", + "saveFailed": "Could not save the navigation layout", + "dragToReorder": "Drag to reorder", + "chatHistory": "Session list" }, "skillPackages": { "title": "Skill Packages", - "subtitle": "Create and maintain reusable skill collections that can be mounted on agents.", + "subtitle": "Create and maintain reusable skill packages, add skills, then mount them on experts", "createPackage": "Create Skill Package", "editPackage": "Edit Skill Package", "createPackageHint": "A skill package is a shared collection of skills. After creating it, add skills and mount the package on agents.", @@ -303,11 +354,16 @@ "packageId": "Package ID", "copyPackageId": "Click to copy package ID", "collapseListPanel": "Collapse list", - "expandListPanel": "Expand list" + "expandListPanel": "Expand list", + "copyPolicy": "Copy policy", + "copyPolicyHint": "Controls whether users other than the creator can copy this package's skills into their agents. Read-only copies are enforced on Dashboard and workspace HTTP writes; Agent tool writes still need harness support (#770).", + "copyPolicySnapshot": "Copy freely", + "copyPolicyLock": "Copy (console read-only)", + "copyPolicyDeny": "No copying" }, "knowledgeBases": { "title": "Knowledge Bases", - "subtitle": "Index documents for chat retrieval. Optionally share them with users on this instance.", + "subtitle": "Upload and index documents for retrieval in chat, and share them if needed", "featureTitle": "Enable Knowledge Bases", "settingsTitle": "Knowledge base settings", "settingsLead": "Instance-wide switch. When on, configure an embedding model. When off, knowledge bases are unavailable for everyone.", @@ -660,6 +716,24 @@ "skipHint": "You can skip this step. Web browsing, screenshots, and online automation features will not be available until a browser is installed." } }, + "mbti": { + "INTJ": "Architect", + "INTP": "Logician", + "ENTJ": "Commander", + "ENTP": "Debater", + "INFJ": "Advocate", + "INFP": "Mediator", + "ENFJ": "Protagonist", + "ENFP": "Campaigner", + "ISTJ": "Logistician", + "ISFJ": "Defender", + "ESTJ": "Executive", + "ESFJ": "Consul", + "ISTP": "Virtuoso", + "ISFP": "Adventurer", + "ESTP": "Entrepreneur", + "ESFP": "Entertainer" + }, "experts": { "subtitle": "Spin up a pre-configured agent (with identity / soul / heartbeat baked in) from a scenario template. Each expert ships with its own system prompt — once created, just bind a provider and start it.", "loadFailed": "Failed to load experts", @@ -750,6 +824,8 @@ "actions": "Actions" }, "totalAgents": "{{count}} expert(s)", + "searchPlaceholder": "Search experts", + "searchEmpty": "No matching experts", "totalLibrary": "{{count}} built-in expert(s)", "totalMarket": "{{count}} market expert(s)", "addFromLibrary": "+ Add from Built-in", @@ -784,7 +860,7 @@ "membersSearch": "Search experts", "membersEmpty": "No experts available", "membersSelected": "{{count}} selected", - "membersHint": "Save applies the current selection as the full roster. At least two members.", + "membersHint": "Only local experts can join a team on this instance — peer experts cannot. Save applies the current selection as the full roster. At least two members.", "memberAvatars": "Members", "memberMore": "+{{count}}", "memoryBtn": "Team memory", @@ -891,9 +967,15 @@ "agentDeleteFailed": "Failed to delete agent", "confirmDelete": "Delete \"{{name}}\"?", "confirmDeleteHint": "This cannot be undone. The agent workspace (including memory and session files) will also be permanently deleted and cannot be recovered.", + "confirmDeleteRemote": "Delete \"{{name}}\" on the peer?", + "confirmDeleteRemoteWhere": " \"{{connection}}\"", + "confirmDeleteRemoteHint": "This permanently deletes it on the peer{{connection}}, including the workspace, memory, and sessions. This cannot be undone. The local list is only an entry point.", + "agentDisable": "Disable", "patchFailed": "Failed to save changes", "defaultModelLabel": "Default Model", "defaultModelPlaceholder": "Select a model", + "defaultModeLabel": "Default Conversation Mode", + "defaultModeHint": "Ask / Plan / Craft mode for new conversations; Craft is the full-execution mode. Existing conversations are unaffected.", "skillPackagesLabel": "Optional Skill Packages", "skillPackagesHint": "Mount packages at create time (local storage with root /).", "skillPackagesUnsupportedHint": "This sandbox cannot mount skill-package directories directly. You can still use Copy skills to create independent workspace copies.", @@ -962,12 +1044,11 @@ "backendLabel": "Storage Backend", "backendRootDir": "Storage Root Dir", "backendRootDirPlaceholder": "Browse and select a directory", - "backendRootDirDesc": "Defaults to the server user's home directory ({{home}}). You may also browse from filesystem root `/`.", - "backendRootDirDescContainer": "Running in a container: defaults to filesystem root `/` (the container is already the isolation boundary). You may still browse and pick another directory.", - "backendRootDirDescAdmin": "Defaults to the server user's home directory ({{home}}). You may also browse from filesystem root `/`.", + "backendRootDirDesc": "Defaults to the filesystem root (`/` on POSIX, the home drive on Windows). You may also browse and pick another directory.", + "backendRootDirDescContainer": "Running in a container: defaults to the filesystem root `/` (the container is already the isolation boundary). You may still browse and pick another directory.", "backendRootDirJailHint": "Saving to a non-root directory enables a sandbox and confines the AI filesystem to that folder. External ACP runners can’t be used with this setup; connecting via IDE / octop acp is unaffected.", + "backendRootDirPathLimitHint": "Saving to a non-root directory limits the AI’s file access to that folder, but this host provides no OS-level sandbox — the AI process still runs with the service account’s own permissions. External ACP runners can’t be used with this setup; connecting via IDE / octop acp is unaffected.", "backendRootDirImmutableHint": "Cannot be changed after creation.", - "rootDirOutsideHomeWarning": "This path is outside your home directory ({{home}}).", "rootDirListFailed": "Could not list subdirectories. Check permissions or choose another path.", "rootDirMkdir": "New folder", "rootDirRename": "Rename folder", @@ -1062,11 +1143,7 @@ "mbtiBtn": "MBTI", "channelsBtn": "Channels", "memoryBtn": "Memory", - "fileLoadFailed": "Failed to load {{filename}}", - "unsavedChangesTitle": "Unsaved changes", - "unsavedChangesBody": "You have unsaved edits in this file. Closing now will discard them.", - "unsavedChangesKeepEditing": "Keep editing", - "unsavedChangesDiscard": "Discard changes" + "openChat": "Chat" }, "subagents": { "catalogTitle": "Manage subagents", @@ -1145,25 +1222,51 @@ } }, "agent_errors": { - "no_models_configured": "No models are configured. Go to Settings → Models to add a Provider, then enable this expert again.", - "model_ref_unavailable": "The default model is unavailable or disabled. Check Settings → Models or update this expert's default model, then try again." + "no_models_configured": "No models are configured. Go to Admin → Model Management to add a provider, then enable this expert again.", + "model_ref_unavailable": "The default model is unavailable or disabled. Check Admin → Model Management or update this expert's default model, then try again.", + "start_timeout": "This expert took too long to start (storage backend I/O). Try again, or switch it to a local workspace backend." }, "stream_errors": { "stream_stall": "The model stopped sending content while the connection stayed open. Click Retry, or try again later with another model. If this happens often, check the provider status or ask an admin to review stream timeout settings.", "rate_limit": "The model provider rate-limited this request. Wait a moment and retry, or switch to another model.", - "auth": "Model authentication failed. Check that the API key under Settings → Models is correct and still valid.", + "auth": "Model authentication failed. Check that the API key under Admin → Model Management is correct and still valid.", "insufficient_balance": "The model provider rejected this request due to insufficient balance or quota. Top up or upgrade the plan for this API key, then try again.", "context_length": "This conversation is too long for the model's context window. Start a new chat, or shorten the history and try again.", "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", "provider_unavailable": "The model provider is temporarily unavailable. Retry later, or switch to another model.", - "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models." + "invalid_request": "This endpoint may reject the live-token field. Turn off “Show live token count in chat” and retry. If it still fails, check whether the model supports tool calling.", + "model_call_failed": "The model call failed after several retries. Wait a moment and retry. If it keeps failing, check the model settings or switch models.", + "model_call_failed_detail": "The model call failed: {{detail}}", + "path_outside_root": "The file path is outside this expert's storage root, so it cannot be read or written. Use a path under the storage root, or widen the storage root in expert settings (on Windows, set an explicit drive path such as D:/octop-data)." }, "chat": { "sharedExpert": { "banner": "Shared expert · provided by {{name}}", "flag": "Shared" }, + "remoteExpert": { + "banner": "Peer expert · via “{{name}}”. Chat history stays on the expert’s instance.", + "flag": "Peer", + "manageOnPeer": "Edit on the peer", + "manageToast": "This is a peer expert. Manage skills, models, knowledge bases, and related settings on the peer Octop.", + "editBanner": "You are editing a peer expert. Changes are saved on the peer Octop.", + "editSkillPackages": "Skill packages stay on this instance and cannot be installed onto a peer expert. That tab is hidden.", + "editTools": "ACP runners execute on the peer. Plugin icons that are local files on the peer may not preview here.", + "editPlugins": "You can toggle plugins already installed on the peer. Install, uninstall, and marketplace stay on the peer Octop.", + "editChannels": "QR login and bot-creator flows run on the peer machine, not this browser.", + "editTasks": "Tasks use the peer timezone. Model and connector options come from the peer; install new connectors there.", + "disconnectedTitle": "Cloud collab disconnected", + "disconnectedHint": "This peer expert is still selected. Reconnect to keep chatting and editing it.", + "disconnectedHintNamed": "The “{{name}}” link dropped. This peer expert is still selected — reconnect to continue.", + "waitForPeer": "The peer is offline. This side cannot redial — wait for the peer to reopen the link.", + "waitForPeerNamed": "“{{name}}” is offline. This side cannot redial — wait for the peer to reopen the link.", + "reconnect": "Reconnect", + "openBridge": "View connection", + "peerOnlyTitle": "This page stays on this instance", + "peerOnlyAcp": "ACP runners listed here belong to this Octop. Pick a local expert to toggle its ACP tool, or open the peer expert’s Tools tab to manage the peer.", + "peerOnlyBrowser": "This page controls the browser on this host. For a peer expert, open the browser dock in chat instead." + }, "pushToast": { "title": "{{name}} sent a reminder", "titleFallback": "Task reminder" @@ -1208,7 +1311,14 @@ "continuing": "Continuing", "generating": "Generating", "generatingWithElapsed": "Generating · {{seconds}}s", - "messageLog": "Conversation messages", + "membersResponding": "Members responding", + "memberResponding": "{{name}} is responding", + "membersRespondingNamed": "{{names}} are responding", + "nameJoin": ", ", + "processOrganizing": "Organizing results", + "newRepliesBelow": "New replies", + "teamWrapupBadge": "Host wrap-up", + "stopTeamHint": "Stop this turn; member replies already in flight may still arrive", "scrollToBottom": "Back to bottom", "turnTimeline": { "label": "Conversation turns", @@ -1232,7 +1342,7 @@ "retry": "Retry", "like": "Helpful", "dislike": "Not helpful", - "selectModel": "Select model", + "selectModel": "Model", "modelAuto": "Auto", "modelAutoHint": "Use agent default model", "reasoningMode": "Thinking mode", @@ -1245,6 +1355,8 @@ "ask": "Ask mode", "plan": "Plan mode", "craft": "Craft mode", + "askReminder": "Ask mode · read-only", + "planReminder": "Plan mode · write plans only", "picker": "Conversation mode", "askHint": "Read-only: read files and search the web. No writes or skills.", "planHint": "Explore read-only, then write a plan to plans/*.md.", @@ -1262,10 +1374,13 @@ "hideThinking": "Hide thinking", "showThinking": "Show thinking", "collapseThinking": "Collapse thinking by default", - "collapseThinkingHint": "Keep thinking and tool calls collapsed while generating replies. You can still expand them manually. Saved in this browser.", + "collapseThinkingHint": "Team chats collapse thinking and tool panels by default; solo chats stay expanded. You can change either anytime. Each preference is saved in this browser.", "processSummary": "Called {{tools}} tools, {{thinking}} deep thoughts", "processSummaryToolsOnly": "Called {{tools}} tools", "processSummaryThinkingOnly": "{{thinking}} deep thoughts", + "processRunningTool": "Calling {{name}}", + "processRunningTools": "Calling tools", + "processThinkingLive": "Thinking", "send": "Send", "stop": "Stop", "queue": { @@ -1291,6 +1406,9 @@ "agentStartingHint": "Please wait until the expert is running.", "goToExperts": "Go to Experts", "goToAgentConfig": "Open Configuration", + "disableStreamUsageAndRetry": "Turn off live tokens and retry", + "disableStreamUsageSuccess": "Live token counts are off. Retry the chat.", + "disableStreamUsageFailed": "Could not turn this off automatically. In model settings, disable “Show live token count in chat”.", "noAgentsTitle": "No experts yet", "noAgentsHint": "Pick a template from the Expert List to create your first AI expert.", "createExpert": "Create an expert", @@ -1303,6 +1421,16 @@ "pin": "Pin", "unpin": "Unpin", "deleteSessionConfirm": "Delete this conversation? This cannot be undone.", + "deleteSessionTitle": "Delete this conversation?", + "deleteSessionBody": "This cannot be undone. The conversation leaves the list immediately. The database file does not get smaller until it is compacted.", + "deleteSessionAndCompactHint": "Delete and compact: compact now. This expert pauses briefly.", + "deleteSessionOnlyHint": "Delete only: leave the file for later. After this expert is idle, scheduled maintenance returns the freed space.", + "deleteSessionAndCompact": "Delete and compact", + "deleteSessionOnly": "Delete only", + "deleteSessionCompacting": "Deleting and compacting the database…", + "deleteSessionCompacted": "Conversation deleted. The database has been compacted.", + "deleteSessionCompactLater": "Conversation deleted. It could not be compacted just now. Maintenance will continue once this expert is idle.", + "deleteSessionLater": "Conversation deleted. After this expert is idle, scheduled maintenance will return the freed space.", "resizeSidebar": "Resize sidebar", "expandHistorySidebar": "Show conversation history", "collapseHistorySidebar": "Hide conversation history", @@ -1325,10 +1453,10 @@ "knowledgePickerSearch": "Search knowledge bases", "knowledgePickerEmpty": "No knowledge bases available", "manageKnowledgeBases": "Manage knowledge bases", - "skillPicker": "Select skills", + "skillPicker": "Skills", "skillPickerSearch": "Search skills", "skillPickerEmpty": "No skills available", - "expertPicker": "Select experts", + "expertPicker": "Experts", "expertPickerSearch": "Search experts", "expertPickerEmpty": "No other experts available", "expertPickerManage": "Manage experts", @@ -1341,18 +1469,22 @@ "expertListHidden": "Hidden experts ({{count}})", "expertListShowVisible": "Back to expert list", "expertListHiddenEmpty": "No hidden shared experts", - "subagentPicker": "Select subagents", + "subagentPicker": "Subagents", "subagentPickerSearch": "Search subagents", "subagentPickerEmpty": "No installed subagents", "subagentPickerManage": "Manage subagents", "modelPickerManage": "Manage models", - "sessionWorking": "Working", + "modelPickerSearch": "Search models", + "modelPickerEmpty": "No matching models", + "sessionWorking": "In progress", + "sessionWaiting": "Waiting for you", "composerContextLabel": "Message context", "contextWindow": { "tooltip": "Context {{used}} / {{max}} · {{percent}}% (last model request)", "breakdownTitle": "Context Usage", "breakdownPercent": "{{percent}}% Full", "breakdownHint": "Shows current context-window occupancy, not this turn’s billed total.", + "estimateHint": "The provider did not return usage; figures below are estimates.", "segments": { "system_prompt": "System prompt", "tool_definitions": "Tool definitions", @@ -1519,6 +1651,7 @@ "dockFileList": "File changes", "dockAddTab": "Add panel", "expertSharedBadge": "Shared", + "expertRemoteBadge": "Peer", "dockFileListEmpty": "No files from tools yet", "dockFileListCount": "{{count}} files", "dockFileListHint": "This list only shows files produced during execution. They are not guaranteed to be kept, and the model may delete them after processing finishes.", @@ -1558,6 +1691,7 @@ "trajectoryCollapsedToolCallMany": "{{count}} tool calls · {{names}}", "trajectoryCollapsedTurn": "{{steps}} steps · {{toolCalls}} tool calls", "trajectoryInspectorKind": "Kind", + "trajectoryInspectorModel": "Model", "trajectoryInspectorSource": "Source", "trajectoryInspectorStatus": "Status", "trajectoryInspectorError": "Error", @@ -1585,7 +1719,9 @@ "loadEarlierMessages": "Load earlier messages", "historyLoadFailed": "History could not be loaded. Existing messages have been kept. Please retry.", "historyRetry": "Retry", - "inputLabel": "Message input" + "creatingSession": "Creating session…", + "resizePopup": "Resize window", + "unreadMessages": "Unread messages" }, "tools": { "unknown": "Unknown tool", @@ -1635,7 +1771,8 @@ "ask_user_question": "Ask the user", "generate_image": "Generate image", "generate_video": "Generate video", - "bilibili_search_anime": "Search Bilibili anime" + "bilibili_search_anime": "Search Bilibili anime", + "search_arxiv": "Search arXiv papers" }, "toolSettings": { "title": "Tool settings", @@ -1655,7 +1792,7 @@ "pluginToolCount": "{{count}} tools", "tabs": { "builtin": "Built-in tools", - "acp": "ACP tools", + "acp": "ACP Collaboration tools", "plugin": "Plugin tools" }, "categories": { @@ -2045,7 +2182,11 @@ "tencentcloud-infra": "Tencent Cloud Infra", "publisher-multi-platform": "Multi-platform Publisher", "docker-management": "Docker Management", - "zipDropZoneLabel": "Select or drop a skill ZIP file" + "copyDenied": "The package creator has disabled copying", + "table": { + "actions": "Actions", + "enabled": "Enabled" + } }, "cronJobs": { "title": "Cron Jobs", @@ -2188,7 +2329,11 @@ "timeoutSeconds": "Timeout (seconds)", "timeoutSecondsTooltip": "Maximum execution time in seconds. The job will be terminated if it exceeds this limit.", "misfireGrace": "Misfire Grace (seconds)", - "misfireGraceTooltip": "If the job was missed (e.g. server was down), it will still run if within this grace period. Set to 0 to skip missed runs." + "misfireGraceTooltip": "If the job was missed (e.g. server was down), it will still run if within this grace period. Set to 0 to skip missed runs.", + "agentlyNewMail": "Agent Mail new mail", + "agentlyMailbox": "Mailbox to watch", + "agentlySelectMailbox": "Select an Agent Mail mailbox", + "agentlyHint": "Run this task when the background connection receives new mail. Authorize the selected mailbox first; the model is not called while idle." }, "preset": { "every5min": "Every 5 minutes", @@ -2205,6 +2350,7 @@ "col": { "id": "Job ID", "name": "Name", + "enabled": "Enabled", "scheduleType": "Schedule Type", "scheduleCron": "Schedule Cron", "scheduleTimezone": "Timezone", @@ -2232,7 +2378,8 @@ "runtimeTimeoutSeconds": "Timeout (s)", "runtimeMisfireGrace": "Misfire Grace (s)" }, - "noAgentSelected": "Pick an expert 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", + "goToChat": "Send to chat" }, "channels": { "title": "Channels", @@ -2289,7 +2436,7 @@ "intro_dingtalk": "Connect DingTalk for 1:1 and group chats — built for workplace collaboration.", "intro_discord": "Connect a Discord bot for server channels and DMs with your agent.", "intro_wecom": "Connect WeCom for app messages, private chats, and group conversations.", - "intro_weixin": "Connect personal WeChat via iLink Bot to receive and reply in private and group chats.", + "intro_weixin": "Connect personal WeChat so users can interact with your assistant via private chat in real time.", "intro_dashboard": "Built-in dashboard channel for local debugging — zero third-party setup.", "intro_yuanbao": "Connect Tencent YuanBao so agent capabilities show up in YuanBao sessions.", "intro_octopbot": "Connect the OctopBot platform to Tencent Cloud-hosted bots and your agent.", @@ -2425,6 +2572,7 @@ "dmPolicy": "DM Policy", "dmPolicyOpen": "Open — allow everyone", "dmPolicyAllowlist": "Allowlist — specified users only", + "dmPolicyDisabled": "Disable DMs", "allowFrom": "DM Allowlist", "allowFromTip": "Only effective when DM Policy is set to Allowlist", "allowFromPlaceholder": "Enter user ID and press Enter", @@ -2453,7 +2601,7 @@ "fieldMustBeJsonObject": "{{label}} must be a JSON object", "jsonMustBeObject": "Must be a JSON object", "invalidJson": "Invalid JSON", - "rawConfigTooltip": "Channel-specific config — see harness-gateway docs", + "rawConfigTooltip": "Channel-specific config — see octop-gateway docs", "getCredentials": "Get credentials", "channelSettingsNamed": "{{kind}} channel settings", "deleteConfirmTitle": "Delete channel \"{{name}}\"?", @@ -2646,6 +2794,12 @@ "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.", + "realtimeStt": "Realtime speech recognition", + "realtimeSttHint": "When enabled, voice input in the composer streams text as you speak (AppID required).", + "realtimeAppIdRequired": "AppID is required to enable realtime speech recognition.", + "realtimeFailed": "Realtime speech recognition failed", + "holdToTalk": "Hold to talk", + "releaseToStop": "Release to finish", "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.", "mimoEndpoint": "API Endpoint", @@ -3050,15 +3204,34 @@ "localDownloadCancelled": "Download cancelled", "localCancelDownloadFailed": "Failed to cancel download", "ollamaLocalModels": "Downloaded Local Models", + "ollamaModelsDir": "Model download directory", + "ollamaModelsDirPlaceholder": "e.g. D:\\Ollama\\models; leave empty for Ollama's default", + "ollamaModelsDirHint": "Set this when Ollama stores models outside the default path (for example off the system drive) so downloaded models are recognized. If the local service is on, Ollama is restarted with this directory.", + "ollamaModelsDirApply": "Apply", + "ollamaModelsDirSaved": "Model directory updated", + "ollamaModelsDirSaveFailed": "Failed to update the model directory", "ollamaModelNamePlaceholder": "e.g. mistral:7b, qwen3:8b", - "showAdvanced": "Advanced Options", - "hideAdvanced": "Hide Advanced", + "showAdvanced": "Model capabilities", + "hideAdvanced": "Hide capabilities", + "customReasoning": "Custom reasoning protocol", + "hideCustomReasoning": "Hide protocol", + "compatibility": "Compatibility", + "hideCompatibility": "Hide compatibility", + "streamUsage": "Show live token count in chat", + "streamUsageHint": "When on, chat shows live token usage. Some private gateways reject this and fail the chat. Custom providers default off; bundled presets default on.", + "testStreamHint": "Connection test is not a streaming chat. If chat fails, turn off “Show live token count in chat”.", + "modelPurpose": "Use", + "purposeChat": "Chat model", + "purposeEmbedding": "Embedding model", + "modelPurposeHint": "Embedding models are only used for knowledge-base retrieval and stay out of chat / Auto.", "contextWindow": "Context Window (tokens)", - "contextWindowPlaceholder": "e.g. 128000", + "contextWindowPlaceholder": "e.g. 128000 (128K)", + "contextWindowHint": "Used for the context-usage hint. An inexact value does not block chat.", "maxTokens": "Max Output Tokens", - "maxTokensPlaceholder": "e.g. 16000", + "maxTokensPlaceholder": "e.g. 16000 (16K)", "reasoning": "Reasoning Support", - "reasoningToggle": "Allow toggle", + "reasoningToggle": "Allow turning thinking off in chat", + "reasoningToggleHint": "When off, chat cannot disable reasoning and always uses Default thinking.", "reasoningDefault": "Default thinking", "reasoningEfforts": "Supported thinking efforts", "reasoningDefaultEffort": "Default effort", @@ -3228,10 +3401,101 @@ "onnxNotReady": "not ready", "onnxCacheDir": "Cache: {{dir}}", "onnxLocalCached": "Cached models", - "onnxQuickDownload": "Quick download from catalog…" + "onnxQuickDownload": "Quick download from catalog…", + "defaultModelExtra": "Model used by Test connection; leave empty to use the first enabled model" }, "advancedSettings": { "description": "Manage runtime configuration and environment variables.", + "bridge": { + "title": "Cloud collaboration", + "description": "Link this Octop to another instance. Browse peer experts and chat across instances; conversation history stays on the expert’s side.", + "add": "Add connection", + "addDisabled": "Limit reached ({{limit}}). Delete a connection to add another.", + "limitHint": "At most {{limit}} connections per user. The same peer URL + username can be added only once, and you cannot link this instance to itself.", + "addConfirm": "Save & connect", + "created": "Saved and connected to the peer", + "createFailed": "Could not save and connect", + "loadFailed": "Could not load cloud collab connections", + "connect": "Reconnect", + "disconnect": "Disconnect", + "disconnectTitle": "Disconnect this cloud collab?", + "disconnectDesc": "This disconnects “{{name}}”. You can reconnect from this side anytime.", + "inboundTag": "Peer-initiated", + "inboundHint": "Opened by the peer. You can only change the display name, icon, and notes. Chat history stays on the expert’s side.", + "inboundOfflineHint": "The peer is offline. This side cannot redial. You can delete this card; it will reappear if the peer opens the link again.", + "inboundActionDisabled": "Peer-initiated links are kept by the peer. While connected, this side cannot disconnect, delete, or auto-reconnect.", + "editHintInbound": "Peer-initiated links can only change display name, icon, and notes. Address and account stay on the peer.", + "delete": "Delete connection", + "deleted": "Cloud collab connection deleted", + "deleteFailed": "Could not delete the cloud collab connection", + "deleteConfirmTitle": "Delete this cloud collab connection?", + "deleteConfirmDesc": "This disconnects “{{name}}” and removes stored peer credentials. This cannot be undone.", + "deleteConfirmDescInboundOffline": "“{{name}}” is offline. Deleting hides this card here; it will come back if the peer reopens the link.", + "editTitle": "Edit connection", + "editHint": "Update display info and the peer account. Changing the address, username, or password re-authenticates; a previously connected link reconnects automatically. Leave the password blank to keep the current one.", + "editSaved": "Connection updated", + "editFailed": "Could not update the connection", + "passwordKeepHint": "Leave blank to keep the current password", + "passwordKeepPlaceholder": "••••••••", + "emptyTitle": "No cloud collaborations yet", + "emptyDesc": "Add a peer Octop address with username and password to start.", + "drawerHint": "The display name is used for chat group switching and must be unique. Probe first to verify the account; Save & connect only persists after login and the cloud-collab handshake succeed — a failure will not keep the name.", + "directionHint": "Initiate from the side that can reach the other. Typically a LAN or laptop connects out to a public/cloud Octop. Probe only checks the account; the handshake must also succeed before the row is saved.", + "sectionIdentity": "Display", + "sectionRemote": "Peer account", + "probe": "Probe", + "probeOk": "Reachable — {{count}} expert(s)/team(s) found", + "probeFailed": "Could not probe the peer Octop", + "probeResultTitle": "{{name}} · {{count}} expert(s)/team(s)", + "noAgents": "No peer experts or teams visible.", + "showMoreExperts": "Show {{count}} more", + "showLessExperts": "Show less", + "kindExpert": "Expert", + "kindTeam": "Team", + "displayName": "Display name", + "displayNamePlaceholder": "e.g. Company cloud", + "displayNameRequired": "Enter a display name", + "displayNameHint": "Shown when switching peer groups in chat; must be unique", + "icon": "Icon", + "iconHint": "Shown on peer expert cards (icon + display name)", + "iconLabels": { + "cloudy": "Cloud", + "cloud": "Cloud outline", + "cloud-cog": "Cloud service", + "globe": "Public network", + "home": "On this machine", + "laptop": "Workstation", + "monitor": "Desktop", + "server": "On-prem", + "radio-tower": "Edge node", + "wifi": "Near-edge network", + "satellite": "Peer access", + "map-pin": "On-site" + }, + "notes": "Notes", + "notesPlaceholder": "Optional private notes", + "peerUrl": "Peer address", + "peerUrlPlaceholder": "https://octop.example.com", + "peerUrlRequired": "Enter the peer Octop address", + "username": "Username", + "usernameRequired": "Enter the peer username", + "password": "Password", + "passwordRequired": "Enter the peer password", + "status": { + "connected": "Connected", + "disconnected": "Disconnected", + "offline": "Offline", + "connecting": "Connecting", + "error": "Error" + }, + "autoReconnect": "Auto-reconnect", + "autoReconnectHint": "Redial after disconnect; turns off after several failures and records the error", + "viewCard": "Cards", + "viewTable": "Table", + "totalConnections": "{{count}} connection(s)", + "colStatus": "Status", + "colAgents": "Experts" + }, "captcha": { "title": "Login captcha", "description": "Optional server-side captcha for password login. The slider is client-only; strong providers are verified with the vendor.", @@ -3318,6 +3582,7 @@ "stageStarting": "Starting...", "stageDownloading": "Downloading...", "stageInstalling": "Installing...", + "mirrorErrorsTitle": "Per-mirror details", "manualUpgradeHint": "You can also run: octop update --verbose", "restartServiceBtn": "Restart Service", "restarting": "Restarting...", @@ -3444,7 +3709,20 @@ "quietHoursEnd": "Do Not Disturb Until", "saveSuccess": "Proactive reminder settings saved", "saveFailed": "Failed to save proactive reminder settings", - "loadFailed": "Failed to load proactive reminder settings" + "loadFailed": "Failed to load proactive reminder settings", + "activeHoursEnd": "Care hours · end", + "activeHoursEndRequired": "Select an end time", + "activeHoursStart": "Care hours · start", + "activeHoursStartRequired": "Select a start time", + "episodeBannerText": "Octop picks a good moment from your episodes to send a caring message. See which entries are referenced in \"Episodes\".", + "intervalOrderError": "Minimum interval cannot exceed maximum interval", + "maxIntervalHours": "Max interval (hours)", + "maxIntervalRequired": "Enter the maximum interval", + "minIntervalHours": "Min interval (hours)", + "minIntervalMin": "Minimum interval is 1 hour", + "minIntervalRequired": "Enter the minimum interval", + "timeRangeError": "Care hours start must be earlier than end", + "viewEpisodes": "Open Episodes →" }, "chatWelcome": { "greeting": "Hi! Your personal AI sidekick is here", @@ -3502,7 +3780,12 @@ "quickPrompt12Text": "Please improve the following resume to make it more professional and impactful, and highlight the key strengths:\n\n" }, "upload": { - "fileTooltip": "Upload attachment", + "tooltip": "Upload files or a folder. You can also drop them on the input.", + "files": "Files", + "folder": "Folder", + "dropHint": "Drop files or a folder to upload", + "emptyDrop": "No files to upload", + "tooMany": "Only the first {{max}} files can be uploaded at once", "unsupportedType": "Unsupported file type: {{type}}", "tooLarge": "File too large (max {{maxMb}}MB): {{name}}", "failed": "Upload failed" @@ -3521,14 +3804,15 @@ "filesFailed": "Could not search workspace files" }, "modelConfig": { - "promptTitle": "LLM Model Required", - "promptMessage": "Chat requires an LLM model to function. Without a configured model, conversations and messages cannot be sent to the backend for processing and storage, and will be lost after a page refresh. Would you like to configure a model now?", - "configureButton": "Configure Model", + "promptTitle": "A model is required", + "promptMessage": "Open Admin → Model Management, add a provider, and enable at least one model before you can chat.", + "promptMessageNoPermission": "No models are available yet. Ask an administrator to configure one before you can chat.", + "configureButton": "Configure models", "skipButton": "Skip", "chatDisabledTitle": "Chat Disabled", "chatDisabledMessage": "Chat functionality is disabled because no model is configured. Please configure a model to enable chat.", "configureNow": "Configure Model", - "modelNotConfigured": "Please configure a model in Settings before using chat" + "modelNotConfigured": "Please configure a model in Admin → Model Management before using chat" }, "header": { "healthLabel": "Health", @@ -3559,7 +3843,8 @@ }, "language": { "title": "Language", - "description": "Choose the display language for the console." + "description": "Choose the display language for the console.", + "saveFailed": "Failed to save language preference" }, "theme": { "title": "Appearance Theme", @@ -3608,6 +3893,8 @@ "role": "Role", "roleAdmin": "Administrator", "roleUser": "User", + "avatar": "Avatar", + "avatarHint": "Pick a default icon, or upload your own image.", "displayName": "Display name", "displayNamePlaceholder": "Your display name", "displayNameHint": "The name shown across the console.", @@ -3783,7 +4070,8 @@ "deleteModelCacheTitle": "Delete model cache?", "deleteModelCacheDesc": "Delete the local cache for {{model}}? You'll need to re-download it before vector search can be re-enabled.", "deleteModelCacheSuccess": "Model cache deleted", - "deleteModelCacheFailed": "Delete failed: the model may be activating. Switch, save, or restart before deleting." + "deleteModelCacheFailed": "Delete failed: the model may be activating. Switch, save, or restart before deleting.", + "selectModel": "Select a model" }, "pipeline": { "title": "Memory pipeline", @@ -3799,11 +4087,16 @@ "emptyTitle": "Memories are still being distilled", "emptyNoRaw": "No memories yet. Start chatting and the system will capture material and distill memories automatically.", "emptyWithRaw": "{{n}} pieces of conversation material captured. Distillation runs automatically once a session goes idle; the first memories usually appear after a few exchanges.", - "emptyPendingSuffix": "{{n}} memory candidates are awaiting confirmation in Pending Review." + "emptyPendingSuffix": "{{n}} memory candidates are awaiting confirmation in Pending Review.", + "lastExtract": "Last organized {{time}}" }, "library": { "viewRaw": "Raw material", - "hintRaw": "Raw conversation material captured before distillation (counts don't map 1:1 to Conversations)" + "hintRaw": "Raw conversation material captured before distillation (counts don't map 1:1 to Conversations)", + "hintAtoms": "All memories in a flat list, filterable by importance", + "hintTree": "Browse related memories grouped by people, projects, tools and more", + "viewAtoms": "List view", + "viewTree": "Topic view" }, "raw": { "typeFilter": "Type", @@ -3851,7 +4144,16 @@ "reloadHint": "Saving reloads the agent automatically. Current conversations are retained.", "loadFailed": "Failed to load memory settings", "saved": "Saved — the agent will reload automatically", - "saveFailed": "Save failed" + "saveFailed": "Save failed", + "advancedTitle": "Distillation call limits", + "advancedDescription": "Cap how much a single distillation run may generate and how long it may wait; 0 means automatic. Independent of the trigger timing above.", + "maxTokensLabel": "Max tokens per distillation", + "maxTokensSuffix": "tokens (0 = automatic)", + "lightTimeoutLabel": "Distillation wait limit", + "lightTimeoutSuffix": "seconds (0 = automatic, default 120s; min 30s)", + "heavyTimeoutLabel": "Batch consolidation wait limit", + "heavyTimeoutSuffix": "seconds (0 = automatic, default 300s)", + "timeoutVsIdleNote": "“Idle for X minutes” only decides when distillation starts; this wait limit decides how long a single run may take. For local reasoning-heavy models, set both the token budget and the wait limit so distillation stops timing out." }, "overview": { "dashboardTitle": "Memory overview", @@ -3863,13 +4165,37 @@ "episodes": "Emotional moments", "rawEvents": "Conversation memory", "candidatesPending": "Needs attention", + "subtitleRawEventsUnit": "raw events", + "dirtyPages": "Topics to refresh", + "dirtyPagesHint": "Some topic summaries need to be regenerated", + "dirtyPagesClean": "All topic summaries are up to date", + "processingStatus": "Processing", + "statusEmpty": "No status data yet", + "pendingReview": "Needs review", + "pendingReviewHint": "Memory drafts are waiting for you to adopt or discard", + "pendingReviewClean": "No memory drafts waiting for review", + "weeklyNewAtoms": "Added in 7 days", + "weeklyNewAtomsHint": "How quickly new memories have been settling", + "indexedEntities": "Indexed topics", + "indexedEntitiesHint": "Expand these topics in the memory library", + "profileEyebrow": "User profile", "kindsTitle": "Memory types", "kindsEmpty": "No memory type data yet", "turnsTitle": "Conversation turns · 7 days", "turnsEmpty": "No conversation turns in the last 7 days", "turns": "Turns", "growthTitle": "Memory growth · 7 days", - "growthEmpty": "No new memories in the last 7 days" + "growthEmpty": "No new memories in the last 7 days", + "atomsDelta7dShort": "added in 7 days", + "entitiesShort": "people & things", + "episodesShort": "conversation episodes", + "heroTitle": "User profile", + "memories": "memories", + "profileSubtitle": "A structured summary of what Octop currently understands about you — high-value conclusions only, not the full memory store.", + "reviewAction": "Tidy up profile", + "rowGoLibrary": "Open memory library →", + "rowHintToLibrary": "Wrong? Discard it in the memory library", + "viewAll": "View all" }, "entitySummary": "Topic summary", "tree": { @@ -3929,11 +4255,133 @@ "ok": "Deprecate", "success": "Deprecated — Octop will no longer use this memory", "failed": "Operation failed: {{message}}" + }, + "atomDetail": "Memory detail", + "episodeDetail": "Episode detail", + "candidates": { + "confirmPromote": "Adopt this memory?", + "confirmReject": "Confirm discard", + "detail": "Memory draft detail", + "empty": "No memory content yet", + "kindLabel": "Type", + "promote": "Adopt", + "promoteOk": "Adopted", + "reject": "Discard", + "rejectHint": "Discarded drafts never enter long-term memory. You can optionally give a reason for later review.", + "rejectOk": "Discarded", + "rejectReasonPlaceholder": "Optional reason, e.g. expired / inaccurate / not important", + "rejectTitle": "Discard this draft", + "statusLabel": "Status" + }, + "journal": { + "action": { + "create": "Create", + "update": "Update", + "promote": "Adopt", + "deprecate": "Deprecate", + "reject": "Discard", + "merge": "Merge" + } + }, + "confidence": { + "high": "High confidence", + "low": "Low confidence", + "medium": "Medium confidence" + }, + "entityType": { + "concept": "Concept", + "event": "Event", + "organization": "Organization", + "other": "Other", + "person": "Person", + "place": "Place", + "project": "Project", + "tool": "Tool" + }, + "episodes": { + "disclaimer": "This diary keeps the emotions and little stories from your time with Octop. It is not quoted in regular chats — it only helps Octop care for you better and feeds the weekly / monthly recaps." + }, + "importance": { + "high": "Very important", + "low": "Normal", + "medium": "Important", + "title": "Importance" + }, + "kind": { + "conflict": "Possible conflict", + "decision": "Decision", + "fact": "Fact", + "preference": "Preference", + "task": "Task" + }, + "list": { + "importanceMin": "Importance at least", + "kind": "Type" + }, + "migrate": { + "alreadyAdopted": "Already migrated, skipped", + "alreadyAdoptedAt": "This package was already imported at {{at}}", + "buttonLabel": "Migrate memory", + "chooseFile": "Choose file", + "comparePkgLabel": "Optional: upload a .hmpkg file to compare row counts", + "confirmDesc": "Once everything looks right, click \"Confirm & import\" to apply.", + "confirmImport": "Confirm & import", + "conflictLabel": "Conflict policy", + "conflictReplace": "Replace (overwrite with new data)", + "conflictSkip": "Skip (keep existing)", + "doctorAllPassed": "All checks passed ✓", + "doctorDesc": "Run a health check on this Octop memory store: schema, indexes, foreign-key integrity and more.", + "doctorFailed": "Some checks failed — see details", + "doctorLabel": "Validate migration", + "doctorTitle": "Validate migration health", + "dryRunResult": "Dry-run result", + "exportBtn": "Pack & download", + "exportDesc": "Pack all memories of this Octop into a .hmpkg file and download it. You can import the file on another host (openclaw / hermes) to keep using them.", + "exportLabel": "Export to file", + "exportSuccess": "Memory package downloaded", + "exportTitle": "Export memory to file", + "hostRewriteKeep": "Keep original host", + "hostRewriteLabel": "Host field", + "hostRewriteTarget": "Rewrite to target host", + "importBtn": "Import directly", + "importLabel": "Import from file", + "importStats": "Wrote {{n}}, skipped {{s}}", + "importSuccess": "Import finished — wrote {{n}} entries", + "importTitle": "Import memory from file", + "previewBtn": "Dry run", + "rowCountDiff": "Row count difference: ", + "runDoctor": "Run checks", + "selectFile": "Select a .hmpkg file first", + "selectFileLabel": "Select a .hmpkg file", + "targetHostLabel": "Target host", + "targetNs": "Target namespace: {{ns}}", + "willApply": "Will write: {{n}} entries", + "importDone": "Import finished" + }, + "terminal": { + "aboutMe": "About you", + "currentFocus": "Current focus", + "emptyAboutMe": "Octop is still getting to know you — chat a bit more and it will fill in", + "emptyEntities": "No frequent people or things yet", + "emptyFocus": "No tasks in progress", + "emptyToldMe": "No explicit facts noted yet", + "atomCount": "{{n}} memories", + "entities": "Key people & things", + "entitiesDesc": "The most frequent topics in your memory", + "toldMe": "Facts you mentioned", + "toldMeDesc": "Key details recently noted by Octop", + "aboutMeDesc": "Stable preferences and facts", + "currentFocusDesc": "Tasks in progress and recent decisions" + }, + "time": { + "daysAgo": "{{n}} days ago", + "today": "Today", + "yesterday": "Yesterday" } }, "personalization": { "title": "Personalization", - "description": "Configure this agent's skills, tools, plugins, subagents, channels, personality, and memory.", + "description": "Configure this expert's skills, tools, plugins, subagents, channels, personality, and memory", "tabs": { "skills": "Skills", "tools": "Tools", @@ -4026,13 +4474,23 @@ "skillFiles": "Skills", "sceneIntro": "Scene Introduction", "clickToPreview": "Click to preview", + "sceneSelectTab": "Choose a scene", + "currentSceneTab": "Current scene", + "moreScenesComing": "More scenes on the way", + "moreScenesSub": "We will keep adding packs — stay tuned", + "noSceneApplied": "No scene applied yet", + "noSceneAppliedHint": "Pick a scene under “Choose a scene” to get started", + "yourConfiguration": "Configuration files", + "drawerConfigFiles": "Configuration files", + "drawerSkillFiles": "Included skills", + "drawerApply": "Apply this scene", "moreScenes": "More Scenes", "readOnlyHint": "Read-only — This is preset content. After applying, you can edit it in \"Current Scene\".", "fileCount": "{{count}} files" }, "workbench": { "title": "Workbench", - "description": "Terminal and browser surfaces for live interaction with your agents", + "description": "View and take over the expert's terminal and browser, and follow execution in real time", "tabs": { "terminal": "Terminal", "browser": "Browser" @@ -4040,7 +4498,7 @@ }, "remoteDesktopHub": { "title": "Remote Desktop", - "description": "Control the host desktop or a connected phone from the browser", + "description": "Control the host desktop or a connected phone from the browser, and work remotely", "tabs": { "desktop": "Server", "phone": "Phone" @@ -4162,6 +4620,8 @@ "uninstallSuccess": "Built-in browser removed", "uninstallFailed": "Uninstall failed", "playwrightMissing": "The playwright package is missing. Install octop[browser] extras first.", + "playwrightOptional": "The playwright package is missing (optional)", + "playwrightOptionalDesc": "octop-browser (CDP) is ready; browser features work. Install octop[browser] extras only if you need Playwright fallback.", "installLog": "Install log", "installRetry": "Retry install", "envReady": "Octop can open pages, fill forms, and take screenshots for you.", @@ -4220,7 +4680,23 @@ "recordingPlaceholder": "Recording... type \"end\" to stop", "confirmPlaceholder": "Type \"confirm\" to apply", "placeholderWithSkills": "Type skill to replay, or chat", - "inputPlaceholder": "Ask about page or control browser..." + "inputPlaceholder": "Ask about page or control browser...", + "objectiveSet": "✅ Task objective set: **{{objective}}**\n\nNext, describe what you want the AI assistant to do in the browser.\n\nExample: \"Open Bilibili, Xiaohongshu and Baidu and search for today's weather\".\n\nOnce described, the AI will perform the actions; when finished, type \"end\" to stop recording.", + "previewMessage": "✅ Recording finished! {{steps}} replay steps generated.\n\n📝 **Skill \"{{name}}\" script preview:**\n\n{{preview}}{{truncation}}\n\nReply \"confirm\" to apply this skill.\n\nOnce applied, just type **\"{{name}}\"** to replay the same workflow in one step.", + "previewNoScript": "✅ Recording finished! {{steps}} replay steps generated.\n\n⚠️ Failed to generate the skill script. Please try again later.", + "skillAppliedMessage": "🎉 Skill **\"{{name}}\"** applied successfully!\n\nFrom now on, just type **\"{{name}}\"** to replay the same browser workflow in one step.", + "noRecordingFound": "⚠️ No recording found for skill \"{{name}}\"; replay failed.", + "recordingNotFound": "Recording not found", + "replayDone": "🎬 Browser-Skill \"{{name}}\" replay finished!", + "stepLine": "Step {{id}} ({{kind}}): {{error}}", + "stepFailed": "failed", + "replayFailedDetail": "⚠️ Skill \"{{name}}\" replay failed:\n{{detail}}", + "replayError": "⚠️ Skill replay error: {{error}}", + "unknownError": "Unknown error", + "replayComplete": "🔄 Skill replay finished! All steps completed successfully.", + "replayFailedSimple": "⚠️ Skill replay failed: {{error}}", + "replayingSkill": "🔄 Replaying with skill \"{{text}}\"...", + "recordingHint": "💡 Recording is still in progress. If you have finished, type \"end\" to stop and generate the skill script." } }, "remoteDesktop": { @@ -4311,7 +4787,8 @@ "mobileHint": "Tap the view to control the remote desktop; shortcut buttons send common key chords.", "adminOnlyTitle": "Admin access required", "adminOnlyDesc": "Remote desktop controls the host OS and is available to administrators only. Ask an admin if you need access.", - "adminOnlyBack": "Back to chat" + "adminOnlyBack": "Back to chat", + "streamError": "Remote desktop connection failed" }, "browserWorkspace": { "title": "Browser", @@ -4417,7 +4894,8 @@ "vectorIndexWarning": "If you change the embedding model, switch between local and external providers, or edit vector dimensions, a mismatch with the existing memory index will cause the Qdrant collection to be recreated — stored vectors are cleared and the index is rebuilt from your memory files.", "loadError": "Failed to load configuration", "applySuccess": "Configuration applied", - "saveAndApply": "Save & Apply" + "saveAndApply": "Save & Apply", + "selectModel": "Select a model" }, "cos": { "tooltip": "Cloud Storage", @@ -4462,7 +4940,8 @@ "database": "Database", "password": "Password", "admin": "Admin", - "model": "Models" + "model": "Models", + "finish": "Finish" }, "database": { "hint": "Choose where Octop stores control-plane data (users, agents, channels). The default SQLite file works for most installs.", @@ -4554,7 +5033,7 @@ }, "storage": { "pageTitle": "Storage Management", - "pageSubtitle": "Manage storage backends (local, object storage, sandboxes, databases) for use by experts", + "pageSubtitle": "Manage local, object, sandbox, and database storage backends for experts", "myStorage": "My Storage", "supportedTypes": "Supported Types", "addBackend": "Add Storage Backend", @@ -4698,13 +5177,32 @@ "probing": "Probing…", "testSuccess": "Storage backend is reachable", "probe_roundtrip_ok": "Write, read, and delete succeeded", + "probe_no_such_bucket": "The specified bucket does not exist. Check the bucket name.", + "probe_no_such_key": "The object was not found.", + "probe_access_denied": "Access denied. Check the access key and bucket permissions.", + "probe_invalid_credentials": "Invalid credentials. Check the access key and secret key.", + "probe_invalid_bucket": "The bucket name is invalid.", + "probe_invalid_endpoint": "Endpoint or region does not match this bucket.", + "probe_timeout": "The storage request timed out. Check the endpoint and network.", + "probe_connection_failed": "Could not connect to the storage service. Check the endpoint and network.", + "probe_database_missing": "The specified database does not exist.", + "probe_content_mismatch": "Read-back content did not match what was written.", + "probe_write_failed": "Could not write a test file: {{detail}}", + "probe_read_failed": "Could not read the test file back: {{detail}}", + "probe_delete_failed": "Could not delete the test file: {{detail}}", + "probe_config_incomplete": "Configuration is incomplete.", + "probe_s3_incompatible": "This S3-compatible store rejected the request. Check the endpoint, or try path-style addressing in advanced settings.", + "probe_signature_mismatch": "The store rejected the request signature. Check the endpoint, or try path-style addressing in advanced settings.", "testFailed": "Connection test failed", "browse": "Browse", "browseDisabled": "Browse unavailable for this Docker sandbox (only fixed scope enables it by default; use Probe to verify connectivity)", "browseTitle": "Browse — {{name}}", "browseEmpty": "Directory is empty or cannot be listed", "browseFailed": "Browse failed", - "browseLoadFailed": "Failed to load subdirectory" + "browseLoadFailed": "Failed to load subdirectory", + "browseDownloadOnly": "This file cannot be previewed. Download it instead?", + "browse_not_text": "This file is not valid UTF-8 text. Download it instead.", + "browse_too_large": "File is too large to preview or download in the browser." }, "adminAgents": { "total": "{{count}} agents", @@ -4750,13 +5248,22 @@ } }, "agentSelector": { - "label": "Active Expert" + "label": "Expert", + "localGroup": "This instance", + "disconnected": "Disconnected", + "expertsGroup": "Experts", + "teamsGroup": "Teams", + "remoteKindGroup": "{{name}}·{{kind}}", + "more": "More", + "teamNeedsExpert": "\"{{name}}\" is a team. Choose an expert for this page.", + "expertsRequired": "This page needs an expert" }, "login": { "title": "Sign in", "subtitle": "An intelligent partner that understands you, helps you, and grows with you", "username": "Username or email", "password": "Password", + "remember": "Remember me", "submit": "Sign in", "forgotPassword": "Forgot password?", "forgotPasswordTitle": "Reset password", @@ -4780,6 +5287,7 @@ "or": "or", "oidcWith": "Continue with {{name}}", "oidcStartFailed": "Could not start single sign-on", + "ldapHint": "Sign in with your {{name}} account", "oidcError": { "denied": "Single sign-on was cancelled.", "state": "Your single sign-on session expired. Please try again.", @@ -4863,7 +5371,7 @@ "expired": "Expired", "revoked": "Revoked" }, - "searchPlaceholder": "Search username, display name, or email", + "searchPlaceholder": "Search username, display name, email, or role", "loadFailed": "Load failed", "createSuccess": "User \"{{username}}\" created", "createFailed": "Create failed", @@ -4890,7 +5398,8 @@ "colEmail": "Email", "colAuth": "Sign-in methods", "colCreatedAt": "Created", - "colRole": "Role", + "colRole": "Account type", + "colRoleName": "Role", "colLoginLock": "Login lock", "loginLockActive": "Locked · {{minutes}} min left", "loginLockNone": "—", @@ -4899,18 +5408,53 @@ "noAgents": "This user has no experts yet", "colActions": "Actions", "roleAdmin": "Admin", - "roleUser": "User", + "roleUser": "Member", "roleAdminHint": "Manage system settings, users, and all data", "roleUserHint": "Use their own experts and workspace; no admin console", - "roleLegendTitle": "Roles", - "roleLegend": "Admins can manage system settings, users, and all data. Regular users only use their own experts and workspace, and cannot manage other users or change global settings.", + "roleLegendTitle": "Account type", + "roleLegend": "Admins can manage system settings, users, and all data. Members only use their own experts and workspace, and cannot manage other users or change global settings. Custom roles show the template name in the account-type column.", "formUsername": "Username", "formPassword": "Password", "formPasswordConfirm": "Confirm password", "formDisplayName": "Display name (optional)", "formEmail": "Email (optional)", "formEmailInvalid": "Enter a valid email address", - "formRole": "Role", + "formRole": "Account type", + "formUserRole": "Role", + "formUserRoleHint": "Optional. A role fills permissions and resource policy only. Set the account type separately. Leaving the role empty keeps the current permissions and policy.", + "avatarSamples": "Sample icons", + "avatarDefault": "Default icon", + "avatarIconUser": "Member", + "avatarIconAdmin": "Admin", + "avatarIconStaff": "User", + "avatarIconDoctor": "Doctor", + "avatarIconNurse": "Nurse", + "avatarIconStudent": "Student", + "avatarIconTeacher": "Teacher", + "avatarIconSupport": "Support", + "avatarIconOps": "Operations", + "avatarIconFinance": "Finance", + "avatarIconBoss": "Executive", + "avatarIconMember": "Member", + "avatarIconAward": "Crown", + "avatarIconTerminal": "Code", + "avatarIconPen": "Quill", + "avatarIconChart": "Pie", + "avatarIconHeadset": "Megaphone", + "avatarIconWrench": "Gear", + "avatarIconScale": "Institution", + "noEmail": "No email", + "formUserRolePlaceholder": "No role", + "formUserRoleStale": "Recorded role name: {{name}}. That template was deleted or renamed. Current permissions stay until you pick another role.", + "formRoleDiverged": "This account's permissions or resource policy differ from the role defaults. Choosing another role overwrites those fields in the form.", + "seedRoleAdmin": "Administrator", + "seedRoleUser": "User", + "seedRoleAdminDescription": "Full access with no resource-policy limits.", + "seedRoleUserDescription": "For regular users with the baseline settings permissions.", + "roleNameChanged": "Customized", + "roleNameMissing": "Role unavailable", + "roleApplyConfirm": "Overwrite the current settings with this role?", + "roleApplyConfirmHint": "Permissions and resource policy become this role's defaults. The account type stays as it is, and you can still change the filled values before saving.", "viewTable": "Table", "viewCard": "Cards", "you": "You", @@ -4920,11 +5464,44 @@ "statusDisabled": "Disabled", "ssoBadge": "SSO", "passwordBadge": "Password", - "tabLocal": "Local users", + "tabLocal": "User management", + "tabRoles": "Roles", + "roleLoadFailed": "Failed to load roles", + "roleCreate": "New role", + "roleCreateTitle": "New role", + "roleEditTitle": "Edit role {{name}}", + "roleCreateSuccess": "Role “{{name}}” created", + "roleCreateFailed": "Failed to create role", + "roleUpdateSuccess": "Role updated", + "roleUpdateFailed": "Failed to update role", + "roleDeleteSuccess": "Role deleted", + "roleDeleteFailed": "Failed to delete role", + "roleDeleteConfirm": "Delete role “{{name}}”? Existing users keep their permissions, policies, and role name.", + "roleDeleteBuiltin": "Built-in roles cannot be deleted", + "roleEmpty": "No roles yet", + "roleBuiltin": "Built-in", + "roleAdminLocked": "The administrator role has every permission and no resource limits. You can rename it. You cannot delete it or change its permissions or policies.", + "roleTemplateHint": "A role only supplies defaults for create, edit, and invite. Changing a role does not rewrite existing users.", + "roleCustomHint": "A custom role supplies permissions and resource policy. Set the account type on the user.", + "roleColName": "Role name", + "roleColId": "Role ID", + "roleDescription": "Description", + "roleDescriptionPlaceholder": "Describe who this role is intended for", + "roleDescriptionEmpty": "No description", + "roleColAccount": "Default account type", + "roleColPolicy": "Resource policy", + "rolePolicyUnlimited": "Off", + "rolePolicyLimited": "On", + "roleNameRequired": "Enter a role name", + "inviteRole": "Role", + "inviteRoleRequired": "Select a role", + "inviteRoleHint": "Only the role name is stored. Permissions and resource policy are read from that role when the invite is redeemed. If the role was deleted or renamed, the invite cannot be redeemed.", + "inviteRoleLegacy": "No role", "tabFeishu": "Feishu Login", "tabWecom": "WeCom Login", "tabDingtalk": "DingTalk Login", "tabOidc": "Generic OIDC", + "tabLdap": "LDAP", "tabSso": "Generic OIDC", "colPermissions": "Permissions", "permAll": "All (admin)", @@ -4943,9 +5520,46 @@ "policyWorkspaceRootRequired": "Choose an allowed storage root", "policyWorkspaceRootContainerHint": "Not available in container deployments — the container filesystem is already the isolation boundary, and new experts default to `/`.", "policyTokenQuota": "Token usage limit", - "policyTokenQuotaHint": "Lifetime input + output tokens across this user's experts. Off means no limit.", + "policyTokenQuotaHint": "Lifetime input + output tokens across this user's experts. Off means no limit. Typical values are in millions (1M = 1,000,000 tokens).", "policyTokenQuotaRequired": "Enter a token quota", - "modalEditTitle": "Edit user {{username}}" + "policyTokenQuotaPlaceholder": "e.g. 10000000 (about 10 million)", + "policyTokenQuotaPresets": "Quick fill", + "policyTokenQuotaPreset": "{{millions}}M", + "policyTokenQuotaPreview": "About {{millions}} million tokens ({{exact}})", + "policyMaxAgents": "Expert limit", + "policyMaxAgentsHint": "How many experts this user may create (teams excluded). Off means no limit.", + "policyMaxAgentsRequired": "Enter an expert limit", + "policyMaxAgentsPlaceholder": "e.g. 5", + "modalEditTitle": "Edit user {{username}}", + "batchSelected": "{{count}} selected", + "batchSelectAll": "Select all", + "batchSelectUser": "Select user {{username}}", + "batchClear": "Clear selection", + "batchEnable": "Enable", + "batchDisable": "Disable", + "batchSetToken": "Set token limit", + "batchSetTokenTitle": "Set token limit for {{count}} users", + "batchSetTokenHint": "Applies the same token quota to all selected users. Turn the switch off for unlimited.", + "batchSetMaxAgents": "Set expert limit", + "batchSetMaxAgentsTitle": "Set expert limit for {{count}} users", + "batchSetMaxAgentsHint": "Applies the same expert creation limit to all selected users. Turn the switch off for unlimited.", + "batchEnableConfirm": "Enable {{count}} selected users?", + "batchEnableHint": "Enabled users can sign in and use the system again.", + "batchDisableConfirm": "Disable {{count}} selected users?", + "batchDisableHint": "Disabled users cannot sign in. Your own account is skipped automatically.", + "batchDeleteConfirm": "Delete {{count}} selected users?", + "batchDeleteHint": "Your own account is skipped automatically. This cannot be undone.", + "batchSuccess": "{{action}} completed for {{count}} users", + "batchPartial": "{{action}} finished: {{succeeded}} succeeded, {{failed}} failed", + "batchAllFailed": "{{action}} failed for all {{failed}} users", + "batchFailed": "Batch operation failed", + "batchAction": { + "enable": "Enable", + "disable": "Disable", + "delete": "Delete", + "set_token_quota": "Set token limit", + "set_max_agents": "Set expert limit" + } }, "adminSso": { "panelTitle": "Single sign-on", @@ -5066,7 +5680,102 @@ "oauthSectionCredentialsHint": "App ID and App Secret from the provider application registration.", "oauthClientSecretHint": "Required. Leave blank after saving to keep the current secret.", "oauthRedirectDocs": "In Feishu, WeCom, DingTalk, or similar consoles, register this URL as the OAuth redirect / callback URI.", - "oauthTestHint": "Save changes before testing a new App ID or secret." + "oauthTestHint": "Save changes before testing a new App ID or secret.", + "ldapTitle": "LDAP directory", + "ldapDesc": "Sign users in against an LDAP directory such as Active Directory or OpenLDAP. Users type their directory username and password into the existing sign-in form.", + "ldap": { + "kind": "LDAP", + "enableLabel": "Enable LDAP sign-in", + "displayName": "Provider display name", + "displayNamePlaceholder": "Shown to users on the sign-in page", + "displayNameRequired": "Enter a display name", + "sectionServer": "Directory server", + "sectionServerHint": "Where Octop connects to authenticate users.", + "serverUrl": "Server URL", + "serverUrlPlaceholder": "ldap://ldap.example.com:389", + "serverUrlHint": "Use ldaps:// for TLS on port 636, or ldap:// together with StartTLS.", + "serverUrlRequired": "Enter the directory server URL", + "serverUrlInvalid": "Enter a valid ldap:// or ldaps:// URL", + "startTls": "StartTLS", + "startTlsHint": "Upgrade a plain ldap:// connection before binding.", + "verifyTls": "Verify TLS certificate", + "verifyTlsHint": "Turn off only for a self-signed test server.", + "sectionBind": "Service account", + "sectionBindHint": "The account Octop uses to search the directory.", + "bindDn": "Bind DN", + "bindDnPlaceholder": "cn=svc-octop,ou=services,dc=example,dc=com", + "bindDnHint": "Leave empty to search anonymously.", + "bindPassword": "Bind password", + "bindPasswordHint": "Required when a bind DN is set.", + "bindPasswordConfigured": "A bind password is configured. Leave blank to keep it unchanged.", + "bindPasswordConfiguredTag": "Configured", + "bindPasswordPlaceholder": "Leave blank to keep the current password", + "sectionSearch": "User search", + "sectionSearchHint": "How Octop finds the directory account behind the username typed on the sign-in page.", + "userBaseDn": "User base DN", + "userBaseDnPlaceholder": "ou=people,dc=example,dc=com", + "userBaseDnRequired": "Enter the user search base", + "userFilter": "User filter", + "userFilterPlaceholder": "(uid={username})", + "userFilterHint": "The literal {username} placeholder is replaced with the typed username before the search runs.", + "userFilterRequired": "Enter a user filter", + "userFilterPlaceholderRequired": "The filter must contain the literal {username} placeholder", + "sectionAttributes": "Attribute mapping", + "sectionAttributesHint": "Directory attributes mapped onto the Octop account.", + "usernameAttribute": "Username attribute", + "usernameAttributeRequired": "Enter the username attribute", + "emailAttribute": "Email attribute", + "displayNameAttribute": "Display name attribute", + "groupAttribute": "Group attribute", + "sectionAccess": "Access & provisioning", + "sectionAccessHint": "Who becomes an admin, and how new accounts are created.", + "adminGroups": "Admin groups", + "adminGroupsHint": "Members of these groups get the admin role — only when an account is first created. Changing a directory group later never re-templates an existing account. Prefer full DNs: a bare CN also matches a same-named group in any other OU.", + "adminGroupsPlaceholder": "octop-admins, cn=it,ou=groups,dc=example,dc=com", + "autoProvision": "Create accounts on first sign-in", + "autoProvisionHint": "When off, only existing Octop users can sign in through the directory.", + "timeout": "Connection timeout (seconds)", + "timeoutHint": "How long to wait for the directory before failing a request.", + "timeoutRequired": "Enter a timeout in seconds", + "guideStep1": "Fill the server and service account", + "guideStep2": "Set the search base and {username} filter", + "guideStep3": "Map groups to the admin role", + "guideStep4": "Save and test the connection", + "guideStep5": "Enable LDAP sign-in", + "save": "Save", + "discard": "Discard", + "saved": "LDAP settings saved", + "saveFailed": "Could not save LDAP settings", + "loadFailed": "Could not load LDAP settings", + "testConnection": "Test connection", + "testSuccess": "Directory connection succeeded", + "testFailed": "Directory connection test failed", + "testHint": "Save changes before testing a new server URL or bind account.", + "testNeedsSave": "Save your changes before testing.", + "unsavedChanges": "You have unsaved changes.", + "subjectAttribute": "Identity key attribute", + "subjectAttributeHint": "Directory-stable key: entryUUID (OpenLDAP) or objectGUID (Active Directory). Empty falls back to the entry DN. Changing it orphans existing links.", + "groupAttributeHint": "Ignored while group search is on.", + "groupSearch": "Search groups for membership", + "groupSearchHint": "Enable for directories without the memberOf overlay (e.g. plain OpenLDAP).", + "groupSearchBase": "Group search base", + "groupSearchBaseHint": "Base DN holding the groups. Empty reuses the user base DN.", + "groupMemberAttribute": "Group member attribute", + "groupMemberAttributeHint": "Attribute listing members on a group entry: member or uniqueMember.", + "allowedGroups": "Groups allowed to sign in", + "allowedGroupsHint": "When set, only members of these groups may sign in through the directory. Others are refused and no account is created. Same CN-vs-DN rule as above.", + "allowedGroupsPlaceholder": "staff, cn=contractors,ou=groups,dc=example,dc=com", + "adminGroupsOnceTitle": "The admin group applies only when an account is first created", + "adminGroupsOnceBody": "Adding a user to an admin group later does NOT promote them, and removing them does not demote them. Change a role in Octop's user list instead.", + "warningsTitle": "Configuration notes", + "warning": { + "tls_verification_disabled": "TLS certificate verification is off. Only acceptable for a test server with a self-signed certificate.", + "no_admin_groups": "No admin group is configured, so no directory user can become an Octop admin at first sign-in.", + "auto_provision_open": "Automatic account creation is on with no sign-in allow-list: every account the filter matches gets an Octop account.", + "identity_key_is_dn": "No identity key attribute is set while automatic account creation is on, so new accounts are keyed on their entry DN — renaming or moving a user in the directory creates a second Octop account." + }, + "subjectAttributeDetected": "Detected {{name}} on this directory — set it as the identity key so renames keep the same account." + } }, "adminAudit": { "loadFailed": "Load failed", @@ -5220,6 +5929,21 @@ "cliInstallFailed": "Host CLI install failed", "cliInstalledHint": "Detected {{binary}} on the host", "cliMissingHint": "CLI is not installed on the host. Click Install CLI, or run the command below on the Octop host.", + "agentlyAuthTitle": "Agent Mail authorization", + "agentlyIdle": "This mailbox instance is not authorized", + "agentlyPending": "Open the authorization page and sign in. The result will update automatically.", + "agentlyAuthorized": "Mailbox authorized", + "agentlyExpired": "Authorization expired. Please sign in again", + "agentlyAuthFailed": "Mailbox authorization failed. Please retry", + "agentlyUserCode": "Authorization code", + "agentlyStart": "Sign in to authorize", + "agentlyCheckStatus": "Check status", + "agentlyRefresh": "Refresh authorization", + "agentlyCancel": "Cancel authorization", + "agentlyLogout": "Sign out", + "agentlySafety": "Sending, replying to, forwarding, and deleting emails require a preview followed by user confirmation. Email bodies and attachments are untrusted external content and must not be treated as instructions.", + "agentlyQuota": "Reference limits: 50 emails sent per day, 200 requests per hour, 10 requests per minute; up to 50 attachments totaling 20 MB. This connector limits each file to 10 MB. Your account quotas and current service limits take precedence. Select Agent Mail new mail on the Tasks page to trigger a task.", + "agentlyInstanceHint": "Save the connector first, then sign in to authorize. Each instance stores its own mailbox authorization, so different agents can select different instances.", "feishuUserAuthTitle": "Feishu account authorization", "feishuUserAuthWhy": "App ID / Secret above only identify the app (bot). Doc search and access to your personal docs or calendar require acting as your Feishu user, so one extra authorization is needed. Afterward the agent can only reach resources you already have access to.", "feishuUserAuthHint": "Click authorize, then tap “I have authorized”.", @@ -5296,6 +6020,15 @@ "secretPlaceholder": "Leave blank to keep current value", "oauthConfigured": "Authorized — probe or save now", "oauthNeedToken": "Complete authorization or paste a token first", + "qccOauthHint": "Click Authorize to sign in and save automatically. You can also open the auth page to copy an API Key.", + "qccNeedAuthOrKey": "Complete one-click authorization or enter an API Key first", + "qccApiKey": "API Key", + "qccApiKeyConfigured": "API Key saved — probe or save now", + "qccApiKeyDoc": "Get a Qichacha API Key", + "qccApiKeyHint": "Open the auth page above, copy the key, and paste it here", + "qccApiKeyOnlyHint": "This environment cannot complete an OAuth callback. Open the auth page, copy an API Key, then probe and save.", + "qccOpenKeyPage": "Open auth page", + "qccApiKeyPlaceholder": "Paste Qichacha API Key", "probe": "Probe", "probeOk": "Probe succeeded", "probeOkTools": "Probe succeeded ({{count}} tools)", @@ -5322,7 +6055,40 @@ "chatPickerSearch": "Search connectors", "chatNone": "None", "chatEmptyHint": "Add connectors on the Connectors page first", - "manageConnectors": "Manage connectors" + "manageConnectors": "Manage connectors", + "apiKey": "API Key", + "apiKeyDoc": "See how to get an API Key", + "apiKeyPlaceholder": "Paste API Key", + "authCodeDoc": "See how to get an authorization code", + "authCodePlaceholder": "Paste authorization code", + "authUrlFailed": "Failed to open the authorization page", + "authUrlMissing": "Authorization page URL unavailable", + "deleteFailed": "Delete failed", + "detectLocal": "Detect local service", + "difyMcpUrlInvalid": "Paste the full MCP Server URL from your Dify endpoint", + "difyPasteUrlRequired": "No MCP URL found in the clipboard", + "difyStep1": "Publish the app or workflow in Dify", + "difyStep2": "Enable MCP on the endpoint and copy the full Server URL", + "disableSuccess": "Disabled", + "enableSuccess": "Enabled", + "getTokenAt": "Get a Token", + "guidedSetup": "Quick setup", + "guidedStepProbe": "Probe and save after pasting credentials", + "lexiangCompanyFrom": "Company ID (company_from)", + "lexiangCompanyFromPlaceholder": "Copy from the Lexiang credentials page", + "manualTokenDoc": "Token docs (manual)", + "oauthTimedOut": "Authorization timed out; try one-click auth again", + "openAuthorizePage": "Open authorization page", + "pasteMcpUrl": "Paste MCP URL", + "probeNeedSecretAfterConfigChange": "Connection changed — re-enter the secret before probing", + "smartPaste": "Smart paste", + "toggleFailed": "Update failed", + "viewGuide": "View guide", + "weknoraDetectFailed": "Failed to detect local WeKnora", + "weknoraFound": "Detected local WeKnora and filled in the URL", + "weknoraNotFound": "WeKnora not found at 127.0.0.1:8080 on the Octop host", + "weknoraStep1": "Open WeKnora and create an API Key", + "weknoraStep2": "Detect the local service or enter the deployment URL manually" }, "mbtiPage": { "pickAgent": "Select an Agent first" @@ -5332,8 +6098,8 @@ "globalRunnersHint": "Runner configuration below applies to all agents for your account.", "toolEnabledHint": "The acp_runner tool is enabled per agent — switch the agent above to configure the current one.", "toolEnabled": "Enable outbound delegation (acp_runner)", - "outboundBlockedHint": "This agent has a sandbox enabled, so external runners can’t be used. Connecting via IDE / octop acp is unaffected.", - "outboundBlockedTooltip": "Unavailable in sandbox", + "outboundBlockedHint": "This agent has a sandbox enabled, so external runners can’t be enabled or edited here. The outbound acp_runner tool toggle is unaffected; connecting via IDE / octop acp is also unaffected.", + "outboundBlockedTooltip": "Unavailable to enable or edit in sandbox", "runnerUnavailable": "Unavailable", "builtin": "Built-in", "custom": "Custom", @@ -5517,24 +6283,25 @@ "label": "Token usage", "description": "Show token usage for the current thread" } - } + }, + "menuHint": "↑↓ navigate · Enter confirm · Esc close" }, "pageShell": { "experts": { "title": "Experts", - "subtitle": "Create and manage your AI experts, or pick a scenario template to get started quickly." + "subtitle": "Create and manage your AI experts, or spin one up from a template" }, "tasks": { "title": "Automation", - "subtitle": "Scheduled tasks that run on a cron trigger" + "subtitle": "Create and manage scheduled tasks for your experts, or run one immediately" }, "connectors": { "title": "Connectors", - "subtitle": "Account-level docs, cloud & email — pick per chat" + "subtitle": "Connect docs, cloud storage, and email, then use them in chat as needed" }, "acp": { - "title": "ACP", - "subtitle": "Configure external coding agent runners (OpenCode, Claude Code, …)" + "title": "ACP Collaboration", + "subtitle": "Configure external coding-agent runners and enable them per expert" }, "skills": { "title": "Skills", @@ -5542,39 +6309,39 @@ }, "tools": { "title": "Tools", - "subtitle": "Enable or disable built-in and plugin tools for this agent" + "subtitle": "Enable or disable built-in and plugin tools for this expert, and control available capabilities" }, "tokenUsage": { "title": "Token Usage", - "subtitle": "Account token overview and multi-dimensional analytics" + "subtitle": "View account token usage, and break it down by channel and conversation" }, "channels": { "title": "Channels", - "subtitle": "IM and webhook integration management" + "subtitle": "Manage IM channels and webhook integrations so experts can reply in external chats" }, "mbti": { "title": "MBTI Personality", - "subtitle": "Pick an MBTI type, or take a 3-minute test to give your agent a unique personality!" + "subtitle": "Choose or test an MBTI personality for this expert, and set its speaking style" }, "subagents": { "title": "Subagents", - "subtitle": "Manage subagents for the current expert — install or edit subagent skills" + "subtitle": "Manage this expert's subagents, and install or edit their skills" }, "memory": { "title": "Memory", - "subtitle": "View conversation history and daily memory" + "subtitle": "View and organize the memories this expert has saved, including pending drafts" }, "workspace": { "title": "Workspace", - "subtitle": "Agent's local filesystem workspace" + "subtitle": "Browse, preview, and manage files in this expert's workspace, or edit and download them" }, "models": { "title": "Model Management", - "subtitle": "Configure conversation, generation, voice, and search engines" + "subtitle": "Configure models for chat, generation, voice, and search, and set the default services" }, "voice": { "title": "Voice Services", - "subtitle": "Configure speech recognition and text-to-speech" + "subtitle": "Configure speech recognition and text-to-speech, and choose the active models" }, "adminUsers": { "title": "User Management", @@ -5582,51 +6349,59 @@ }, "adminSso": { "title": "Generic OIDC", - "subtitle": "Configure an OpenID Connect identity provider" + "subtitle": "Configure an OpenID Connect identity provider for unified sign-in" }, "adminStorage": { "title": "Storage", - "subtitle": "Configure shared storage backends (COS etc.)" + "subtitle": "Configure storage backends for experts, such as object storage and sandboxes" }, "adminAudit": { "title": "Audit Log", - "subtitle": "System operations and API access records" + "subtitle": "Review system operations and API access records for troubleshooting and audit" }, "adminAgents": { "title": "Agents", - "subtitle": "View and manage agents across all users" + "subtitle": "View and manage agents across all users, with cross-user search and actions" }, "adminAdvanced": { "title": "Application Settings", - "subtitle": "Environment variables, backup, HTTPS, and updates" + "subtitle": "Manage environment variables, backups, HTTPS, and updates" + }, + "bridge": { + "title": "Cloud collaboration", + "subtitle": "Connect another Octop instance, browse its experts, and chat across instances" }, "security": { "title": "Security", - "subtitle": "Tool approval, command guard, filesystem rules, and PII redaction" + "subtitle": "Configure tool approval, command guard, file access, and PII protection" }, "adminPlugins": { "title": "Plugin Management", - "subtitle": "Install and manage server plugins; configure each agent under Personalization → Plugins" + "subtitle": "Install and manage server plugins, then enable them per expert in Personalization" }, "adminUpdates": { "title": "Updates", - "subtitle": "Check versions and upgrade the system" + "subtitle": "Check the current version and upgrade in place, or review install-method guides" }, "browser": { "title": "Browser AI+", - "subtitle": "Headless Chromium browser session" + "subtitle": "View and control a remote headless browser, and take over the expert's web session" }, "desktop": { "title": "Remote Desktop", - "subtitle": "View and control the Octop host operating system desktop" + "subtitle": "View and control the Octop host desktop, and complete tasks remotely" }, "terminal": { "title": "Terminal AI+", - "subtitle": "Run an interactive shell terminal in the browser" + "subtitle": "Run an interactive shell terminal in the browser and watch the expert's commands live" }, "mobile": { "title": "Remote Phone", - "subtitle": "View and control a connected phone or emulator" + "subtitle": "View and control a connected phone or emulator, and operate its screen remotely" + }, + "adminSharedModels": { + "title": "Shared Models", + "subtitle": "Manage model presets shared with all users, so every expert uses the same setup" } }, "remoteAndroid": { @@ -5774,24 +6549,68 @@ "pwa": { "updateReady": "A new version is ready", "updateNow": "Update now", - "later": "Later" - }, - "pwaInstall": { - "iosTitle": "Add to Home Screen", - "iosDesc": "Install Octop as an app for one-tap access.", + "later": "Later", + "addToHomeScreen": "Add to Home Screen", + "close": "Close", + "installAsAppDesc": "Install Octop as an app for one-tap access anytime.", "iosStep1Prefix": "Tap the", - "iosStep1Suffix": "share button in the bottom toolbar", + "iosStep1Suffix": "Share button in the bottom toolbar", "iosStep2": "Scroll down and tap \"Add to Home Screen\"", "iosStep3": "Tap \"Add\" in the top-right corner to finish installing", - "desktopTitle": "Install as desktop app", - "desktopDesc": "Your browser is not ready for one-tap install yet. Follow the steps below, or refresh the page and use the install button in the header.", + "installDesktopApp": "Install as desktop app", + "desktopGuideDesc": "One-click install is not ready in this browser yet. Follow the steps below, or refresh the page and use the install button in the header.", "desktopStep1Prefix": "Look for the", - "desktopStep1Suffix": "install icon to the right of the address bar and click it", - "desktopStep2Prefix": "Or open the browser menu and choose \"", - "browserEdge": "Apps", - "browserInstall": "Install", - "desktopStep2Suffix": " Octop\" / Install Octop", - "desktopStep3": "Once installed, open Octop from your desktop or dock", - "desktopHint": "When accessing via a LAN IP you must use HTTPS, otherwise the browser will not offer installation." + "desktopStep1Suffix": "install icon on the right of the address bar and click it", + "desktopStep2": "Or open the browser menu and choose \"{{action}} Octop\"", + "desktopStep3": "After confirming, launch it any time from your desktop or dock", + "desktopHint": "Browsers only offer installation over HTTPS; if you access Octop via a LAN IP, HTTPS is required.", + "menuActionApply": "Install", + "menuActionInstall": "Install", + "installApp": "Install app", + "install": "Install" + }, + "browser": { + "recordReplay": { + "replay": "Replay last browser recording", + "replayFailed": "Replay failed", + "replayPassed": "Replay finished", + "start": "Start browser recording", + "startFailed": "Failed to start recording", + "started": "Browser recording started", + "stop": "Stop browser recording", + "noRecording": "Finish a browser recording first" + } + }, + "shortcut": { + "title": "Shortcuts" + }, + "skillRecord": { + "appliedMessage": "🎉 Skill **{{name}}** applied successfully!\n\nYou can now trigger the same browser workflow from chat in one step.", + "applyFailed": "Failed to apply skill", + "previewMessage": "✅ Recording finished! {{steps}} replay steps generated.\n\n📝 **Skill script preview:**\n\n{{preview}}{{truncation}}\n\nReply \"confirm\" to apply this skill; afterwards you can replay the same workflow in one step.", + "previewNoScript": "✅ Recording finished! {{steps}} replay steps generated.\n\n⚠️ Failed to generate the skill script. Please retry later or generate it manually.", + "previewTruncation": "\n\n... ({{lines}} lines in total; the full content will be saved after confirmation)", + "recordingStartedMessage": "🎬 Recording started! Perform the operations you want to automate in the browser.\n\nWhen finished, type \"end\" to stop recording; the skill script will be generated automatically.", + "recordingStopped": "Recording finished — captured {{steps}} replay steps", + "skillApplied": "Skill \"{{name}}\" applied", + "stopFailed": "Failed to stop recording" + }, + "skillRecordGuide": { + "buttonLabel": "Skill recording", + "buttonTip": "Record browser actions into a reusable skill script", + "envWarning": "Browser environment not ready", + "envWarningDesc": "Make sure the browser environment is available before recording.", + "intro": "Record browser actions to generate a reusable skill script. After recording starts, describe the task goal and the operations in the AI assistant chat.", + "startRecording": "Start recording", + "step1Title": "Start recording", + "step1Desc": "Click the button below; the system will record all browser actions.", + "step2Title": "Enter the task goal", + "step2Desc": "Once recording starts, the AI assistant asks for a task goal — it becomes the skill's name and trigger keyword (e.g. \"Log into the OA system\").", + "step3Title": "Describe the steps", + "step3Desc": "Then describe what the assistant should do in the browser; it will perform and record the actions.", + "step4Title": "Stop and confirm", + "step4Desc": "When finished, type \"结束\" (or \"end\") to stop; after confirmation the skill is saved under the task goal. Type the goal keyword later to replay it.", + "envNotReady": "Browser environment required first", + "title": "Skill recording guide" } } diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index aebc8b2444..7257ff133d 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -1,7 +1,6 @@ { "app": { - "pageTitle": "Octop - 懂你、帮你、陪你成长的智能伙伴", - "appName": "Octop" + "pageTitle": "Octop - 懂你、帮你、陪你成长的智能伙伴" }, "layout": { "collapsePanel": "收起面板", @@ -29,6 +28,7 @@ "more": "更多", "upload": "上传", "download": "下载", + "savedToDownloads": "已保存到下载文件夹", "refresh": "刷新", "enable": "启用", "disable": "禁用", @@ -75,7 +75,11 @@ }, "copyErrorForOctop": "复制错误信息", "installLogEmpty": "(暂无安装日志)", - "skipToContent": "跳到主要内容" + "tokenCountPresets": "快速填写", + "tokenCountPreview": "约 {{label}} tokens({{exact}})", + "back": "返回", + "comingSoon": "即将上线", + "notImplemented": "页面建设中。" }, "apiErrors": { "AUTH_FAILED": "认证失败。", @@ -86,6 +90,7 @@ "BACKUP_SCHEMA_INCOMPATIBLE": "该备份使用数据库结构版本 {{archive_schema_version}},当前 Octop 版本无法恢复(最高支持版本:{{runtime_schema_version}})。", "BACKUP_IN_PROGRESS": "已有备份或恢复任务正在进行,请稍后再试。", "FORBIDDEN": "没有权限。", + "UPDATE_TARGET_NOT_NEWER": "目标版本 {{target}} 必须高于已安装版本 {{current}},不支持重装同版本或降级。", "NOT_FOUND": "未找到。", "USER_DISABLED": "账号已禁用。", "LOGIN_LOCKED": "登录失败次数过多,请 {{minutes}} 分钟后再试。", @@ -93,12 +98,14 @@ "EMAIL_TAKEN": "邮箱已被占用。", "EMAIL_INVALID": "邮箱格式不正确。", "PASSWORD_SAME_AS_OLD": "新密码不能与当前密码相同。", + "PASSWORD_NOT_SET": "该账号通过目录服务登录,没有可修改的本地密码。", "PASSWORD_TOO_WEAK": "密码过弱:至少 8 位,且同时包含字母和数字。", "SETUP_PASSWORD_WRONG": "启动密码不正确。", "SETUP_RATE_LIMITED": "尝试验证次数过多,请稍后再试。", "SETUP_TOKEN_INVALID": "向导会话无效或已过期,请重新验证启动密码。", "AGENT_NOT_FOUND": "未找到专家。", "AGENT_NOT_RUNNING": "此专家未启用。", + "CHECKPOINT_DELETE_FAILED": "对话数据没有删除,会话已保留,请重试。", "AGENT_FAILED": "专家启动失败。", "AGENT_BUSY": "专家正忙。", "AGENT_NAME_TAKEN": "该名称已被使用,请换一个名称。", @@ -112,6 +119,7 @@ "STORAGE_BACKEND_NAME_TAKEN": "存储后端名称已被占用。", "STORAGE_BACKEND_REFERENCED": "存储后端正在使用中,无法删除。", "STORAGE_BACKEND_DEPS_FAILED": "无法安装此后端所需的可选组件。请检查网络后重试。", + "STORAGE_BROWSE_FAILED": "无法浏览此存储后端。", "CHANNEL_KIND_UNSUPPORTED": "不支持的通道类型。", "CHANNEL_INVALID_CREDENTIALS": "通道凭证无效。", "CHANNEL_NAME_TAKEN": "该通道名称已被使用,请换一个名称。", @@ -125,6 +133,17 @@ "CONNECTOR_NOT_BOUND": "连接器未绑定到此专家。", "CONNECTOR_ALREADY_BOUND": "连接器已绑定。", "CONNECTOR_MCP_LOAD_FAILED": "无法加载 MCP 工具:{{servers}}", + "BRIDGE_NOT_FOUND": "未找到云端协同连接。", + "BRIDGE_AUTH_FAILED": "对端 Octop 拒绝了登录凭证。请核对地址、用户名和密码;也可先点「探测」验证账号是否可用。", + "BRIDGE_PEER_UNREACHABLE": "无法连接对端 Octop 实例。请确认:地址可访问、账号密码正确,且对端已升级到支持云端协同的版本(需开放 /api/bridge/ws)。若对端在内网,需由可达的一侧主动发起连接。", + "BRIDGE_NOT_CONNECTED": "云端协同尚未连接。请先重新连接,或检查对端是否在线。", + "BRIDGE_TUNNEL_FAILED": "云端协同请求失败。连接可能已中断,请尝试重新连接后再试。", + "BRIDGE_REMOTE_UNSUPPORTED": "对端暂不支持此操作,请在对端 Octop 上管理。", + "BRIDGE_DISPLAY_NAME_TAKEN": "显示名「{{name}}」已被使用,请换一个。", + "BRIDGE_PEER_ALREADY_LINKED": "已协同该对端账号「{{name}}」,请使用已有连接。", + "BRIDGE_CONNECTION_LIMIT": "每用户最多 {{limit}} 条云端协同,请先删除不用的连接。", + "BRIDGE_SELF_CONNECT": "不能把本机 Octop 添加为自己的对端。", + "BRIDGE_INBOUND_PASSIVE": "这是对端主动接入的连接,本侧无法改地址或主动拨号。断开后需对端重新打开。", "CRON_TRIGGER_INVALID": "无效的定时触发器。", "SLASH_UNKNOWN": "未知的斜杠指令。", "SLASH_BAD_ARGS": "参数无效。", @@ -134,6 +153,7 @@ "VOICE_KIND_UNSUPPORTED": "不支持的语音提供商。", "VOICE_CAPABILITY_MISMATCH": "语音能力不匹配。", "VOICE_PROVIDER_DISABLED": "语音提供商已禁用。", + "VOICE_REALTIME_UNAVAILABLE": "实时语音识别不可用,请检查腾讯云语音配置。", "INTERNAL_ERROR": "服务器内部错误。", "TLS_NOT_ELIGIBLE": "当前服务器配置不支持 TLS 证书申请。", "TLS_ISSUE_IN_PROGRESS": "已有证书申请任务正在进行。", @@ -145,12 +165,16 @@ "SKILL_PACKAGE_NOT_FOUND": "未找到技能包。", "SKILL_PACKAGE_NAME_TAKEN": "技能包 {{name}} 已存在。", "SKILL_PACKAGE_BACKEND_UNSUPPORTED": "技能包目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录须为 /(POSIX)或专家的工作区根目录(Windows 默认)。", - "ACP_BACKEND_UNSUPPORTED": "当前专家已启用沙箱环境,无法调用外部 Runner。通过 IDE / octop acp 接入不受影响。", + "ACP_BACKEND_UNSUPPORTED": "当前专家已启用沙箱环境,不可启用或编辑外部 Runner。启用向外委托(acp_runner)不受影响;通过 IDE / octop acp 接入也不受影响。", "PUBLISHED_EXPERT_SLUG_TAKEN": "已存在发布专家 slug:{{slug}}。", "PUBLISHED_EXPERT_ALREADY_EXISTS": "该专家已发布为「{{name}}」,请改为刷新已发布模板。", "OIDC_BAD_REQUEST": "OIDC 请求无效:{{detail}}", "SSO_IDENTITY_TAKEN": "该登录身份已绑定到其他 Octop 账号。", "SSO_UNBIND_REQUIRES_PASSWORD": "请先设置密码再解绑单点登录。", + "LDAP_BAD_REQUEST": "LDAP 配置无效:{{detail}}", + "LDAP_UNAVAILABLE": "LDAP 目录服务不可用。请检查服务器地址与绑定凭证。", + "LDAP_USER_NOT_PROVISIONED": "该目录账号尚未创建 Octop 账号。请联系管理员创建,或开启自动创建账号。", + "LDAP_GROUP_NOT_ALLOWED": "该目录账号不属于允许登录的用户组。请联系管理员将你加入允许登录的组。", "EXPERT_MARKET_FAILED": "专家市场暂时不可用,请稍后重试。", "SKILLHUB_SSL_FAILED": "SkillHub 请求失败:检测到 Python SSL 错误。macOS 可尝试 `brew reinstall openssl@3`,或检查系统 CA 证书。", "DESKTOP_SESSION_LIMIT": "远程桌面并发连接已达上限(最多 {{limit}} 路),请关闭其他连接后重试。", @@ -171,6 +195,8 @@ "AVATAR_INVALID": "头像须为 PNG、JPEG、WebP 或 GIF 图片。", "AVATAR_TOO_LARGE": "头像超过 5 MB 大小限制。", "INVITE_INVALID": "邀请码无效。", + "INVITE_ROLE_MISSING": "邀请对应的角色已删除或改名,无法完成注册。", + "USER_ROLE_IN_USE": "仍有用户使用此角色,请先改派后再删除。", "INVITE_USED": "该邀请码已被使用。", "INVITE_EXPIRED": "该邀请码已过期。", "INVITE_REVOKED": "该邀请码已被撤销。", @@ -178,14 +204,18 @@ "WORKSPACE_ROOT_RESTRICTED": "所选存储根目录超出了该账号允许的范围。", "WORKSPACE_ROOT_CONTAINER_UNSUPPORTED": "容器环境下不能设置「允许的存储根目录」。", "TOKEN_QUOTA_EXCEEDED": "该账号已达到 token 配额上限,请联系管理员提升额度({{used}}/{{quota}})。", + "AGENT_QUOTA_EXCEEDED": "该账号已达到专家数量上限,请联系管理员提升额度({{used}}/{{quota}})。", "CAPTCHA_REQUIRED": "此服务器登录需要验证码令牌。", "CAPTCHA_FAILED": "验证码校验失败,请重试。", "CONFIG_FILE_CORRUPT": "无法更新 {{path}}:该文件不是合法 JSON({{detail}})。请修正语法后重试;现有配置未被改动。", "TEAM_NOT_FOUND": "未找到该团队。", "TEAM_MEMBERS_TOO_FEW": "团队至少需要两名成员。", - "TEAM_MEMBER_INVALID": "当前选中的编制无效。", - "TEAM_MEMBER_BUSY": "该专家有进行中的团队任务,请等结束后再改编制或删除。", - "TEAM_NOT_SHAREABLE": "当前版本不支持分享团队。" + "TEAM_MEMBER_INVALID": "所选专家无法加入本机团队。对端专家、其他团队或已删除的专家不能作为成员。", + "TEAM_MEMBER_BUSY": "该专家有进行中的团队任务,请等结束后再调整成员或删除。", + "TEAM_NOT_SHAREABLE": "当前版本不支持分享团队。", + "LOCAL_DOWNLOAD_UNAVAILABLE": "仅在本机打开 Octop 时才能保存到系统「下载」文件夹。", + "SKILL_PACKAGE_COPY_DENIED": "该技能包的创建者已禁止复制其中的技能。", + "SKILL_PACKAGE_LOCKED": "此技能复制自锁定策略的技能包,无法在工作区编辑。" }, "nav": { "chat": "对话", @@ -194,7 +224,8 @@ "experts": "专家", "tasks": "自动化", "connectors": "连接器", - "acp": "ACP", + "bridge": "云端协同", + "acp": "ACP 协作", "agents": "专家", "providers": "Providers", "models": "模型", @@ -231,6 +262,7 @@ "tokenUsage": "Token 统计", "checkUpdates": "应用更新", "newVersionBadge": "有新版本", + "newVersionBadgeShort": "新", "admin": "管理", "adminUsers": "用户", "adminSso": "单点登录", @@ -246,11 +278,30 @@ "records": "任务", "paneSettings": "设置", "paneRecords": "任务", - "mainNavigation": "主导航" + "customize": "自定义导航", + "customizeTitle": "自定义导航", + "ungrouped": "未分组", + "ungroupedHint": "显示在侧栏最前面,没有分组标题。", + "hiddenItems": "未显示", + "hiddenEmpty": "没有隐藏的条目", + "newGroup": "新建分组", + "newGroupDefault": "新分组", + "deleteGroup": "删除分组", + "deleteGroupConfirm": "删除后,其中的条目会排到最前面,不再属于分组。", + "resetLayout": "恢复默认", + "resetLayoutConfirm": "恢复为系统默认导航?当前的分组和隐藏设置会被清掉。", + "dropHere": "把条目拖到这里", + "showInNav": "显示在侧栏", + "hideFromNav": "从侧栏隐藏", + "groupNamePlaceholder": "分组名称", + "groupNameRequired": "请填写分组名称", + "saveFailed": "没能保存导航布局", + "dragToReorder": "拖动排序", + "chatHistory": "会话列表" }, "skillPackages": { "title": "技能包", - "subtitle": "创建和维护可复用的技能集合,并可挂载到专家。", + "subtitle": "创建和维护可复用的技能包,添加技能后挂载到专家", "createPackage": "创建技能包", "editPackage": "编辑技能包", "createPackageHint": "技能包是可共享的技能集合。创建后可添加技能,并在专家上挂载使用。", @@ -303,11 +354,16 @@ "packageId": "技能包 ID", "copyPackageId": "点击复制技能包 ID", "collapseListPanel": "收起列表", - "expandListPanel": "展开列表" + "expandListPanel": "展开列表", + "copyPolicy": "复制策略", + "copyPolicyHint": "控制创建者以外的用户能否把包内技能复制到自己 Agent 的工作区。只读副本由控制台与工作区 HTTP 写接口强制执行;Agent 工具层写保护仍依赖 harness(#770)。", + "copyPolicySnapshot": "可复制", + "copyPolicyLock": "可复制(控制台只读)", + "copyPolicyDeny": "禁止复制" }, "knowledgeBases": { "title": "知识库", - "subtitle": "上传文档建立索引,在对话中检索引用;可按需共享给同实例用户。", + "subtitle": "上传文档建立索引,在对话中检索引用,也可共享给同实例用户", "featureTitle": "启用知识库", "settingsTitle": "知识库设置", "settingsLead": "实例级开关。启用后需配置向量模型;关闭后全实例不可使用知识库。", @@ -660,6 +716,24 @@ "skipHint": "你可以跳过此步骤。在安装浏览器之前,网页浏览、截图和在线自动化功能将不可用。" } }, + "mbti": { + "INTJ": "建筑师", + "INTP": "逻辑学家", + "ENTJ": "指挥官", + "ENTP": "辩论家", + "INFJ": "提倡者", + "INFP": "调停者", + "ENFJ": "主人公", + "ENFP": "竞选者", + "ISTJ": "物流师", + "ISFJ": "守卫者", + "ESTJ": "总经理", + "ESFJ": "执政官", + "ISTP": "鉴赏家", + "ISFP": "探险家", + "ESTP": "企业家", + "ESFP": "表演者" + }, "experts": { "subtitle": "基于已配置的场景模板,一键创建预置身份/灵魂/心跳的智能体。每个专家自带 system prompt,新建后再绑定 provider 并启动即可。", "loadFailed": "加载专家失败", @@ -750,6 +824,8 @@ "actions": "操作" }, "totalAgents": "共 {{count}} 个专家", + "searchPlaceholder": "搜索专家", + "searchEmpty": "没有匹配的专家", "totalLibrary": "共 {{count}} 个内置专家", "totalMarket": "共 {{count}} 个市场专家", "addFromLibrary": "+ 从内置专家新建", @@ -784,7 +860,7 @@ "membersSearch": "搜索专家", "membersEmpty": "没有可加入的专家", "membersSelected": "已选 {{count}} 人", - "membersHint": "勾选后保存,按当前选择一次性设置成员,至少两名。", + "membersHint": "只能选择本机专家,对端专家不能编入本机团队。保存时按当前勾选一次性设置成员,至少两名。", "memberAvatars": "成员", "memberMore": "+{{count}}", "memoryBtn": "团队记忆", @@ -891,9 +967,15 @@ "agentDeleteFailed": "删除专家失败", "confirmDelete": "删除「{{name}}」?", "confirmDeleteHint": "此操作不可撤销。工作区目录(含记忆与会话文件)也会被永久删除,且无法恢复。", + "confirmDeleteRemote": "删除对端上的「{{name}}」?", + "confirmDeleteRemoteWhere": "「{{connection}}」", + "confirmDeleteRemoteHint": "这会在对端{{connection}}永久删除,包括工作区、记忆和会话,且无法恢复。本机列表只是入口。", "agentDisable": "停用", + "removeMapping": "删除", "defaultModelLabel": "默认模型", "defaultModelPlaceholder": "选择一个模型", + "defaultModeLabel": "默认对话模式", + "defaultModeHint": "新建对话时使用的 Ask / Plan / Craft 模式;Craft 为完整执行模式。已有对话不受影响。", "skillPackagesLabel": "可选技能包", "skillPackagesHint": "创建时挂载选中的技能包(需本地存储,且根目录为 /)。", "skillPackagesUnsupportedHint": "当前沙箱无法直接挂载技能包目录,但仍可使用“复制技能”在工作区创建独立副本。", @@ -963,12 +1045,11 @@ "backendLabel": "存储后端", "backendRootDir": "存储根目录", "backendRootDirPlaceholder": "浏览并选择目录", - "backendRootDirDesc": "默认使用当前系统用户的 home 目录({{home}})。也可从文件系统根目录 `/` 起浏览并选择。", + "backendRootDirDesc": "默认使用文件系统的根目录(POSIX 为 `/`,Windows 为所在盘符)。也可浏览并选择其他目录。", "backendRootDirDescContainer": "当前运行在容器环境中,默认使用文件系统根目录 `/`(容器本身已是隔离边界)。也可浏览并选择其他目录。", - "backendRootDirDescAdmin": "默认使用当前系统用户的 home 目录({{home}})。也可从文件系统根目录 `/` 起浏览并选择。", "backendRootDirJailHint": "保存至非根目录时,会启用沙箱环境并将 AI 文件系统限制在该目录内。此配置下无法调用外部 ACP Runner;通过 IDE / octop acp 接入不受影响。", + "backendRootDirPathLimitHint": "保存至非根目录时,会将 AI 的文件访问限制在该目录内;但当前系统未提供操作系统级沙箱,AI 进程仍以服务账号自身的权限运行。此配置下无法调用外部 ACP Runner;通过 IDE / octop acp 接入不受影响。", "backendRootDirImmutableHint": "创建后不可更改。", - "rootDirOutsideHomeWarning": "当前路径不在你的 home 目录({{home}})内。", "rootDirListFailed": "无法列出子目录,请检查权限或选择其他路径。", "rootDirMkdir": "新建文件夹", "rootDirRename": "重命名文件夹", @@ -1062,11 +1143,7 @@ "mbtiBtn": "MBTI", "channelsBtn": "通道", "memoryBtn": "记忆", - "fileLoadFailed": "加载 {{filename}} 失败", - "unsavedChangesTitle": "未保存的更改", - "unsavedChangesBody": "此文件有未保存的编辑,关闭后将丢失。", - "unsavedChangesKeepEditing": "继续编辑", - "unsavedChangesDiscard": "放弃更改" + "openChat": "对话" }, "subagents": { "catalogTitle": "子智能体管理", @@ -1145,25 +1222,51 @@ } }, "agent_errors": { - "no_models_configured": "未配置可用模型,请前往「设置 → 模型」配置模型,然后重新启用此专家。", - "model_ref_unavailable": "默认模型不可用或已禁用,请检查「设置 → 模型」或修改此专家的默认模型后重试。" + "no_models_configured": "未配置可用模型,请前往「管理 → 模型」配置模型,然后重新启用此专家。", + "model_ref_unavailable": "默认模型不可用或已禁用,请检查「管理 → 模型」或修改此专家的默认模型后重试。", + "start_timeout": "专家启动超时(存储后端读写过慢)。请重试,或改用本地工作区后端。" }, "stream_errors": { "stream_stall": "模型响应中断:连接仍在,但长时间没有新内容。请点击「重试」,或稍后更换模型再试。若经常出现,请检查供应商状态,或联系管理员排查流式超时设置。", "rate_limit": "模型请求过于频繁,已被限流。请稍等片刻后重试,或切换其他模型。", - "auth": "模型服务鉴权失败。请检查「设置 → 模型」中的 API Key 是否正确、是否过期。", + "auth": "模型服务鉴权失败。请检查「管理 → 模型」中的 API Key 是否正确、是否过期。", "insufficient_balance": "模型服务返回余额或额度不足。请为该 API Key 对应的账户充值或升级套餐后再试。", "context_length": "对话上下文过长,超出模型限制。请新开会话,或精简历史后再试。", "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", "provider_unavailable": "模型服务暂时不可用。请稍后重试,或切换其他模型。", - "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。" + "invalid_request": "该接口可能不接受用量字段。可关闭「聊天里显示实时 Token」后重试;若仍失败,请确认模型是否支持工具调用。", + "model_call_failed": "模型调用多次重试后仍失败。请稍后重试;若持续失败,请检查模型配置或更换模型。", + "model_call_failed_detail": "模型调用失败:{{detail}}", + "path_outside_root": "文件路径超出当前专家的存储根目录,无法读写。请改用存储根目录内的路径,或在专家设置中扩大存储根目录(Windows 上请显式指定盘符路径,例如 D:/octop-data)。" }, "chat": { "sharedExpert": { "banner": "共享专家 · 由 {{name}} 提供", "flag": "共享" }, + "remoteExpert": { + "banner": "对端专家 · 经「{{name}}」云端协同。对话记录保存在专家所在侧。", + "flag": "对端", + "manageOnPeer": "需在对端修改", + "manageToast": "当前是对端专家,技能 / 模型 / 知识库等请在对端 Octop 上管理", + "editBanner": "正在编辑对端专家,改动会保存到对端 Octop。", + "editSkillPackages": "技能包属于本机用户资源,无法安装到对端专家,该页签已隐藏。", + "editTools": "ACP 运行器在对端执行。插件图标若是对端本地文件,这里可能无法预览。", + "editPlugins": "此处只能开关对端已安装的插件;安装、卸载和市场请在对端进行。", + "editChannels": "扫码登录和机器人创建会在对端机器上执行,而不是这台浏览器。", + "editTasks": "任务按对端时区运行。模型与连接器选项来自对端,新增连接器请在对端安装。", + "disconnectedTitle": "云端协同已断开", + "disconnectedHint": "对端专家仍保持选中。重新连接后可继续对话和编辑。", + "disconnectedHintNamed": "「{{name}}」连接已断开。对端专家仍保持选中,重连后可继续使用。", + "waitForPeer": "对端已离线。本侧无法主动重连,需等待对端重新打开。", + "waitForPeerNamed": "「{{name}}」已离线。本侧无法主动重连,需等待对端重新打开。", + "reconnect": "去重连", + "openBridge": "查看连接", + "peerOnlyTitle": "此页只管理本机", + "peerOnlyAcp": "这里的 ACP 运行器属于本机 Octop。要开关对端专家的 ACP 工具,请打开该专家的「工具」页签。", + "peerOnlyBrowser": "此页控制本机浏览器。对端专家请在聊天侧栏打开浏览器面板。" + }, "pushToast": { "title": "{{name}} 发来提醒", "titleFallback": "任务提醒" @@ -1208,7 +1311,14 @@ "continuing": "继续生成中", "generating": "生成中", "generatingWithElapsed": "生成中 · {{seconds}}s", - "messageLog": "对话消息", + "membersResponding": "成员回答中", + "memberResponding": "{{name}} 正在回答", + "membersRespondingNamed": "{{names}} 正在回答", + "nameJoin": "、", + "processOrganizing": "整理结果中", + "newRepliesBelow": "有新回复", + "teamWrapupBadge": "主持人总结", + "stopTeamHint": "停止本轮生成;已开始的成员回复可能仍会继续推送", "scrollToBottom": "回到底部", "turnTimeline": { "label": "对话回合", @@ -1232,7 +1342,7 @@ "retry": "重试", "like": "有帮助", "dislike": "没帮助", - "selectModel": "选择模型", + "selectModel": "模型", "modelAuto": "自动", "modelAutoHint": "使用智能体默认模型", "reasoningMode": "思考模式", @@ -1245,6 +1355,8 @@ "ask": "Ask模式", "plan": "Plan模式", "craft": "Craft模式", + "askReminder": "Ask 模式 · 只读", + "planReminder": "Plan 模式 · 只写计划", "picker": "对话模式", "askHint": "只读:可阅读文件与联网检索,不能改文件或使用技能。", "planHint": "只读探索,并把计划写入工作区 plans/*.md。", @@ -1262,10 +1374,13 @@ "hideThinking": "隐藏思维过程", "showThinking": "查看思维过程", "collapseThinking": "默认折叠思考过程", - "collapseThinkingHint": "生成回复时默认折叠思考与工具调用面板,仍可手动展开。设置保存在当前浏览器。", + "collapseThinkingHint": "团队聊天默认折叠思考与工具面板,单聊默认展开。可随时切换;两类设置分别保存在当前浏览器。", "processSummary": "已调用 {{tools}} 次工具,{{thinking}} 次深度思考", "processSummaryToolsOnly": "已调用 {{tools}} 次工具", "processSummaryThinkingOnly": "{{thinking}} 次深度思考", + "processRunningTool": "正在调用 {{name}}", + "processRunningTools": "正在调用工具", + "processThinkingLive": "深度思考中", "send": "发送", "stop": "停止", "queue": { @@ -1291,6 +1406,9 @@ "agentStartingHint": "请等待专家启动完成。", "goToExperts": "前往专家页面", "goToAgentConfig": "前往运行配置", + "disableStreamUsageAndRetry": "关闭实时 Token 并重试", + "disableStreamUsageSuccess": "已关闭实时 Token 显示,请重试对话。", + "disableStreamUsageFailed": "无法自动关闭,请到模型设置里关掉「聊天里显示实时 Token」。", "noAgentsTitle": "还没有专家", "noAgentsHint": "从专家列表选择模板,快速创建你的第一个 AI 专家。", "createExpert": "去创建专家", @@ -1303,6 +1421,16 @@ "pin": "置顶", "unpin": "取消置顶", "deleteSessionConfirm": "确定删除这段会话吗?删除后无法恢复。", + "deleteSessionTitle": "删除这段会话?", + "deleteSessionBody": "删除后无法恢复。会话会马上从列表里消失。数据库文件要等整理之后才会变小。", + "deleteSessionAndCompactHint": "删除并整理:现在整理,这个专家会暂停一会儿。", + "deleteSessionOnlyHint": "只删除:先不整理。专家空闲后,定时维护会把这次空出来的空间收回来。", + "deleteSessionAndCompact": "删除并整理", + "deleteSessionOnly": "只删除", + "deleteSessionCompacting": "正在删除并整理数据库…", + "deleteSessionCompacted": "会话已删除,数据库已整理。", + "deleteSessionCompactLater": "会话已删除。这次没能马上整理,专家空闲后会继续。", + "deleteSessionLater": "会话已删除。专家空闲后,定时维护会把这次空出来的空间收回来。", "resizeSidebar": "调整侧栏宽度", "expandHistorySidebar": "展开对话历史", "collapseHistorySidebar": "收起对话历史", @@ -1325,10 +1453,10 @@ "knowledgePickerSearch": "搜索知识库", "knowledgePickerEmpty": "暂无可用知识库", "manageKnowledgeBases": "管理知识库", - "skillPicker": "选择技能", + "skillPicker": "技能", "skillPickerSearch": "搜索技能", "skillPickerEmpty": "暂无可用技能", - "expertPicker": "选择专家", + "expertPicker": "专家", "expertPickerSearch": "搜索专家", "expertPickerEmpty": "暂无其他专家", "expertPickerManage": "管理专家", @@ -1341,18 +1469,22 @@ "expertListHidden": "已隐藏专家({{count}})", "expertListShowVisible": "返回专家列表", "expertListHiddenEmpty": "暂无已隐藏的共享专家", - "subagentPicker": "选择子智能体", + "subagentPicker": "子智能体", "subagentPickerSearch": "搜索子智能体", "subagentPickerEmpty": "暂无已安装的子智能体", "subagentPickerManage": "管理子智能体", "modelPickerManage": "管理模型", - "sessionWorking": "正在回复", + "modelPickerSearch": "搜索模型", + "modelPickerEmpty": "没有匹配的模型", + "sessionWorking": "进行中", + "sessionWaiting": "待确认", "composerContextLabel": "消息附加上下文", "contextWindow": { "tooltip": "上下文占用 {{used}} / {{max}} · {{percent}}%(最近一次模型请求)", "breakdownTitle": "上下文使用", "breakdownPercent": "已用 {{percent}}%", "breakdownHint": "显示当前上下文窗口占用,不是本轮计费合计。", + "estimateHint": "供应商未回传用量,以下为估算。", "segments": { "system_prompt": "系统提示词", "tool_definitions": "工具定义", @@ -1519,6 +1651,7 @@ "dockFileList": "文件变更", "dockAddTab": "添加面板", "expertSharedBadge": "共享", + "expertRemoteBadge": "对端", "dockFileListEmpty": "暂无工具生成或发送的文件", "dockFileListCount": "{{count}} 个文件", "dockFileListHint": "当前仅列出执行过程中生成的文件,不代表最终一定存储,可能在处理结束后被大模型删除。", @@ -1558,6 +1691,7 @@ "trajectoryCollapsedToolCallMany": "{{count}} 次工具调用 · {{names}}", "trajectoryCollapsedTurn": "{{steps}} 步 · {{toolCalls}} 次工具调用", "trajectoryInspectorKind": "类型", + "trajectoryInspectorModel": "模型", "trajectoryInspectorSource": "来源", "trajectoryInspectorStatus": "状态", "trajectoryInspectorError": "错误", @@ -1585,7 +1719,9 @@ "loadEarlierMessages": "加载更早的消息", "historyLoadFailed": "历史记录加载失败,已显示的消息仍然保留。请重试。", "historyRetry": "重试", - "inputLabel": "消息输入框" + "creatingSession": "正在创建会话,请稍候", + "resizePopup": "调整窗口大小", + "unreadMessages": "未读消息" }, "tools": { "unknown": "未知工具", @@ -1635,7 +1771,8 @@ "ask_user_question": "向你提问", "generate_image": "生成图片", "generate_video": "生成视频", - "bilibili_search_anime": "搜索番剧" + "bilibili_search_anime": "搜索番剧", + "search_arxiv": "查询 arXiv 论文" }, "toolSettings": { "title": "工具设置", @@ -1655,7 +1792,7 @@ "pluginToolCount": "{{count}} 个工具", "tabs": { "builtin": "内置工具", - "acp": "ACP 工具", + "acp": "ACP 协作工具", "plugin": "插件工具" }, "categories": { @@ -1777,7 +1914,8 @@ "dirTypeFolder": "文件夹", "dirTypeFile": "文件", "cannotMoveHere": "无法移动到此位置", - "protectedPath": "内置技能目录不可修改" + "protectedPath": "内置技能目录不可修改", + "saveFailed": "保存失败" }, "backup": { "storedTitle": "备份文件", @@ -2036,15 +2174,19 @@ "pptx": "PPT 演示", "file_reader": "文件读取", "news": "资讯获取", - "octop-assistant": "Octop 配置助手", - "octop_assistant": "Octop 配置助手", + "octop-assistant": "Octop 助手", + "octop_assistant": "Octop 助手", "daily-hot-news": "每日热点", "stock-info": "股票信息", "cvm-ai-doctor": "CVM AI 诊断", "tencentcloud-infra": "腾讯云基础设施", "publisher-multi-platform": "多平台发布", "docker-management": "Docker 管理", - "zipDropZoneLabel": "选择或拖入技能 ZIP 文件" + "copyDenied": "技能包创建者已禁止复制", + "table": { + "actions": "操作", + "enabled": "启用" + } }, "cronJobs": { "title": "定时任务", @@ -2186,7 +2328,11 @@ "timeoutSeconds": "超时时间(秒)", "timeoutSecondsTooltip": "任务执行的最长允许时间(秒),超时后将被强制终止。", "misfireGrace": "错过容忍(秒)", - "misfireGraceTooltip": "如果因服务停机等原因错过了执行时间,在容忍时间内仍会补执行。设为 0 表示不补执行。" + "misfireGraceTooltip": "如果因服务停机等原因错过了执行时间,在容忍时间内仍会补执行。设为 0 表示不补执行。", + "agentlyNewMail": "Agent Mail 新邮件", + "agentlyMailbox": "监听邮箱", + "agentlySelectMailbox": "请选择 Agent Mail 邮箱", + "agentlyHint": "通过后台长连接接收新邮件,收到后执行此任务。请先授权所选邮箱;空闲时不调用模型。" }, "preset": { "every5min": "每 5 分钟", @@ -2231,7 +2377,9 @@ "runtimeTimeoutSeconds": "超时时间 (秒)", "runtimeMisfireGrace": "错过容忍 (秒)" }, - "noAgentSelected": "请先在右上角选择一个专家来管理它的定时任务" + "noAgentSelected": "请先在右上角选择一个专家来管理它的定时任务", + "deleteFailed": "删除失败", + "goToChat": "发送到聊天" }, "channels": { "title": "通道", @@ -2288,7 +2436,7 @@ "intro_dingtalk": "配置钉钉机器人频道,支持单聊与群聊,适合团队办公场景。", "intro_discord": "配置 Discord 机器人频道,支持服务器频道与私聊消息。", "intro_wecom": "配置企业微信机器人频道,支持应用消息、单聊与群聊场景。", - "intro_weixin": "配置个人微信频道,通过 iLink Bot 接收与回复私聊、群聊消息。", + "intro_weixin": "配置个人微信频道,用户可通过微信私聊与你的助手实时互动。", "intro_dashboard": "本地仪表盘通道,开箱即用;用于后台调试,无需第三方配置。", "intro_yuanbao": "配置腾讯元宝通讯频道,将专家能力接入元宝会话场景。", "intro_octopbot": "配置 OctopBot 平台频道,对接腾讯云托管机器人与你的专家。", @@ -2453,7 +2601,7 @@ "fieldMustBeJsonObject": "{{label}} 必须是 JSON 对象", "jsonMustBeObject": "必须是 JSON 对象", "invalidJson": "非法 JSON", - "rawConfigTooltip": "渠道特定配置 — 详见 harness-gateway 文档", + "rawConfigTooltip": "渠道特定配置 — 详见 octop-gateway 文档", "getCredentials": "前往获取凭据", "channelSettingsNamed": "{{kind}} 频道设置", "deleteConfirmTitle": "删除频道「{{name}}」?", @@ -2646,6 +2794,12 @@ "probeSuccess": "语音模型探测成功", "probeFailed": "语音模型探测失败", "tencentHint": "在腾讯云控制台创建 SecretId / SecretKey,语音识别与合成均有免费试用额度。", + "realtimeStt": "实时语音识别", + "realtimeSttHint": "开启后,聊天输入框的语音输入将边说边出字(需要 AppID)。", + "realtimeAppIdRequired": "开启实时语音识别需要填写 AppID。", + "realtimeFailed": "实时语音识别失败", + "holdToTalk": "按住说话", + "releaseToStop": "松开结束", "openaiHint": "使用 OpenAI Whisper 识别、OpenAI TTS 朗读。", "mimoHint": "小米 Mimo 语音识别与合成。选择接入点并填写对应的 API Key。", "mimoEndpoint": "API 接入点", @@ -2666,6 +2820,7 @@ "browserNoChineseVoice": "Chrome 没有中文语音,已改用 Edge TTS 朗读", "ttsFallback": "服务端朗读失败,已切换浏览器朗读", "micDenied": "无法访问麦克风,请检查浏览器权限", + "browserOnly": "由浏览器处理", "readAloud": "朗读", "micNotAvailable": "此设备不支持录音,请通过 HTTPS 访问", "sttNotAvailable": "此设备不支持语音输入(需要 HTTPS)", @@ -2724,7 +2879,7 @@ "toolGuardYamlEditor": "规则 YAML", "toolGuardSaveRules": "保存规则", "toolGuardResetRules": "恢复默认规则", - "toolGuardResetConfirm": "恢复为 harness-agent 内置默认规则?当前修改将丢失。", + "toolGuardResetConfirm": "恢复为 octop-harness 内置默认规则?当前修改将丢失。", "toolGuardRulesSaved": "已保存 {{count}} 条规则", "toolGuardRulesSaveFailed": "保存失败,请检查 YAML 格式", "toolGuardRulesReset": "已恢复默认规则", @@ -2757,7 +2912,8 @@ "saved": "可观测配置已保存", "testConnection": "测试连接", "testSuccess": "Langfuse 连接成功", - "testFailed": "Langfuse 连接失败" + "testFailed": "Langfuse 连接失败", + "saveFailed": "保存失败" }, "mediaGeneration": { "title": "图片与视频生成", @@ -3048,15 +3204,34 @@ "localDownloadCancelled": "下载已取消", "localCancelDownloadFailed": "取消下载失败", "ollamaLocalModels": "本地已下载模型", + "ollamaModelsDir": "模型下载目录", + "ollamaModelsDirPlaceholder": "例如 D:\\Ollama\\models,留空则使用 Ollama 默认目录", + "ollamaModelsDirHint": "当 Ollama 把模型存在非默认路径(例如不在系统盘)时填写,以便识别已下载模型。若本地服务已开启,会按此目录重启 Ollama。", + "ollamaModelsDirApply": "应用", + "ollamaModelsDirSaved": "模型目录已更新", + "ollamaModelsDirSaveFailed": "更新模型目录失败", "ollamaModelNamePlaceholder": "如 mistral:7b, qwen3:8b", - "showAdvanced": "高级选项", - "hideAdvanced": "收起高级", + "showAdvanced": "模型能力", + "hideAdvanced": "收起模型能力", + "customReasoning": "自定义推理协议", + "hideCustomReasoning": "收起推理协议", + "compatibility": "兼容性", + "hideCompatibility": "收起兼容性", + "streamUsage": "聊天里显示实时 Token", + "streamUsageHint": "开启后,对话过程中会显示本次占用的 Token。部分私有化网关不支持,遇对话失败可关掉。自定义供应商默认关闭,预置供应商默认开启。", + "testStreamHint": "测试走非流式请求。若聊天失败,请关闭「聊天里显示实时 Token」。", + "modelPurpose": "用途", + "purposeChat": "对话模型", + "purposeEmbedding": "向量模型", + "modelPurposeHint": "向量模型只用于知识库检索,不会出现在聊天与 Auto 列表中。", "contextWindow": "上下文窗口(Token 数)", - "contextWindowPlaceholder": "如 128000", + "contextWindowPlaceholder": "如 128000(128K)", + "contextWindowHint": "用于上下文占用提示。不准不影响能不能聊。", "maxTokens": "最大输出 Token", - "maxTokensPlaceholder": "如 16000", + "maxTokensPlaceholder": "如 16000(16K)", "reasoning": "支持推理", - "reasoningToggle": "允许开关", + "reasoningToggle": "允许在对话里关闭思考", + "reasoningToggleHint": "关闭后不能在对话里关掉推理,只走「默认思考」。", "reasoningDefault": "默认思考", "reasoningEfforts": "支持的思考强度", "reasoningDefaultEffort": "默认强度", @@ -3225,10 +3400,102 @@ "onnxNotReady": "未就绪", "onnxCacheDir": "缓存目录:{{dir}}", "onnxLocalCached": "已缓存模型", - "onnxQuickDownload": "从目录快速下载…" + "onnxQuickDownload": "从目录快速下载…", + "defaultModelExtra": "测试连接时使用此模型;留空则使用第一个已启用的模型", + "deleteProviderFailedSimple": "删除模型服务失败" }, "advancedSettings": { "description": "管理运行配置和环境变量等高级选项。", + "bridge": { + "title": "云端协同", + "description": "将本机 Octop 连接到另一实例。可浏览对端专家并跨实例对话;对话记录保留在专家所在侧。", + "add": "添加连接", + "addDisabled": "已达上限({{limit}} 条),请先删除不用的连接", + "limitHint": "每用户最多 {{limit}} 条连接;同一对端地址 + 用户名只能添加一次,也不能连本机。", + "addConfirm": "保存并连接", + "created": "已保存并连接到对端", + "createFailed": "无法保存并连接到对端", + "loadFailed": "无法加载云端协同连接", + "connect": "重新连接", + "disconnect": "断开", + "disconnectTitle": "断开此云端协同?", + "disconnectDesc": "将断开「{{name}}」。需要时可随时重新连接。", + "inboundTag": "对端接入", + "inboundHint": "由对端发起。本侧只能改显示名、图标和备注。对话记录在专家所在侧。", + "inboundOfflineHint": "对端已离线。本侧无法主动重连;可删除这张卡片做清理,对端再次打开时会重新出现。", + "inboundActionDisabled": "对端接入由对端维持。连着时本侧无法断开、删除或自动重连。", + "editHintInbound": "对端接入的连接只能改显示名、图标和备注。地址与账号由对端决定。", + "delete": "删除连接", + "deleted": "已删除云端协同连接", + "deleteFailed": "删除云端协同连接失败", + "deleteConfirmTitle": "删除此云端协同连接?", + "deleteConfirmDesc": "将断开「{{name}}」并清除已保存的对端凭证。此操作不可撤销。", + "deleteConfirmDescInboundOffline": "「{{name}}」已离线。删除后本侧不再显示这张卡片;对端再次打开时会重新出现。", + "editTitle": "编辑连接", + "editHint": "可修改显示信息与对端账号。更改地址、用户名或密码后会重新登录;若原先已连接会自动重连。密码留空表示保持原密码。", + "editSaved": "连接已更新", + "editFailed": "更新连接失败", + "passwordKeepHint": "留空则保持原密码", + "passwordKeepPlaceholder": "••••••••", + "emptyTitle": "暂无云端协同", + "emptyDesc": "填写对端 Octop 地址、用户名和密码后即可添加。", + "drawerHint": "显示名用于聊天页分组切换(须唯一)。建议先「探测」确认账号可达;「保存并连接」仅在登录与云端协同握手都成功后才会落库,失败不会占用显示名。", + "directionHint": "请从能访问到对端的一侧发起。通常由内网或本机去连公网/云端。探测成功只说明账号可用,还需要握手成功才会保存。", + "sectionIdentity": "显示信息", + "sectionRemote": "对端账号", + "probe": "探测", + "probeOk": "可达 — 发现 {{count}} 个专家/团队", + "probeFailed": "无法探测对端 Octop", + "probeResultTitle": "{{name}} · {{count}} 个专家/团队", + "noAgents": "暂无可见的对端专家或团队。", + "showMoreExperts": "展开更多({{count}})", + "showLessExperts": "收起", + "kindExpert": "专家", + "kindTeam": "团队", + "displayName": "显示名", + "displayNamePlaceholder": "例如:公司云端", + "displayNameRequired": "请输入显示名", + "displayNameHint": "聊天页切换对端分组时显示此名称,须唯一", + "icon": "图标", + "iconHint": "显示在对端专家卡片上(图标 + 显示名)", + "iconLabels": { + "cloudy": "云端", + "cloud": "云", + "cloud-cog": "云服务", + "globe": "公网", + "home": "本机", + "laptop": "工作站", + "monitor": "桌面", + "server": "机房", + "radio-tower": "边缘节点", + "wifi": "近端网络", + "satellite": "对端接入", + "map-pin": "现场" + }, + "notes": "备注信息", + "notesPlaceholder": "可选,仅自己可见的说明", + "peerUrl": "对端地址", + "peerUrlPlaceholder": "https://octop.example.com", + "peerUrlRequired": "请输入对端 Octop 地址", + "username": "用户名", + "usernameRequired": "请输入对端用户名", + "password": "密码", + "passwordRequired": "请输入对端密码", + "status": { + "connected": "已连接", + "disconnected": "未连接", + "offline": "离线", + "connecting": "连接中", + "error": "错误" + }, + "autoReconnect": "自动重连", + "autoReconnectHint": "断开后自动拨号;连续失败多次后会关闭并写入错误信息", + "viewCard": "卡片", + "viewTable": "表格", + "totalConnections": "共 {{count}} 条连接", + "colStatus": "状态", + "colAgents": "专家" + }, "captcha": { "title": "登录验证码", "description": "可选的密码登录服务端验证码。滑块仅在前端生效;强校验提供商会向厂商校验。", @@ -3315,6 +3582,7 @@ "stageStarting": "启动中...", "stageDownloading": "下载中...", "stageInstalling": "安装中...", + "mirrorErrorsTitle": "各镜像详情", "manualUpgradeHint": "也可手动运行:octop update --verbose", "restartServiceBtn": "重启服务", "restarting": "正在重启...", @@ -3441,7 +3709,20 @@ "quietHoursEnd": "免打扰结束", "saveSuccess": "设置已保存", "saveFailed": "保存失败,请稍后再试", - "loadFailed": "主动提醒设置加载失败" + "loadFailed": "主动提醒设置加载失败", + "activeHoursEnd": "关心时段 · 结束", + "activeHoursEndRequired": "请选择结束时间", + "activeHoursStart": "关心时段 · 开始", + "activeHoursStartRequired": "请选择开始时间", + "episodeBannerText": "Octop 会从你的情绪日记中挑选合适的时机,主动发来一句关心。你可以在「情绪日记」中查看哪些记录会被参考。", + "intervalOrderError": "最短间隔不能大于最长间隔", + "maxIntervalHours": "最长间隔(小时)", + "maxIntervalRequired": "请输入最大间隔", + "minIntervalHours": "最短间隔(小时)", + "minIntervalMin": "最小间隔不能小于 1 小时", + "minIntervalRequired": "请输入最小间隔", + "timeRangeError": "关心时段的开始时间必须早于结束时间", + "viewEpisodes": "去看情绪日记 →" }, "chatWelcome": { "greeting": "嗨!你专属的智能伙伴来啦~", @@ -3499,7 +3780,12 @@ "quickPrompt12Text": "请帮我优化以下简历内容,使其更专业、更有吸引力,并突出核心优势:\n\n" }, "upload": { - "fileTooltip": "上传附件", + "tooltip": "上传文件或文件夹。也可拖到输入框。", + "files": "文件", + "folder": "文件夹", + "dropHint": "松开以上传文件或文件夹", + "emptyDrop": "没有可上传的文件", + "tooMany": "一次最多上传 {{max}} 个文件,其余已跳过", "unsupportedType": "不支持的文件类型:{{type}}", "tooLarge": "文件过大(最大 {{maxMb}}MB):{{name}}", "failed": "上传失败" @@ -3518,14 +3804,15 @@ "filesFailed": "无法搜索工作区文件" }, "modelConfig": { - "promptTitle": "需要配置大语言模型", - "promptMessage": "聊天功能需要大语言模型才能运行。未配置模型时,对话和消息无法发送到后端进行处理和保存,刷新页面后将会丢失。是否立即配置模型?", - "configureButton": "配置模型", + "promptTitle": "还没有可用模型", + "promptMessage": "请先到「管理 → 模型」添加提供商并启用至少一个模型,才能开始对话。", + "promptMessageNoPermission": "当前没有可用模型,请联系管理员配置后再开始对话。", + "configureButton": "去配置模型", "skipButton": "跳过", "chatDisabledTitle": "聊天已禁用", "chatDisabledMessage": "聊天功能已禁用,因为未配置模型。请配置模型以启用聊天功能。", "configureNow": "配置模型", - "modelNotConfigured": "请先在设置中配置模型后再使用聊天功能" + "modelNotConfigured": "请先在「管理 → 模型」配置模型后再使用聊天功能" }, "header": { "healthLabel": "健康状态", @@ -3556,7 +3843,8 @@ }, "language": { "title": "语言", - "description": "选择控制台的显示语言。" + "description": "选择控制台的显示语言。", + "saveFailed": "保存语言偏好失败" }, "theme": { "title": "外观主题", @@ -3605,6 +3893,8 @@ "role": "角色", "roleAdmin": "管理员", "roleUser": "普通用户", + "avatar": "头像", + "avatarHint": "选择一个默认图标,或上传自己的图片。", "displayName": "显示名称", "displayNamePlaceholder": "您的显示名称", "displayNameHint": "设置在界面中展示的名称。", @@ -3783,7 +4073,16 @@ "reloadHint": "保存后专家会自动重载,当前对话不会被删除。", "loadFailed": "记忆设置加载失败", "saved": "已保存,专家将自动重载", - "saveFailed": "保存失败" + "saveFailed": "保存失败", + "advancedTitle": "提炼调用参数", + "advancedDescription": "限制单次记忆提炼的生成量和等待时间,0 表示自动。与上方的提炼时机相互独立。", + "maxTokensLabel": "单次提炼最多生成", + "maxTokensSuffix": "token(0 为自动)", + "lightTimeoutLabel": "单次提炼等待上限", + "lightTimeoutSuffix": "秒(0 为自动,默认 120 秒;最小 30 秒)", + "heavyTimeoutLabel": "批量整理等待上限", + "heavyTimeoutSuffix": "秒(0 为自动,默认 300 秒)", + "timeoutVsIdleNote": "“对话空闲 X 分钟后提炼”只决定何时开始提炼;这里的等待上限决定单次提炼最多等多久。使用深度思考的本地模型时,建议同时设置输出预算和等待上限,避免提炼一直超时。" }, "overview": { "dashboardTitle": "记忆概览", @@ -3817,9 +4116,15 @@ "indexedEntitiesHint": "可在记忆库中按主题展开查看", "profileEyebrow": "用户画像", "heroTitle": "用户画像", - "profileSubtitle": "基于已有记忆整理的偏好、当前重点、关键事实与高频人事物。", + "profileSubtitle": "AI 当前对你的结构化理解,只展示高价值结论,并非完整记忆库。", "memories": "条记忆", - "entitiesShort": "个主题" + "entitiesShort": "个人物/事物", + "atomsDelta7dShort": "条近7天新增", + "episodesShort": "段对话片段", + "reviewAction": "整理画像", + "rowGoLibrary": "去记忆库 →", + "rowHintToLibrary": "如有误,可在记忆库中弃用", + "viewAll": "查看全部" }, "terminal": { "aboutMe": "关于你", @@ -3844,7 +4149,7 @@ "conflict": "可能冲突" }, "entityType": { - "person": "人", + "person": "人物", "place": "地点", "project": "项目", "tool": "工具", @@ -3855,9 +4160,9 @@ }, "importance": { "title": "重要程度", - "high": "高", - "medium": "中", - "low": "低" + "high": "非常重要", + "medium": "重要", + "low": "一般" }, "list": { "kind": "类型", @@ -4002,7 +4307,9 @@ "deleteModelCacheTitle": "删除模型缓存?", "deleteModelCacheDesc": "确定删除 {{model}} 的本地缓存吗?删除后需要重新下载才能再次启用向量检索。", "deleteModelCacheSuccess": "模型缓存已删除", - "deleteModelCacheFailed": "删除失败:当前模型可能正在激活中。请先切换/保存/重启后再删除。" + "deleteModelCacheFailed": "删除失败:当前模型可能正在激活中。请先切换/保存/重启后再删除。", + "done": "已完成", + "selectModel": "选择模型" }, "migrate": { "buttonLabel": "迁移记忆", @@ -4058,12 +4365,23 @@ "emptyTitle": "记忆还在提炼中", "emptyNoRaw": "还没有记忆。开始对话后,系统会自动捕获素材并提炼记忆。", "emptyWithRaw": "已捕获 {{n}} 条对话记忆。记忆提炼会在会话空闲后自动运行,首批记忆通常在几轮对话后出现。", - "emptyPendingSuffix": "另有 {{n}} 条候选记忆待确认,请前往「记忆沉淀」查看。" + "emptyPendingSuffix": "另有 {{n}} 条候选记忆待确认,请前往「记忆沉淀」查看。", + "lastExtract": "上次整理 {{time}}" + }, + "confidence": { + "high": "高置信", + "low": "低置信", + "medium": "中置信" + }, + "time": { + "daysAgo": "{{n}} 天前", + "today": "今天", + "yesterday": "昨天" } }, "personalization": { "title": "个性化", - "description": "配置当前智能体的技能、工具、插件、子智能体、通道、人格与记忆。", + "description": "配置当前专家的技能、工具、插件、子智能体、通道、人格与记忆", "tabs": { "skills": "技能", "tools": "工具", @@ -4172,7 +4490,7 @@ }, "workbench": { "title": "工作台", - "description": "终端与浏览器合一,实时查看并接管专家的执行面", + "description": "查看并接管专家的终端与浏览器,实时跟进执行过程", "tabs": { "terminal": "终端", "browser": "浏览器" @@ -4180,7 +4498,7 @@ }, "remoteDesktopHub": { "title": "远程桌面", - "description": "在浏览器中操控服务器桌面或已连接的手机", + "description": "在浏览器中操控服务器桌面或已连接的手机,远程完成操作", "tabs": { "desktop": "服务器", "phone": "手机" @@ -4272,6 +4590,7 @@ "idleHint": "实时查看专家的浏览器窗口,也可以提前登录网站、管理书签等。关闭此页面后,登录状态仍会保留,专家可继续复用。", "goBack": "后退", "goForward": "前进", + "reload": "刷新", "go": "访问", "fullscreen": "切换全屏", "fullscreenFailed": "无法进入全屏", @@ -4301,6 +4620,8 @@ "uninstallSuccess": "内置浏览器已卸载", "uninstallFailed": "卸载失败", "playwrightMissing": "playwright 包未安装,请先安装 octop[browser] extras。", + "playwrightOptional": "playwright 包未安装(可选)", + "playwrightOptionalDesc": "octop-browser (CDP) 已就绪,浏览器功能可用。如需 Playwright 备用模式,可安装 octop[browser] extras。", "installLog": "安装日志", "installRetry": "重新安装", "envReady": "可以帮你打开网页、填写表单和截图了", @@ -4359,7 +4680,23 @@ "recordingPlaceholder": "录制中...输入\"结束\"停止", "confirmPlaceholder": "输入\"确认\"应用技能,或继续对话", "placeholderWithSkills": "输入技能名回放,或继续对话", - "inputPlaceholder": "问问网页,或继续操作浏览器..." + "inputPlaceholder": "问问网页,或继续操作浏览器...", + "objectiveSet": "✅ 任务目标已设定:**{{objective}}**\n\n接下来请描述你想让AI助手在浏览器中执行的具体操作。\n\n例如:\"打开B站、小红书和百度,搜索今天天气\"。\n\n描述完成后AI会自动执行操作;操作结束后输入\"结束\"即可停止录制。", + "previewMessage": "✅ 录制完成!已生成 {{steps}} 个回放步骤。\n\n📝 **技能「{{name}}」脚本预览:**\n\n{{preview}}{{truncation}}\n\n回复\"确认\"即可应用此技能。\n\n应用后,只需输入 **\"{{name}}\"** 即可一键触发回放相同操作流程。", + "previewNoScript": "✅ 录制完成!已生成 {{steps}} 个回放步骤。\n\n⚠️ 技能脚本生成失败,请稍后重试。", + "skillAppliedMessage": "🎉 技能 **「{{name}}」** 已成功应用!\n\n之后只需输入 **\"{{name}}\"** 即可一键触发回放相同的浏览器操作流程。", + "noRecordingFound": "⚠️ 无法找到技能「{{name}}」的录制记录,回放失败。", + "recordingNotFound": "找不到录制记录", + "replayDone": "🎬 Browser-Skill「{{name}}」回放完成!", + "stepLine": "步骤 {{id}} ({{kind}}): {{error}}", + "stepFailed": "执行失败", + "replayFailedDetail": "⚠️ 技能「{{name}}」回放失败:\n{{detail}}", + "replayError": "⚠️ 技能回放出错:{{error}}", + "unknownError": "未知错误", + "replayComplete": "🔄 技能回放完成!所有步骤已成功执行。", + "replayFailedSimple": "⚠️ 技能回放失败:{{error}}", + "replayingSkill": "🔄 正在使用技能「{{text}}」进行回放...", + "recordingHint": "💡 录制还在进行中。如果操作已完成,输入\"结束\"即可停止录制并生成技能脚本。" } }, "remoteDesktop": { @@ -4450,7 +4787,8 @@ "mobileHint": "点击画面可操控远程桌面;快捷按钮会发送常用快捷键。", "adminOnlyTitle": "需要管理员权限", "adminOnlyDesc": "远程桌面可操控主机操作系统,仅管理员可用。如需使用,请联系管理员。", - "adminOnlyBack": "返回对话" + "adminOnlyBack": "返回对话", + "streamError": "远程桌面连接失败" }, "browserWorkspace": { "title": "浏览器", @@ -4555,7 +4893,9 @@ "vectorIndexWarning": "更换向量化模型、切换本地/第三方方式,或修改「向量维度」后,若与当前记忆向量库维度不一致,系统会重建 Qdrant 集合,原有向量会被清空,并需从记忆文件重新建立索引。", "loadError": "加载配置失败", "applySuccess": "配置已应用", - "saveAndApply": "保存并应用" + "saveAndApply": "保存并应用", + "done": "已完成", + "selectModel": "选择模型" }, "admin": { "sharedModels": { @@ -4693,7 +5033,7 @@ }, "storage": { "pageTitle": "存储管理", - "pageSubtitle": "管理存储后端(本地、对象存储、沙箱、数据库),供专家使用", + "pageSubtitle": "管理本地、对象存储、沙箱与数据库等存储后端,供专家使用", "myStorage": "我的存储", "supportedTypes": "支持类型", "addBackend": "添加存储后端", @@ -4837,13 +5177,32 @@ "probing": "探测中…", "testSuccess": "存储后端可用", "probe_roundtrip_ok": "写入、读取与删除均成功", + "probe_no_such_bucket": "存储桶不存在,请检查 Bucket 名称", + "probe_no_such_key": "对象不存在", + "probe_access_denied": "没有访问权限,请检查密钥和存储桶权限", + "probe_invalid_credentials": "访问密钥无效,请检查 Access Key / Secret Key", + "probe_invalid_bucket": "Bucket 名称不合法", + "probe_invalid_endpoint": "Endpoint 或 Region 与存储桶不匹配", + "probe_timeout": "连接超时,请检查 Endpoint 或网络", + "probe_connection_failed": "无法连接到存储服务,请检查 Endpoint 和网络", + "probe_database_missing": "指定的数据库不存在", + "probe_content_mismatch": "写入后读回的内容不一致", + "probe_write_failed": "写入探测失败:{{detail}}", + "probe_read_failed": "读取探测失败:{{detail}}", + "probe_delete_failed": "删除探测失败:{{detail}}", + "probe_config_incomplete": "配置不完整", + "probe_s3_incompatible": "该 S3 兼容存储不支持当前请求方式。请检查 Endpoint,或在高级配置中尝试 path-style", + "probe_signature_mismatch": "存储拒绝了请求签名。请检查 Endpoint,或在高级配置中尝试 path-style", "testFailed": "检测失败", "browse": "浏览", "browseDisabled": "当前 Docker 沙箱不可浏览(仅 fixed 作用域默认开启;可用探测验证连通性)", "browseTitle": "浏览 — {{name}}", "browseEmpty": "目录为空或无法列出", "browseFailed": "浏览失败", - "browseLoadFailed": "加载子目录失败" + "browseLoadFailed": "加载子目录失败", + "browseDownloadOnly": "该文件不支持预览,是否下载?", + "browse_not_text": "该文件不是 UTF-8 文本,请下载查看", + "browse_too_large": "文件过大,无法在浏览器中预览或下载" }, "adminAgents": { "total": "共 {{count}} 个智能体", @@ -4889,13 +5248,22 @@ } }, "agentSelector": { - "label": "当前专家" + "label": "专家", + "localGroup": "本机", + "disconnected": "已断开", + "expertsGroup": "专家", + "teamsGroup": "团队", + "remoteKindGroup": "{{name}}·{{kind}}", + "more": "更多", + "teamNeedsExpert": "当前是团队「{{name}}」,此页请选择专家", + "expertsRequired": "此页需要专家" }, "login": { "title": "登录", "subtitle": "懂你、帮你、陪你成长的智能伙伴", "username": "用户名或邮箱", "password": "密码", + "remember": "记住登录状态", "submit": "登录", "forgotPassword": "忘记密码?", "forgotPasswordTitle": "重置密码", @@ -4919,6 +5287,7 @@ "or": "或", "oidcWith": "使用 {{name}} 继续", "oidcStartFailed": "无法启动单点登录", + "ldapHint": "使用{{name}}账号登录", "oidcError": { "denied": "已取消单点登录。", "state": "单点登录会话已过期,请重试。", @@ -5002,7 +5371,7 @@ "expired": "已过期", "revoked": "已撤销" }, - "searchPlaceholder": "搜索用户名、显示名称或邮箱", + "searchPlaceholder": "搜索用户名、显示名称、邮箱或角色名称", "loadFailed": "加载失败", "createSuccess": "用户「{{username}}」已创建", "createFailed": "创建失败", @@ -5029,7 +5398,8 @@ "colEmail": "邮箱", "colAuth": "登录方式", "colCreatedAt": "创建时间", - "colRole": "角色", + "colRole": "账号类型", + "colRoleName": "角色名称", "colLoginLock": "登录锁定", "loginLockActive": "锁定中 · 剩余 {{minutes}} 分钟", "loginLockNone": "—", @@ -5038,18 +5408,53 @@ "noAgents": "该用户暂无专家", "colActions": "操作", "roleAdmin": "管理员", - "roleUser": "普通用户", + "roleUser": "成员", "roleAdminHint": "管理系统设置、用户权限与全部数据", "roleUserHint": "使用自有专家与工作区,无管理后台权限", - "roleLegendTitle": "角色说明", - "roleLegend": "管理员可管理系统设置、用户与全部数据;普通用户仅使用自有专家与工作区,不能管理其他用户或修改全局配置。", + "roleLegendTitle": "账号类型", + "roleLegend": "管理员可管理系统设置、用户与全部数据;成员仅使用自有专家与工作区,不能管理其他用户或修改全局配置。自定义角色在账号类型列显示模板名称。", "formUsername": "用户名", "formPassword": "密码", "formPasswordConfirm": "确认密码", "formDisplayName": "显示名称(可选)", "formEmail": "邮箱(可选)", "formEmailInvalid": "请输入有效的邮箱地址", - "formRole": "角色", + "formRole": "账号类型", + "formUserRole": "角色", + "formUserRoleHint": "可选。选择后只填入权限和资源策略,保存前仍可改写。账号类型需要单独设置。不选角色时,当前权限和策略保持不变。", + "avatarSamples": "示例图标", + "avatarDefault": "默认图标", + "avatarIconUser": "成员", + "avatarIconAdmin": "管理员", + "avatarIconStaff": "普通用户", + "avatarIconDoctor": "医生", + "avatarIconNurse": "护士", + "avatarIconStudent": "学生", + "avatarIconTeacher": "老师", + "avatarIconSupport": "客服", + "avatarIconOps": "运营", + "avatarIconFinance": "财务", + "avatarIconBoss": "老板", + "avatarIconMember": "成员", + "avatarIconAward": "皇冠", + "avatarIconTerminal": "代码", + "avatarIconPen": "羽毛笔", + "avatarIconChart": "饼图", + "avatarIconHeadset": "喇叭", + "avatarIconWrench": "齿轮", + "avatarIconScale": "机构", + "noEmail": "未填写邮箱", + "formUserRolePlaceholder": "不使用角色", + "formUserRoleStale": "当前记录的角色名称:{{name}}。该模板已删除或改名,现有权限保持不变,直到你改选其他角色。", + "formRoleDiverged": "当前账号的权限或资源策略与该角色默认值不一致。改选角色会覆盖表单里的这些值。", + "seedRoleAdmin": "管理员", + "seedRoleUser": "用户", + "seedRoleAdminDescription": "拥有完整权限且不受资源策略限制。", + "seedRoleUserDescription": "适用于普通用户,默认具备基础设置权限。", + "roleNameChanged": "已改写", + "roleNameMissing": "角色已失效", + "roleApplyConfirm": "用所选角色覆盖当前设置?", + "roleApplyConfirmHint": "权限和资源策略会改成该角色的默认值。账号类型保持不变,保存前仍可再改。", "viewTable": "表格", "viewCard": "卡片", "you": "你", @@ -5059,11 +5464,44 @@ "statusDisabled": "已禁用", "ssoBadge": "单点登录", "passwordBadge": "密码", - "tabLocal": "内置用户", + "tabLocal": "用户管理", + "tabRoles": "角色管理", + "roleLoadFailed": "加载角色失败", + "roleCreate": "新建角色", + "roleCreateTitle": "新建角色", + "roleEditTitle": "编辑角色 {{name}}", + "roleCreateSuccess": "角色「{{name}}」已创建", + "roleCreateFailed": "创建角色失败", + "roleUpdateSuccess": "角色已更新", + "roleUpdateFailed": "更新角色失败", + "roleDeleteSuccess": "角色已删除", + "roleDeleteFailed": "删除角色失败", + "roleDeleteConfirm": "删除角色「{{name}}」?已创建的用户仍保留原来的权限、策略和角色名称。", + "roleDeleteBuiltin": "内置角色不能删除", + "roleEmpty": "暂无角色", + "roleBuiltin": "内置", + "roleAdminLocked": "管理员角色拥有全部权限,且不启用资源限制。可以修改名称,不能删除,也不能改权限或策略。", + "roleTemplateHint": "角色只提供创建、编辑和邀请时的默认值。修改角色不会改写已经创建的用户。", + "roleCustomHint": "自定义角色只提供权限和资源策略。账号类型在用户上单独设置。", + "roleColName": "角色名称", + "roleColId": "角色 ID", + "roleDescription": "角色描述", + "roleDescriptionPlaceholder": "说明该角色适用的人员或职责", + "roleDescriptionEmpty": "暂无描述", + "roleColAccount": "默认账号类型", + "roleColPolicy": "资源策略", + "rolePolicyUnlimited": "未启用", + "rolePolicyLimited": "已启用", + "roleNameRequired": "请输入角色名称", + "inviteRole": "角色", + "inviteRoleRequired": "请选择角色", + "inviteRoleHint": "只记录角色名称。对方注册时读取该角色当前的权限和资源策略。角色若已删除或改名,邀请将无法完成注册。", + "inviteRoleLegacy": "未指定角色", "tabFeishu": "飞书登录", "tabWecom": "企微登录", "tabDingtalk": "钉钉登录", "tabOidc": "通用OIDC", + "tabLdap": "LDAP", "tabSso": "通用OIDC", "colPermissions": "权限", "permAll": "全部(管理员)", @@ -5082,9 +5520,46 @@ "policyWorkspaceRootRequired": "请选择允许的存储根目录", "policyWorkspaceRootContainerHint": "容器环境下不可设置——容器文件系统本身已是隔离边界,创建专家时默认使用 `/`。", "policyTokenQuota": "Token 使用上限", - "policyTokenQuotaHint": "该用户所有专家累计的输入+输出 token。关闭表示不限制。", + "policyTokenQuotaHint": "该用户所有专家累计的输入+输出 token。关闭表示不限制。常用单位为百万(1M = 100 万 tokens)。", "policyTokenQuotaRequired": "请输入 token 上限", - "modalEditTitle": "编辑用户 {{username}}" + "policyTokenQuotaPlaceholder": "例如 10000000(约 10 百万)", + "policyTokenQuotaPresets": "快速填写", + "policyTokenQuotaPreset": "{{millions}} 百万", + "policyTokenQuotaPreview": "约 {{millions}} 百万 tokens({{exact}})", + "policyMaxAgents": "专家数量上限", + "policyMaxAgentsHint": "该用户可创建的专家数量(不含团队)。关闭表示不限制。", + "policyMaxAgentsRequired": "请输入专家数量上限", + "policyMaxAgentsPlaceholder": "例如 5", + "modalEditTitle": "编辑用户 {{username}}", + "batchSelected": "已选 {{count}} 人", + "batchSelectAll": "全选", + "batchSelectUser": "选择用户 {{username}}", + "batchClear": "取消选择", + "batchEnable": "启用", + "batchDisable": "禁用", + "batchSetToken": "设置 Token 上限", + "batchSetTokenTitle": "为 {{count}} 个用户设置 Token 上限", + "batchSetTokenHint": "将对所选用户统一设置 Token 使用上限;关闭开关表示不限制。", + "batchSetMaxAgents": "设置专家上限", + "batchSetMaxAgentsTitle": "为 {{count}} 个用户设置专家数量上限", + "batchSetMaxAgentsHint": "将对所选用户统一设置可创建的专家数量;关闭开关表示不限制。", + "batchEnableConfirm": "启用选中的 {{count}} 个用户?", + "batchEnableHint": "启用后这些用户可以再次登录并使用系统。", + "batchDisableConfirm": "禁用选中的 {{count}} 个用户?", + "batchDisableHint": "禁用后这些用户将无法登录。当前登录用户会被自动跳过。", + "batchDeleteConfirm": "删除选中的 {{count}} 个用户?", + "batchDeleteHint": "当前登录用户会被自动跳过。此操作不可撤销。", + "batchSuccess": "已对 {{count}} 个用户完成「{{action}}」", + "batchPartial": "「{{action}}」完成:成功 {{succeeded}},失败 {{failed}}", + "batchAllFailed": "「{{action}}」全部失败({{failed}})", + "batchFailed": "批量操作失败", + "batchAction": { + "enable": "启用", + "disable": "禁用", + "delete": "删除", + "set_token_quota": "设置 Token 上限", + "set_max_agents": "设置专家上限" + } }, "adminSso": { "panelTitle": "单点登录", @@ -5205,7 +5680,102 @@ "oauthSectionCredentialsHint": "来自开放平台应用注册的 App ID 与 App Secret。", "oauthClientSecretHint": "必填。保存后可留空以保留现有密钥。", "oauthRedirectDocs": "在飞书、企业微信、钉钉等开放平台中,将此 URL 登记为授权回调地址。", - "oauthTestHint": "更改 App ID 或密钥后,请先保存再测试。" + "oauthTestHint": "更改 App ID 或密钥后,请先保存再测试。", + "ldapTitle": "LDAP 目录", + "ldapDesc": "接入 Active Directory、OpenLDAP 等目录服务登录。用户在现有登录表单中直接输入域账号与密码。", + "ldap": { + "kind": "LDAP", + "enableLabel": "启用 LDAP 登录", + "displayName": "提供商显示名称", + "displayNamePlaceholder": "显示在登录页面上", + "displayNameRequired": "请输入显示名称", + "sectionServer": "目录服务器", + "sectionServerHint": "Octop 连接此服务器完成用户认证。", + "serverUrl": "服务器地址", + "serverUrlPlaceholder": "ldap://ldap.example.com:389", + "serverUrlHint": "ldaps:// 表示 636 端口的 TLS;ldap:// 可配合 StartTLS 升级为加密连接。", + "serverUrlRequired": "请输入目录服务器地址", + "serverUrlInvalid": "请输入有效的 ldap:// 或 ldaps:// 地址", + "startTls": "StartTLS", + "startTlsHint": "绑定前将明文 ldap:// 连接升级为 TLS。", + "verifyTls": "校验 TLS 证书", + "verifyTlsHint": "仅在自签名证书的测试服务器上关闭。", + "sectionBind": "服务账号", + "sectionBindHint": "Octop 用于检索目录的账号。", + "bindDn": "绑定 DN", + "bindDnPlaceholder": "cn=svc-octop,ou=services,dc=example,dc=com", + "bindDnHint": "留空表示匿名检索。", + "bindPassword": "绑定密码", + "bindPasswordHint": "设置了绑定 DN 时必填。", + "bindPasswordConfigured": "已配置绑定密码;留空将保留现有密码。", + "bindPasswordConfiguredTag": "已配置", + "bindPasswordPlaceholder": "留空以保留现有密码", + "sectionSearch": "用户检索", + "sectionSearchHint": "Octop 依据登录页输入的用户名查找对应的目录账号。", + "userBaseDn": "用户基准 DN", + "userBaseDnPlaceholder": "ou=people,dc=example,dc=com", + "userBaseDnRequired": "请输入用户检索起始 DN", + "userFilter": "用户过滤器", + "userFilterPlaceholder": "(uid={username})", + "userFilterHint": "检索前会把字面量 {username} 占位符替换为登录页输入的用户名。", + "userFilterRequired": "请输入用户过滤器", + "userFilterPlaceholderRequired": "过滤器必须包含字面量 {username} 占位符", + "sectionAttributes": "属性映射", + "sectionAttributesHint": "目录属性与 Octop 账号字段的对应关系。", + "usernameAttribute": "用户名字段", + "usernameAttributeRequired": "请输入用户名字段", + "emailAttribute": "邮箱字段", + "displayNameAttribute": "显示名称字段", + "groupAttribute": "所属组字段", + "sectionAccess": "权限与开通", + "sectionAccessHint": "哪些用户成为管理员,以及新账号如何创建。", + "adminGroups": "管理员组", + "adminGroupsHint": "这些组的成员在 Octop 中获得管理员角色 —— 仅首次创建账号时判定,之后改目录组不会重新套用到已有账号。建议填完整 DN;只填 CN 会匹配到任意 OU 下的同名组。", + "adminGroupsPlaceholder": "octop-admins, cn=it,ou=groups,dc=example,dc=com", + "autoProvision": "首次登录时自动创建账号", + "autoProvisionHint": "关闭后,仅已存在的 Octop 用户可通过目录登录。", + "timeout": "连接超时(秒)", + "timeoutHint": "等待目录响应的最长时间,超时即视为失败。", + "timeoutRequired": "请输入超时秒数", + "guideStep1": "填写服务器与服务账号", + "guideStep2": "设置检索基准与 {username} 过滤器", + "guideStep3": "将组映射为管理员角色", + "guideStep4": "保存并测试连接", + "guideStep5": "启用 LDAP 登录", + "save": "保存", + "discard": "放弃更改", + "saved": "LDAP 设置已保存", + "saveFailed": "无法保存 LDAP 设置", + "loadFailed": "无法加载 LDAP 设置", + "testConnection": "测试连接", + "testSuccess": "目录连接成功", + "testFailed": "目录连接测试失败", + "testHint": "更改服务器地址或绑定账号后,请先保存再测试。", + "testNeedsSave": "请先保存更改再测试。", + "unsavedChanges": "有未保存的更改。", + "subjectAttribute": "身份主键属性", + "subjectAttributeHint": "目录侧稳定不变的主键:entryUUID(OpenLDAP)或 objectGUID(Active Directory)。留空则退化为使用条目 DN。修改后已有账号关联将失效。", + "groupAttributeHint": "开启组检索后该项不生效。", + "groupSearch": "通过检索组判定成员", + "groupSearchHint": "目录未启用 memberOf overlay 时开启(例如默认配置的 OpenLDAP)。", + "groupSearchBase": "组检索基准 DN", + "groupSearchBaseHint": "存放组的基准 DN。留空则沿用用户基准 DN。", + "groupMemberAttribute": "组成员属性", + "groupMemberAttributeHint": "组条目上记录成员的属性:member 或 uniqueMember。", + "allowedGroups": "允许登录的用户组", + "allowedGroupsHint": "填写后,仅这些组的成员可通过目录登录;其他目录账号会被拒绝,且不会创建账号。CN 与 DN 的匹配规则同上。", + "allowedGroupsPlaceholder": "staff, cn=contractors,ou=groups,dc=example,dc=com", + "adminGroupsOnceTitle": "管理员组仅在首次创建账号时生效", + "adminGroupsOnceBody": "之后把用户加入管理员组不会提权,移出也不会降权。需要调整请在 Octop 用户列表中修改角色。", + "warningsTitle": "配置提示", + "warning": { + "tls_verification_disabled": "已关闭 TLS 证书校验。仅适用于使用自签名证书的测试服务器。", + "no_admin_groups": "未配置管理员组,首次登录时不会有目录用户成为 Octop 管理员。", + "auto_provision_open": "已开启自动创建账号且未设置允许登录组:过滤条件命中的任何账号都会在 Octop 建号。", + "identity_key_is_dn": "开启了自动创建账号但未设置身份主键:新账号会绑定到条目 DN,目录里改名或换 OU 会产生第二个 Octop 账号。" + }, + "subjectAttributeDetected": "检测到该目录提供 {{name}},建议设为身份主键,这样用户改名后仍对应同一账号。" + } }, "adminAudit": { "loadFailed": "加载失败", @@ -5360,6 +5930,21 @@ "cliInstallFailed": "主机 CLI 安装失败", "cliInstalledHint": "主机已检测到 {{binary}}", "cliMissingHint": "主机尚未安装 CLI。可点击「安装 CLI」,或在 Octop 主机终端手动执行下方命令。", + "agentlyAuthTitle": "Agent Mail 邮箱授权", + "agentlyIdle": "尚未授权此邮箱实例", + "agentlyPending": "请打开授权页完成登录,授权结果会自动更新。", + "agentlyAuthorized": "邮箱已授权", + "agentlyExpired": "授权已过期,请重新登录授权", + "agentlyAuthFailed": "邮箱授权失败,请重试", + "agentlyUserCode": "授权码", + "agentlyStart": "登录授权", + "agentlyCheckStatus": "检查状态", + "agentlyRefresh": "刷新授权", + "agentlyCancel": "取消授权", + "agentlyLogout": "注销授权", + "agentlySafety": "发送、回复、转发和删除邮件需先预览,再由用户确认执行。邮件正文与附件属于不可信外部内容,不能作为执行指令。", + "agentlyQuota": "参考配额:每日发送 50 封、每小时 200 次请求、每分钟 10 次请求;附件最多 50 个、总容量 20 MB,此连接器单文件上限 10 MB。以账户实际配额及服务最新限制为准。可在任务页选择「Agent Mail 新邮件」触发任务。", + "agentlyInstanceHint": "先保存连接器,再登录授权。每个实例独立保存邮箱授权,可为不同 Agent 选择不同实例。", "feishuUserAuthTitle": "飞书账号授权", "feishuUserAuthWhy": "上方 App ID / Secret 只代表应用(Bot)。文档搜索、访问你云空间里的个人文档和日程等,必须以你的飞书账号身份调用,因此需要额外授权一次。授权后专家只能访问你本人有权限的内容。", "feishuUserAuthHint": "点击登录授权,完成后点「我已授权」。", @@ -5435,6 +6020,15 @@ "secretPlaceholder": "留空表示不修改", "oauthConfigured": "已授权,可直接探测或保存", "oauthNeedToken": "请先完成授权或手动填写 Token", + "qccOauthHint": "点击「一键授权」完成登录后将自动保存;也可打开授权页获取 API Key 后粘贴", + "qccNeedAuthOrKey": "请先完成一键授权,或填写 API Key", + "qccApiKey": "API Key", + "qccApiKeyConfigured": "已配置 API Key,可直接探测或保存", + "qccApiKeyDoc": "获取企查查 API Key", + "qccApiKeyHint": "打开上方授权页获取 Key 后粘贴到这里", + "qccApiKeyOnlyHint": "当前环境无法完成 OAuth 回调。请打开授权页获取 API Key,粘贴后探测并保存。", + "qccOpenKeyPage": "打开授权页", + "qccApiKeyPlaceholder": "粘贴企查查 API Key", "probe": "探测", "probeOk": "探测成功", "probeOkTools": "探测成功({{count}} 个工具)", @@ -5461,7 +6055,40 @@ "chatPickerSearch": "搜索连接器", "chatNone": "无", "chatEmptyHint": "请先在连接器页面添加连接", - "manageConnectors": "管理连接器" + "manageConnectors": "管理连接器", + "apiKey": "API Key", + "apiKeyDoc": "查看如何获取 API Key", + "apiKeyPlaceholder": "粘贴 API Key", + "authCodeDoc": "查看如何获取授权码", + "authCodePlaceholder": "粘贴授权码", + "authUrlFailed": "打开授权页失败", + "authUrlMissing": "无法获取授权页地址", + "deleteFailed": "删除失败", + "detectLocal": "检测本机服务", + "difyMcpUrlInvalid": "请粘贴 Dify 访问点提供的完整 MCP Server URL", + "difyPasteUrlRequired": "剪贴板中没有 MCP URL", + "difyStep1": "在 Dify 中发布应用或工作流", + "difyStep2": "在访问点启用 MCP 并复制完整 Server URL", + "disableSuccess": "已停用", + "enableSuccess": "已启用", + "getTokenAt": "前往获取 Token", + "guidedSetup": "快速接入", + "guidedStepProbe": "粘贴凭证后探测并保存", + "lexiangCompanyFrom": "企业标识 (company_from)", + "lexiangCompanyFromPlaceholder": "从乐享凭证页复制", + "manualTokenDoc": "手动获取 Token 文档", + "oauthTimedOut": "授权超时,请重试一键授权", + "openAuthorizePage": "打开授权页", + "pasteMcpUrl": "粘贴 MCP 地址", + "probeNeedSecretAfterConfigChange": "连接配置已修改,请重新填写密钥后再探测", + "smartPaste": "智能粘贴", + "toggleFailed": "更新失败", + "viewGuide": "查看获取说明", + "weknoraDetectFailed": "检测本机 WeKnora 失败", + "weknoraFound": "已检测到本机 WeKnora 并填入地址", + "weknoraNotFound": "未在 OCTOP 主机的 127.0.0.1:8080 检测到 WeKnora", + "weknoraStep1": "打开 WeKnora 并创建 API Key", + "weknoraStep2": "检测本机服务,或手动填写部署地址" }, "mbtiPage": { "pickAgent": "请先选择一个专家" @@ -5471,8 +6098,8 @@ "globalRunnersHint": "以下 Runner 配置对该账号下所有专家生效。", "toolEnabledHint": "acp_runner 工具需按专家单独开启,切换上方专家后为当前专家配置。", "toolEnabled": "启用向外委托(acp_runner)", - "outboundBlockedHint": "当前专家已启用沙箱环境,无法调用外部 Runner。通过 IDE / octop acp 接入不受影响。", - "outboundBlockedTooltip": "沙箱环境下不可用", + "outboundBlockedHint": "当前专家已启用沙箱环境,不可启用或编辑外部 Runner。启用向外委托(acp_runner)不受影响;通过 IDE / octop acp 接入也不受影响。", + "outboundBlockedTooltip": "沙箱环境下不可启用或编辑", "runnerUnavailable": "不可用", "builtin": "内置", "custom": "自定义", @@ -5656,24 +6283,25 @@ "label": "Token 用量", "description": "查看当前话题的 Token 用量" } - } + }, + "menuHint": "↑↓ 选择 · Enter 确认 · Esc 关闭" }, "pageShell": { "experts": { "title": "专家", - "subtitle": "创建与管理你的 AI 专家,也可从模板库挑选场景一键新建。" + "subtitle": "创建与管理你的 AI 专家,也可从模板一键新建" }, "tasks": { "title": "自动化", - "subtitle": "配置专家定期执行的计划任务" + "subtitle": "创建和管理专家定期执行的计划任务,也可立刻手动运行" }, "connectors": { "title": "连接器", - "subtitle": "账户级连接文档、网盘与邮箱;对话时按需勾选" + "subtitle": "连接文档、网盘与邮箱,并在对话中按需使用" }, "acp": { - "title": "ACP", - "subtitle": "配置外部编程专家 Runner(OpenCode、Claude Code 等)" + "title": "ACP 协作", + "subtitle": "配置外部编程专家 Runner,并按专家开启协作" }, "skills": { "title": "技能", @@ -5681,91 +6309,99 @@ }, "tools": { "title": "工具", - "subtitle": "启用或关闭当前智能体的内置工具与插件工具" + "subtitle": "启用或关闭当前专家的内置工具与插件工具,控制可用能力" }, "tokenUsage": { "title": "Token 统计", - "subtitle": "账户 Token 消耗总览与多维分析" + "subtitle": "查看账户 Token 消耗总览,并按渠道与会话做多维分析" }, "channels": { "title": "通道", - "subtitle": "IM 渠道与 Webhook 接入管理" + "subtitle": "管理 IM 渠道与 Webhook 接入,让专家在外部会话中回复" }, "mbti": { "title": "MBTI 人格", - "subtitle": "选一个 MBTI 人格类型,或花 3 分钟做个测试,让 TA 拥有独一无二的性格!" + "subtitle": "为当前专家选择或测试 MBTI 人格,设定说话风格与性格" }, "subagents": { "title": "子智能体", - "subtitle": "管理当前专家的子智能体,安装或编辑子智能体技能" + "subtitle": "管理当前专家的子智能体,安装或编辑它们的技能" }, "memory": { "title": "记忆", - "subtitle": "查看 Octop 的记忆内容,也可以顺手整理一下" + "subtitle": "查看并整理当前专家记下的记忆,也可处理待确认草稿" }, "workspace": { "title": "工作区", - "subtitle": "专家的本地文件系统工作空间" + "subtitle": "浏览、预览和管理专家工作区中的文件,也可编辑或下载" }, "models": { "title": "模型配置", - "subtitle": "配置对话模型、生成模型、语音模型与搜索引擎" + "subtitle": "配置对话、生成、语音与搜索所用的模型,并设置默认服务" }, "voice": { "title": "语音服务", - "subtitle": "配置语音识别与朗读服务" + "subtitle": "配置语音识别与朗读服务,并选择当前使用的模型" }, "adminUsers": { "title": "用户管理", - "subtitle": "管理内置账号与登录方式(通用OIDC、飞书、企微、钉钉)" + "subtitle": "管理内置账号与登录方式(通用 OIDC、飞书、企微、钉钉)" }, "adminSso": { "title": "通用OIDC", - "subtitle": "配置 OpenID Connect 身份提供商" + "subtitle": "配置 OpenID Connect 身份提供商,用于统一登录" }, "adminStorage": { "title": "存储管理", - "subtitle": "配置共享存储后端(COS 等)" + "subtitle": "配置可供专家使用的存储后端,如对象存储与沙箱" }, "adminAudit": { "title": "审计日志", - "subtitle": "系统操作与 API 访问记录" + "subtitle": "查看系统操作与 API 访问记录,便于排查与审计" }, "adminAgents": { "title": "智能体", - "subtitle": "查看并管理所有用户的智能体" + "subtitle": "查看并管理所有用户的智能体,支持跨用户检索与操作" }, "adminAdvanced": { "title": "应用设置", - "subtitle": "环境变量、备份、HTTPS 与更新" + "subtitle": "管理环境变量、备份、HTTPS 与系统更新" + }, + "bridge": { + "title": "云端协同", + "subtitle": "连接另一台 Octop,在本机浏览对端专家并跨实例对话" }, "security": { "title": "安全防护", - "subtitle": "工具审批、命令防护、文件访问与敏感信息防护" + "subtitle": "配置工具审批、命令防护、文件访问与敏感信息防护" }, "adminPlugins": { "title": "插件管理", - "subtitle": "安装并管理服务器插件;各专家的启用状态在「个性化 → 插件」中配置" + "subtitle": "安装并管理服务器插件,各专家可在个性化中单独启用" }, "adminUpdates": { "title": "应用更新", - "subtitle": "检查版本与系统升级" + "subtitle": "检查当前版本并一键升级系统,也可查看安装说明" }, "browser": { "title": "浏览器 AI+", - "subtitle": "基于 Chromium 的无头浏览器会话" + "subtitle": "查看并操控远端无头浏览器,实时接管专家的网页操作" }, "desktop": { "title": "远程桌面", - "subtitle": "查看并操控 Octop 主机操作系统桌面" + "subtitle": "查看并操控 Octop 主机操作系统桌面,远程完成桌面操作" }, "terminal": { "title": "终端 AI+", - "subtitle": "在浏览器中运行交互式 Shell 终端" + "subtitle": "在浏览器中运行交互式 Shell 终端,实时查看专家的命令执行" }, "mobile": { "title": "远程手机", - "subtitle": "查看并控制已连接的手机或模拟器" + "subtitle": "查看并控制已连接的手机或模拟器,远程操作屏幕" + }, + "adminSharedModels": { + "title": "共享模型", + "subtitle": "管理面向全体用户共享的模型配置,供各专家统一使用" } }, "remoteAndroid": { @@ -5913,24 +6549,68 @@ "pwa": { "updateReady": "新版本已就绪", "updateNow": "立即更新", - "later": "稍后更新" - }, - "pwaInstall": { - "iosTitle": "添加到主屏幕", - "iosDesc": "将 Octop 安装为 App,随时一键打开。", + "later": "稍后更新", + "addToHomeScreen": "添加到主屏幕", + "close": "关闭", + "installAsAppDesc": "将 Octop 安装为 App,随时一键打开。", "iosStep1Prefix": "点击底部工具栏的", "iosStep1Suffix": "分享按钮", "iosStep2": "向下滚动,点击「添加到主屏幕」", "iosStep3": "点击右上角「添加」完成安装", - "desktopTitle": "安装为桌面应用", - "desktopDesc": "浏览器尚未准备好一键安装。请按以下方式操作,或刷新页面后再点 Header 中的安装按钮。", + "installDesktopApp": "安装为桌面应用", + "desktopGuideDesc": "浏览器尚未准备好一键安装。请按以下方式操作,或刷新页面后再点 Header 中的安装按钮。", "desktopStep1Prefix": "查看地址栏右侧的", "desktopStep1Suffix": "安装图标并点击", - "desktopStep2Prefix": "或打开浏览器菜单,选择「", - "browserEdge": "应用", - "browserInstall": "安装", - "desktopStep2Suffix": " Octop」/ Install Octop", + "desktopStep2": "或打开浏览器菜单,选择「{{action}} Octop」/ Install Octop", "desktopStep3": "确认安装后,可从桌面或程序坞一键打开", - "desktopHint": "通过局域网 IP 访问时需使用 HTTPS,否则浏览器不会提供安装选项。" + "desktopHint": "通过局域网 IP 访问时需使用 HTTPS,否则浏览器不会提供安装选项。", + "menuActionApply": "应用", + "menuActionInstall": "安装", + "installApp": "安装应用", + "install": "安装" + }, + "browser": { + "recordReplay": { + "replay": "回放最近一次浏览器录制", + "replayFailed": "回放失败", + "replayPassed": "回放完成", + "start": "开始浏览器录制", + "startFailed": "开始录制失败", + "started": "已开始浏览器录制", + "stop": "停止浏览器录制", + "noRecording": "请先完成一次浏览器录制" + } + }, + "shortcut": { + "title": "快捷指令" + }, + "skillRecord": { + "appliedMessage": "🎉 技能 **{{name}}** 已成功应用!\n\n之后可以通过聊天输入相关指令,一键回放相同的浏览器操作流程。", + "applyFailed": "应用技能失败", + "previewMessage": "✅ 录制完成!已生成 {{steps}} 个回放步骤。\n\n📝 **技能脚本预览:**\n\n{{preview}}{{truncation}}\n\n回复\"确认\"即可应用此技能,之后可以一键回放相同操作流程。", + "previewNoScript": "✅ 录制完成!已生成 {{steps}} 个回放步骤。\n\n⚠️ 技能脚本生成失败,请稍后重试或手动生成。", + "previewTruncation": "\n\n... (共 {{lines}} 行,完整内容将在确认后保存)", + "recordingStartedMessage": "🎬 录制已开始!请在浏览器中进行你想要自动化的操作。\n\n操作完成后,输入\"结束\"即可结束录制,系统将自动生成技能脚本。", + "recordingStopped": "录制完成,已生成 {{steps}} 个回放步骤", + "skillApplied": "技能 \"{{name}}\" 已成功应用", + "stopFailed": "停止录制失败" + }, + "skillRecordGuide": { + "buttonLabel": "技能录制", + "buttonTip": "录制浏览器操作,生成可复用的技能脚本", + "envWarning": "浏览器环境未就绪", + "envWarningDesc": "请先确保浏览器环境可用,然后再开始录制。", + "intro": "通过录制浏览器操作,自动生成可复用的技能脚本。录制开始后,你将在AI助手对话中输入任务目标和操作描述。", + "startRecording": "开始录制", + "step1Title": "开始录制", + "step1Desc": "点击下方按钮,系统将开始录制所有浏览器操作。", + "step2Title": "输入任务目标", + "step2Desc": "录制开始后,AI助手会提示你输入任务目标——这将成为技能的名称和触发关键词(例如\"登录OA系统\")。", + "step3Title": "描述操作步骤", + "step3Desc": "接着描述你想让AI助手在浏览器中执行的操作,助手会自动执行并录制。", + "step4Title": "结束录制并确认", + "step4Desc": "操作完成后,输入\"结束\"停止录制,确认后技能将以任务目标为名保存。之后只需输入任务目标关键词即可触发回放。", + "envNotReady": "请先安装浏览器环境", + "title": "技能录制引导" } } diff --git a/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx b/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx index 00381e1e78..b1bd3b5d49 100644 --- a/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx +++ b/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx @@ -25,6 +25,7 @@ import { } from "./useStorageBackends"; import { StorageBackendDrawer } from "./StorageBackendModal"; import { StorageBrowseDrawer } from "./StorageBrowseDrawer"; +import { storageProbeMessage } from "./storageProbeMessage"; import styles from "./storage.module.less"; interface StorageBackendCardProps { @@ -81,15 +82,11 @@ export function StorageBackendCard({ }>(`/admin/storage-backends/${backend.id}/test`, { method: "POST" }); hide(); if (result.ok) { - const msg = result.message_key - ? t( - `storage.${result.message_key}`, - result.message || t("storage.testSuccess"), - ) - : result.message || t("storage.testSuccess"); - message.success(msg); + message.success( + storageProbeMessage(result, t, "storage.testSuccess"), + ); } else { - message.error(result.message || t("storage.testFailed")); + message.error(storageProbeMessage(result, t, "storage.testFailed")); } } catch (err) { hide(); @@ -163,15 +160,9 @@ export function StorageBackendCard({ message_key?: string; }>(`/admin/storage-backends/${backend.id}/test`, { method: "POST" }); if (result.ok) { - const msg = result.message_key - ? t( - `storage.${result.message_key}`, - result.message || t("storage.testSuccess"), - ) - : result.message || t("storage.testSuccess"); - message.success(msg); + message.success(storageProbeMessage(result, t, "storage.testSuccess")); } else { - message.error(result.message || t("storage.testFailed")); + message.error(storageProbeMessage(result, t, "storage.testFailed")); } } catch (err) { message.error( diff --git a/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx b/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx index 3797ad08b1..87c1b38519 100644 --- a/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx +++ b/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx @@ -27,6 +27,7 @@ import { type StorageBackendRow, } from "./useStorageBackends"; import { DockerEnvFooter } from "./DockerEnvFooter"; +import { storageProbeMessage } from "./storageProbeMessage"; interface StorageBackendDrawerProps { open: boolean; @@ -228,15 +229,13 @@ export function StorageBackendDrawer({ }>(`/admin/storage-backends/${backendId}/test`, { method: "POST" }); hide(); if (result.ok) { - const msg = result.message_key - ? t( - `storage.${result.message_key}`, - result.message || t("storage.testSuccess"), - ) - : result.message || t("storage.testSuccess"); - message.success(msg); + message.success( + storageProbeMessage(result, t, "storage.testSuccess"), + ); } else { - message.warning(result.message || t("storage.testFailed")); + message.warning( + storageProbeMessage(result, t, "storage.testFailed"), + ); } } catch (err) { hide(); @@ -344,15 +343,9 @@ export function StorageBackendDrawer({ body: JSON.stringify(body), }); if (result.ok) { - const msg = result.message_key - ? t( - `storage.${result.message_key}`, - result.message || t("storage.testSuccess"), - ) - : result.message || t("storage.testSuccess"); - message.success(msg); + message.success(storageProbeMessage(result, t, "storage.testSuccess")); } else { - message.error(result.message || t("storage.testFailed")); + message.error(storageProbeMessage(result, t, "storage.testFailed")); } } catch (err) { if (err && typeof err === "object" && "errorFields" in err) return; @@ -626,7 +619,7 @@ export function StorageBackendDrawer({ > diff --git a/dashboard/src/pages/Admin/Storage/StorageBrowseDrawer.tsx b/dashboard/src/pages/Admin/Storage/StorageBrowseDrawer.tsx index 88f8cce836..95af2b6166 100644 --- a/dashboard/src/pages/Admin/Storage/StorageBrowseDrawer.tsx +++ b/dashboard/src/pages/Admin/Storage/StorageBrowseDrawer.tsx @@ -1,16 +1,32 @@ /** * StorageBrowseDrawer — browse files/directories on a configured storage backend. */ -import { useCallback, useEffect, useState } from "react"; -import { Drawer, Empty, Spin, Tree } from "antd"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button, Drawer, Empty, Modal, Spin, Tree } from "antd"; import { message } from "@/utils/antdMessage"; import type { TreeDataNode } from "antd"; -import { Folder, RefreshCw } from "lucide-react"; +import { ArrowDownToLine, Folder, RefreshCw } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { request } from "../../../api/request"; +import { request, requestBlob } from "../../../api/request"; import { fileTreeIcon } from "../../../utils/fileTreeIcon"; +import { + dedupeFileTreeInfos, + nodeKey, + pathFromKey, +} from "../../../utils/fileTreeNodes"; +import { saveBlobAsFile } from "../../../utils/saveBlobAsFile"; import { workspaceEntryPath } from "../../../utils/workspacePath"; +import FileViewer from "../../Agent/Workspace/components/FileViewer"; +import { + defaultPreviewMode, + getPreviewKind, + previewNeedsFillLayout, +} from "../../Agent/Workspace/components/FilePreview"; +import { getDocKind } from "../../Agent/Workspace/utils/docKind"; +import { isProbablyText } from "../../Agent/Workspace/utils/fileKind"; +import { getMediaKind } from "../../Agent/Workspace/utils/mediaKind"; +import { storageBrowseError } from "./storageProbeMessage"; import type { StorageBackendRow } from "./useStorageBackends"; import styles from "./storage.module.less"; @@ -20,24 +36,16 @@ interface FileInfo { size?: number; } -interface TreeKey { - path: string; - is_dir: boolean; -} - interface StorageBrowseDrawerProps { open: boolean; onClose: () => void; backend: StorageBackendRow | null; } -function nodeKey(t: TreeKey): string { - return `${t.is_dir ? "d" : "f"}:${t.path}`; -} +const STORAGE_ROOT_PATH = "/"; -function pathFromKey(key: string): TreeKey { - const sep = key.indexOf(":"); - return { is_dir: key[0] === "d", path: key.slice(sep + 1) }; +function storageRootKey(): string { + return nodeKey(STORAGE_ROOT_PATH, true); } function formatSize(bytes: number): string { @@ -46,24 +54,53 @@ function formatSize(bytes: number): string { return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } -function toTreeNodes(infos: FileInfo[]): TreeDataNode[] { - const sorted = [...infos].sort((a, b) => { - const ad = a.is_dir ? 0 : 1; - const bd = b.is_dir ? 0 : 1; - if (ad !== bd) return ad - bd; - const an = ( - a.path.split("/").filter(Boolean).pop() || a.path - ).toLowerCase(); - const bn = ( - b.path.split("/").filter(Boolean).pop() || b.path - ).toLowerCase(); - return an.localeCompare(bn); +function storageFileUrl( + backendId: number, + path: string, + kind: "file" | "download", + opts?: { preview?: boolean }, +): string { + const params = new URLSearchParams({ path }); + if (kind === "download" && opts?.preview) params.set("preview", "true"); + return `/admin/storage-backends/${backendId}/${kind}?${params.toString()}`; +} + +function canPreviewPath(path: string): boolean { + return Boolean( + getMediaKind(path) || getDocKind(path) || isProbablyText(path), + ); +} + +function isAbortError(err: unknown): boolean { + return ( + (err instanceof DOMException && err.name === "AbortError") || + (err instanceof Error && err.name === "AbortError") + ); +} + +function pathDepth(key: string): number { + return pathFromKey(key).path.split("/").filter(Boolean).length; +} + +function replaceNodeChildren( + nodes: TreeDataNode[], + key: string, + children: TreeDataNode[], +): TreeDataNode[] { + return nodes.map((n) => { + if (n.key === key) return { ...n, children }; + if (n.children) { + return { ...n, children: replaceNodeChildren(n.children, key, children) }; + } + return n; }); +} - return sorted.map((info) => { +function toTreeNodes(infos: FileInfo[], listedPath?: string): TreeDataNode[] { + return dedupeFileTreeInfos(infos, listedPath).map((info) => { const fullPath = workspaceEntryPath(info.path); const fname = fullPath.split("/").filter(Boolean).pop() || fullPath; - const key = nodeKey({ path: fullPath, is_dir: !!info.is_dir }); + const key = nodeKey(fullPath, !!info.is_dir); return { key, title: ( @@ -97,64 +134,172 @@ export function StorageBrowseDrawer({ const { t } = useTranslation(); const [treeData, setTreeData] = useState([]); const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(false); + const [expandedKeys, setExpandedKeys] = useState([ + storageRootKey(), + ]); + const [previewPath, setPreviewPath] = useState(null); + const [downloadPrompt, setDownloadPrompt] = useState(null); + const [content, setContent] = useState(""); + const [fileLoading, setFileLoading] = useState(false); + const abortRef = useRef(null); + const expandedKeysRef = useRef(expandedKeys); + expandedKeysRef.current = expandedKeys; + + const buildStorageRootNode = useCallback( + (children: TreeDataNode[]): TreeDataNode => ({ + key: storageRootKey(), + title: ( + + + {STORAGE_ROOT_PATH} + + ), + isLeaf: false, + children, + }), + [], + ); + + const backendId = backend?.id; const fetchTree = useCallback( async (path: string) => { - if (!backend) return []; + if (backendId == null) return []; return request( - `/admin/storage-backends/${backend.id}/tree?path=${encodeURIComponent( + `/admin/storage-backends/${backendId}/tree?path=${encodeURIComponent( path, )}`, ); }, - [backend], + [backendId], ); + const closePreview = useCallback(() => { + abortRef.current?.abort(); + setPreviewPath(null); + setContent(""); + setFileLoading(false); + }, []); + + const releaseBrowse = useCallback(() => { + if (backendId == null) return; + abortRef.current?.abort(); + void request(`/admin/storage-backends/${backendId}/browse`, { + method: "DELETE", + }).catch(() => undefined); + }, [backendId]); + const refreshRoot = useCallback(async () => { - if (!backend) return; + if (backendId == null) return; + const keepExpanded = [...expandedKeysRef.current]; setLoading(true); try { const data = await fetchTree("/"); - setTreeData(toTreeNodes(data)); + let nodes = [buildStorageRootNode(toTreeNodes(data, STORAGE_ROOT_PATH))]; + const dirs = keepExpanded + .filter((key) => key !== storageRootKey()) + .sort((a, b) => pathDepth(a) - pathDepth(b)); + for (const key of dirs) { + const { path, is_dir } = pathFromKey(key); + if (!is_dir) continue; + try { + const children = toTreeNodes(await fetchTree(path), path); + nodes = replaceNodeChildren(nodes, key, children); + } catch { + // Keep the collapsed placeholder; expand will retry. + } + } + setTreeData(nodes); + setExpandedKeys(keepExpanded.length ? keepExpanded : [storageRootKey()]); + setLoadError(false); } catch (err) { - message.error( - err instanceof Error ? err.message : t("storage.browseFailed"), - ); - setTreeData([]); + message.error(storageBrowseError(err, t)); + setLoadError(true); } finally { setLoading(false); } - }, [backend, fetchTree, t]); + }, [backendId, fetchTree, t, buildStorageRootNode]); useEffect(() => { - if (open && backend) { - setTreeData([]); - void refreshRoot(); - } - }, [open, backend, refreshRoot]); + if (!open || !backend) return; + setTreeData([]); + setLoadError(false); + expandedKeysRef.current = [storageRootKey()]; + setExpandedKeys([storageRootKey()]); + void refreshRoot(); + return () => { + closePreview(); + setDownloadPrompt(null); + releaseBrowse(); + }; + }, [open, backendId, refreshRoot, closePreview, releaseBrowse]); const onLoadData = async (node: TreeDataNode): Promise => { const { path, is_dir } = pathFromKey(String(node.key)); if (!is_dir) return; try { const data = await fetchTree(path); - const children = toTreeNodes(data); - const replace = (nodes: TreeDataNode[]): TreeDataNode[] => - nodes.map((n) => - n.key === node.key - ? { ...n, children } - : n.children - ? { ...n, children: replace(n.children) } - : n, - ); - setTreeData((d) => replace(d)); + const children = toTreeNodes(data, path); + setTreeData((d) => replaceNodeChildren(d, String(node.key), children)); } catch (err) { - message.error( - err instanceof Error ? err.message : t("storage.browseLoadFailed"), + message.error(storageBrowseError(err, t)); + } + }; + + const downloadFile = async (path: string) => { + if (!backend) return; + const filename = path.split("/").filter(Boolean).pop() || "download.bin"; + try { + const blob = await requestBlob( + storageFileUrl(backend.id, path, "download"), ); + await saveBlobAsFile(blob, filename); + } catch (err) { + message.error(storageBrowseError(err, t)); } }; + const openFile = async (path: string) => { + if (!backend) return; + if (!canPreviewPath(path)) { + setDownloadPrompt(path); + return; + } + abortRef.current?.abort(); + const abort = new AbortController(); + abortRef.current = abort; + setPreviewPath(path); + setContent(""); + if (getMediaKind(path) || getDocKind(path)) return; + setFileLoading(true); + try { + const r = await request<{ content: string }>( + storageFileUrl(backend.id, path, "file"), + { signal: abort.signal }, + ); + if (abort.signal.aborted) return; + setContent(r.content); + } catch (err) { + if (isAbortError(err) || abort.signal.aborted) return; + message.error(storageBrowseError(err, t)); + setPreviewPath(null); + } finally { + if (!abort.signal.aborted) setFileLoading(false); + } + }; + + const previewName = previewPath + ? previewPath.split("/").filter(Boolean).pop() || previewPath + : ""; + const downloadName = downloadPrompt + ? downloadPrompt.split("/").filter(Boolean).pop() || downloadPrompt + : ""; + const fillPreview = + previewPath != null && + (getDocKind(previewPath) != null || + previewNeedsFillLayout(getPreviewKind(previewPath))); + return ( } > - {loading ? ( + {loading && treeData.length === 0 ? (
) : treeData.length === 0 ? ( - + ) : ( setExpandedKeys(keys.map(String))} + onSelect={(keys) => { + if (!keys.length) return; + const { path, is_dir } = pathFromKey(String(keys[0])); + if (is_dir) return; + void openFile(path); + }} className={styles.browseTree} /> )} + } + onClick={() => { + if (previewPath) void downloadFile(previewPath); + }} + > + {t("common.download")} + + } + > + {previewPath && backend ? ( +
+ +
+ ) : null} +
+ setDownloadPrompt(null)} + okText={t("common.download")} + cancelText={t("common.cancel")} + onOk={() => { + if (downloadPrompt) void downloadFile(downloadPrompt); + setDownloadPrompt(null); + }} + > + {t("storage.browseDownloadOnly")} +
); } diff --git a/dashboard/src/pages/Admin/Storage/storage.module.less b/dashboard/src/pages/Admin/Storage/storage.module.less index c86d14ce4a..f40ef9ab4a 100644 --- a/dashboard/src/pages/Admin/Storage/storage.module.less +++ b/dashboard/src/pages/Admin/Storage/storage.module.less @@ -359,6 +359,19 @@ /* ── Browse drawer ─────────────────────────────────────────────── */ +.browsePreviewBody { + max-height: 70vh; + overflow: auto; +} + +.browsePreviewFill { + height: 70vh; + min-height: 320px; + overflow: hidden; + display: flex; + flex-direction: column; +} + .browseRefreshBtn { display: inline-flex; align-items: center; diff --git a/dashboard/src/pages/Admin/Storage/storageProbeMessage.test.ts b/dashboard/src/pages/Admin/Storage/storageProbeMessage.test.ts new file mode 100644 index 0000000000..a7a9ef8e70 --- /dev/null +++ b/dashboard/src/pages/Admin/Storage/storageProbeMessage.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { storageBrowseError, storageProbeMessage } from "./storageProbeMessage"; + +function t(key: string, options?: string | Record): string { + const catalog: Record = { + "storage.testFailed": "检测失败", + "storage.browseFailed": "浏览失败", + "storage.probe_no_such_bucket": "存储桶不存在,请检查 Bucket 名称", + "storage.probe_write_failed": "写入探测失败:{{detail}}", + }; + const template = catalog[key]; + if (!template) { + if (typeof options === "string") return options; + if ( + options && + typeof options === "object" && + typeof options.defaultValue === "string" + ) { + return options.defaultValue; + } + return key; + } + if ( + options && + typeof options === "object" && + typeof options.detail === "string" + ) { + return template.replace("{{detail}}", options.detail); + } + return template; +} + +describe("storageProbeMessage", () => { + it("prefers a classified bucket error over the raw SDK dump", () => { + expect( + storageProbeMessage( + { + ok: false, + message_key: "probe_no_such_bucket", + message: + "The specified bucket does not exist. Check the bucket name.", + }, + t, + "storage.testFailed", + ), + ).toBe("存储桶不存在,请检查 Bucket 名称"); + }); + + it("interpolates leftover write details", () => { + expect( + storageProbeMessage( + { + ok: false, + message_key: "probe_write_failed", + message: "widget exploded", + }, + t, + "storage.testFailed", + ), + ).toBe("写入探测失败:widget exploded"); + }); +}); + +describe("storageBrowseError", () => { + it("uses the classified storage key instead of WORKSPACE_OP_UNSUPPORTED", () => { + const err = new Error( + 'Request failed: 400 - {"error":{"code":"STORAGE_BROWSE_FAILED","message":"Could not browse this storage backend.","details":{"message_key":"probe_no_such_bucket","reason":"The specified bucket does not exist. Check the bucket name."}}}', + ); + expect(storageBrowseError(err, t as never)).toBe( + "存储桶不存在,请检查 Bucket 名称", + ); + }); +}); diff --git a/dashboard/src/pages/Admin/Storage/storageProbeMessage.ts b/dashboard/src/pages/Admin/Storage/storageProbeMessage.ts new file mode 100644 index 0000000000..9de4bbd0fa --- /dev/null +++ b/dashboard/src/pages/Admin/Storage/storageProbeMessage.ts @@ -0,0 +1,40 @@ +import type { TFunction } from "i18next"; + +import { apiErrorMessage, parseApiError } from "../../../utils/apiError"; + +export interface StorageProbeResult { + ok?: boolean; + message?: string; + message_key?: string; +} + +export function storageProbeMessage( + result: StorageProbeResult, + t: TFunction, + fallbackKey: string, +): string { + if (result.message_key) { + return t(`storage.${result.message_key}`, { + defaultValue: result.message || t(fallbackKey), + detail: result.message || "", + }); + } + return result.message || t(fallbackKey); +} + +export function storageBrowseError(error: unknown, t: TFunction): string { + const parsed = parseApiError(error); + const key = parsed?.details?.message_key; + if (typeof key === "string" && key) { + const reason = + typeof parsed?.details?.reason === "string" + ? parsed.details.reason + : parsed.message || ""; + return storageProbeMessage( + { message_key: key, message: reason }, + t, + "storage.browseFailed", + ); + } + return apiErrorMessage(error, t("storage.browseFailed"), t); +} diff --git a/dashboard/src/pages/Admin/Storage/useStorageBackends.tsx b/dashboard/src/pages/Admin/Storage/useStorageBackends.tsx index 3ebd91b9d6..2bb8fecf58 100644 --- a/dashboard/src/pages/Admin/Storage/useStorageBackends.tsx +++ b/dashboard/src/pages/Admin/Storage/useStorageBackends.tsx @@ -371,7 +371,7 @@ export const STORAGE_TYPE_DEFS: StorageTypeDef[] = [ kind: "custom", nameKey: "storage.kindCustom", descKey: "storage.descCustom", - color: "#8c8c8c", + color: "#7aa116", icon: storageBrandIcon("custom"), fields: [ { diff --git a/dashboard/src/pages/Admin/Users/InviteDrawer.tsx b/dashboard/src/pages/Admin/Users/InviteDrawer.tsx index 4e756fe066..1e60471235 100644 --- a/dashboard/src/pages/Admin/Users/InviteDrawer.tsx +++ b/dashboard/src/pages/Admin/Users/InviteDrawer.tsx @@ -6,6 +6,7 @@ import { Form, Input, InputNumber, + Select, Pagination, Popconfirm, Space, @@ -15,6 +16,14 @@ import { } from "antd"; import { Copy, Link2, Plus, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { + SEEDED_USER_ROLE_ID, + snapshotRoleLabel, + userRoleLabel, + userRolesApi, + type UserRole, +} from "../../../api/modules/userRoles"; +import { RoleSelectLabel } from "./ProfileAvatar"; import { invitesApi, localInviteUrl, @@ -24,6 +33,7 @@ import { import { message } from "@/utils/antdMessage"; import { apiErrorMessage } from "../../../utils/apiError"; import { copyText } from "../../../utils/copyText"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; import { useServerTimezone } from "../../../hooks/useServerTimezone"; import { formatServerDateTime } from "../../../utils/formatMessageTime"; import styles from "./index.module.less"; @@ -44,14 +54,32 @@ interface InviteDrawerProps { onClose: () => void; } +function presetInviteRoleId( + loaded: UserRole[], + actorIsAdmin: boolean, +): string | undefined { + const pool = actorIsAdmin + ? loaded + : loaded.filter((role) => role.system_role !== "admin"); + return pool.find((role) => role.user_role_id === SEEDED_USER_ROLE_ID) + ?.user_role_id; +} + export default function InviteDrawer({ open, onClose }: InviteDrawerProps) { const { t } = useTranslation(); const timeZone = useServerTimezone(); + const currentUser = useCurrentUser(); + const actorIsAdmin = currentUser?.role === "admin"; const [loading, setLoading] = useState(false); const [creating, setCreating] = useState(false); const [rows, setRows] = useState([]); const [page, setPage] = useState(1); - const [form] = Form.useForm<{ note?: string; expires_in_days: number }>(); + const [roles, setRoles] = useState([]); + const [form] = Form.useForm<{ + note?: string; + expires_in_days: number; + role?: string; + }>(); const refresh = useCallback(async () => { setLoading(true); @@ -66,10 +94,24 @@ export default function InviteDrawer({ open, onClose }: InviteDrawerProps) { useEffect(() => { if (!open) return; - form.setFieldsValue({ expires_in_days: 7, note: undefined }); setPage(1); void refresh(); - }, [open, form, refresh]); + void userRolesApi + .list() + .then((loaded) => { + setRoles(loaded); + form.setFieldsValue({ + expires_in_days: 7, + note: undefined, + role: presetInviteRoleId(loaded, actorIsAdmin), + }); + }) + .catch((err) => { + setRoles([]); + form.setFieldsValue({ expires_in_days: 7, note: undefined }); + message.error(apiErrorMessage(err, t("adminUsers.roleLoadFailed"), t)); + }); + }, [actorIsAdmin, open, form, refresh, t]); const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE) || 1); const safePage = Math.min(page, pageCount); @@ -85,15 +127,21 @@ export default function InviteDrawer({ open, onClose }: InviteDrawerProps) { const onCreate = async (values: { note?: string; expires_in_days: number; + role?: string; }) => { setCreating(true); try { const row = await invitesApi.create({ note: values.note?.trim() || null, expires_in_days: values.expires_in_days, + role: values.role, }); message.success(t("adminUsers.inviteCreateSuccess")); - form.setFieldsValue({ note: undefined, expires_in_days: 7 }); + form.setFieldsValue({ + note: undefined, + expires_in_days: 7, + role: presetInviteRoleId(roles, actorIsAdmin), + }); setRows((prev) => [row, ...prev.filter((r) => r.id !== row.id)]); setPage(1); const url = localInviteUrl(row.code); @@ -151,6 +199,51 @@ export default function InviteDrawer({ open, onClose }: InviteDrawerProps) { initialValues={{ expires_in_days: 7 }} className={styles.inviteCreateForm} > + + + + + { + const raw = (value ?? "").trim(); + if (!raw) return; + if (!/^ldaps?:\/\/[^\s/]+/i.test(raw)) { + throw new Error( + t("adminSso.ldap.serverUrlInvalid"), + ); + } + }, + }, + ]} + > + + + +
+ + + + + + +
+ + +
+
+

+ {t("adminSso.ldap.sectionBind")} +

+

+ {t("adminSso.ldap.sectionBindHint")} +

+
+ + + + + + + {t("adminSso.ldap.bindPassword")} + {hasBindPassword && ( + + + {t("adminSso.ldap.bindPasswordConfiguredTag")} + + )} + + } + extra={ + hasBindPassword + ? t("adminSso.ldap.bindPasswordConfigured") + : t("adminSso.ldap.bindPasswordHint") + } + > + + +
+ +
+
+

+ {t("adminSso.ldap.sectionSearch")} +

+

+ {t("adminSso.ldap.sectionSearchHint")} +

+
+ + + + + + { + const raw = (value ?? "").trim(); + if (raw && !raw.includes("{username}")) { + throw new Error( + t("adminSso.ldap.userFilterPlaceholderRequired"), + ); + } + }, + }, + ]} + > + + +
+ +
+
+

+ {t("adminSso.ldap.sectionAttributes")} +

+

+ {t("adminSso.ldap.sectionAttributesHint")} +

+
+ +
+ + + + + + + + + + + + + + + +
+ + + + + + {groupSearch && ( +
+ + + + + + +
+ )} +
+ +
+
+

+ {t("adminSso.ldap.sectionAccess")} +

+

+ {t("adminSso.ldap.sectionAccessHint")} +

+
+ + + + + + + + + + + + + + +
+ + + + + ), + }, + ]} + /> + +
+
+ + + {dirty && ( + + )} +
+
+ {dirty ? ( + + {t("adminSso.ldap.unsavedChanges")} + + ) : ( + + {t("adminSso.ldap.testHint")} + + )} +
+
+ + + + + + + ); +} diff --git a/dashboard/src/pages/Admin/Users/ProfileAvatar.test.tsx b/dashboard/src/pages/Admin/Users/ProfileAvatar.test.tsx new file mode 100644 index 0000000000..cb810f3827 --- /dev/null +++ b/dashboard/src/pages/Admin/Users/ProfileAvatar.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { ProfileAvatar, ProfileAvatarPicker } from "./ProfileAvatar"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("../../../hooks/useAuthImageSrc", () => ({ + useAuthImageSrc: (url: string) => ({ + src: url || "", + loadState: url ? "ready" : "idle", + }), +})); + +describe("ProfileAvatar", () => { + it("keeps the Lucide default when no icon is chosen", () => { + const { container, rerender } = render(); + expect(container.querySelector("svg")).toBeTruthy(); + expect(container.querySelector("img")).toBeNull(); + + rerender(); + expect(container.querySelector("svg")).toBeTruthy(); + + rerender(); + expect(container.querySelector("svg")).toBeTruthy(); + expect(container.querySelector("img")).toBeNull(); + }); + + it("renders a cropped portrait for a selected sample", () => { + const { container } = render(); + const img = container.querySelector("img"); + expect(img).toBeTruthy(); + expect(img?.getAttribute("src")).toBeTruthy(); + expect(img?.className).toMatch(/presetPortraitImg/); + expect(container.querySelector("svg")).toBeNull(); + }); + + it("falls back to the Lucide default for unknown icons", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeTruthy(); + expect(container.querySelector("img")).toBeNull(); + }); + + it("renders a Lucide role sample instead of a portrait", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeTruthy(); + expect(container.querySelector("img")).toBeNull(); + }); +}); + +describe("ProfileAvatarPicker", () => { + it("selecting the default sample saves a null icon", async () => { + const onSelectIcon = vi.fn(); + render( + , + ); + + await userEvent.click( + screen.getByRole("button", { name: "adminUsers.avatarDefault" }), + ); + expect(onSelectIcon).toHaveBeenCalledWith(null); + }); +}); diff --git a/dashboard/src/pages/Admin/Users/ProfileAvatar.tsx b/dashboard/src/pages/Admin/Users/ProfileAvatar.tsx new file mode 100644 index 0000000000..a9f478e445 --- /dev/null +++ b/dashboard/src/pages/Admin/Users/ProfileAvatar.tsx @@ -0,0 +1,427 @@ +import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { Button } from "antd"; +import { + Code2, + Cog, + Crown, + Feather, + ImagePlus, + Landmark, + Megaphone, + PieChart, + UserRound, + Users, + type LucideIcon, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { message } from "@/utils/antdMessage"; +import { request, requestUpload } from "../../../api/request"; +import adminPortrait from "../../../assets/avatars/admin.png"; +import bossPortrait from "../../../assets/avatars/boss.png"; +import doctorPortrait from "../../../assets/avatars/doctor.png"; +import financePortrait from "../../../assets/avatars/finance.png"; +import nursePortrait from "../../../assets/avatars/nurse.png"; +import opsPortrait from "../../../assets/avatars/ops.png"; +import staffPortrait from "../../../assets/avatars/staff.png"; +import studentPortrait from "../../../assets/avatars/student.png"; +import supportPortrait from "../../../assets/avatars/support.png"; +import teacherPortrait from "../../../assets/avatars/teacher.png"; +import { useAuthImageSrc } from "../../../hooks/useAuthImageSrc"; +import { validateAvatarFile } from "../../Experts/components/ExpertAvatarPicker"; +import styles from "./index.module.less"; + +const AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif"; + +export type AvatarKind = "user" | "role"; + +interface AvatarPreset { + id: string; + labelKey: string; + bg: string; + fg: string; + Icon?: LucideIcon; + src?: string; +} + +const USER_PRESETS: AvatarPreset[] = [ + { + id: "user", + labelKey: "adminUsers.avatarIconUser", + bg: "#e8f1ff", + fg: "#175cd3", + Icon: UserRound, + }, + { + id: "admin", + labelKey: "adminUsers.avatarIconAdmin", + bg: "", + fg: "", + src: adminPortrait, + }, + { + id: "staff", + labelKey: "adminUsers.avatarIconStaff", + bg: "", + fg: "", + src: staffPortrait, + }, + { + id: "doctor", + labelKey: "adminUsers.avatarIconDoctor", + bg: "", + fg: "", + src: doctorPortrait, + }, + { + id: "nurse", + labelKey: "adminUsers.avatarIconNurse", + bg: "", + fg: "", + src: nursePortrait, + }, + { + id: "student", + labelKey: "adminUsers.avatarIconStudent", + bg: "", + fg: "", + src: studentPortrait, + }, + { + id: "teacher", + labelKey: "adminUsers.avatarIconTeacher", + bg: "", + fg: "", + src: teacherPortrait, + }, + { + id: "support", + labelKey: "adminUsers.avatarIconSupport", + bg: "", + fg: "", + src: supportPortrait, + }, + { + id: "ops", + labelKey: "adminUsers.avatarIconOps", + bg: "", + fg: "", + src: opsPortrait, + }, + { + id: "finance", + labelKey: "adminUsers.avatarIconFinance", + bg: "", + fg: "", + src: financePortrait, + }, + { + id: "boss", + labelKey: "adminUsers.avatarIconBoss", + bg: "", + fg: "", + src: bossPortrait, + }, +]; + +const ROLE_PRESETS: AvatarPreset[] = [ + { + id: "member", + labelKey: "adminUsers.avatarIconMember", + bg: "", + fg: "", + Icon: Users, + }, + { + id: "award", + labelKey: "adminUsers.avatarIconAward", + bg: "", + fg: "", + Icon: Crown, + }, + { + id: "terminal", + labelKey: "adminUsers.avatarIconTerminal", + bg: "", + fg: "", + Icon: Code2, + }, + { + id: "pen", + labelKey: "adminUsers.avatarIconPen", + bg: "", + fg: "", + Icon: Feather, + }, + { + id: "chart", + labelKey: "adminUsers.avatarIconChart", + bg: "", + fg: "", + Icon: PieChart, + }, + { + id: "headset", + labelKey: "adminUsers.avatarIconHeadset", + bg: "", + fg: "", + Icon: Megaphone, + }, + { + id: "wrench", + labelKey: "adminUsers.avatarIconWrench", + bg: "", + fg: "", + Icon: Cog, + }, + { + id: "scale", + labelKey: "adminUsers.avatarIconScale", + bg: "", + fg: "", + Icon: Landmark, + }, +]; + +function presetsFor(kind: AvatarKind): AvatarPreset[] { + return kind === "role" ? ROLE_PRESETS : USER_PRESETS; +} + +export function resolveAvatarPreset( + kind: AvatarKind, + icon?: string | null, +): AvatarPreset { + const presets = presetsFor(kind); + return (icon && presets.find((item) => item.id === icon)) || presets[0]; +} + +export function uploadUserAvatar(userId: number, file: File) { + const body = new FormData(); + body.append("file", file); + return requestUpload<{ avatar_url: string | null }>( + `/users/${userId}/avatar`, + body, + ); +} + +export function deleteUserAvatar(userId: number) { + return request(`/users/${userId}/avatar`, { method: "DELETE" }); +} + +function pickImageFile(): Promise { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = AVATAR_ACCEPT; + input.addEventListener("change", () => resolve(input.files?.[0] ?? null), { + once: true, + }); + input.click(); + }); +} + +export function ProfileAvatar({ + url, + icon, + defaultIcon, + kind = "user", + className, +}: { + url?: string | null; + icon?: string | null; + defaultIcon?: string | null; + kind?: AvatarKind; + className?: string; +}) { + const trimmed = url?.trim() || ""; + const { src, loadState } = useAuthImageSrc(trimmed); + const photo = Boolean(trimmed) && loadState === "ready" && Boolean(src); + const preset = resolveAvatarPreset(kind, icon ?? defaultIcon); + const portrait = !photo && Boolean(preset.src); + const tinted = !photo && !portrait && kind !== "role"; + const style: CSSProperties | undefined = tinted + ? { background: preset.bg, color: preset.fg } + : undefined; + const PresetIcon = preset.Icon; + return ( + + {photo ? ( + + ) : portrait ? ( + + ) : PresetIcon ? ( + + ) : null} + + ); +} + +export function RoleSelectLabel({ + url, + icon, + label, +}: { + url?: string | null; + icon?: string | null; + label: string; +}) { + return ( + + + {label} + + ); +} + +export function ProfileAvatarPicker({ + avatarUrl, + icon, + kind = "user", + disabled = false, + onPick, + onSelectIcon, + onRemove, +}: { + avatarUrl?: string | null; + icon?: string | null; + kind?: AvatarKind; + disabled?: boolean; + onPick: (file: File) => void | Promise; + onSelectIcon?: (icon: string | null) => void | Promise; + onRemove?: () => void | Promise; +}) { + const { t } = useTranslation(); + const [localPreview, setLocalPreview] = useState(null); + const localPreviewRef = useRef(null); + const displayUrl = localPreview ?? avatarUrl; + const hasPhoto = Boolean(displayUrl?.trim()); + const presets = presetsFor(kind); + + useEffect(() => { + return () => { + if (localPreviewRef.current) URL.revokeObjectURL(localPreviewRef.current); + }; + }, []); + + const replaceLocalPreview = (next: string | null) => { + if (localPreviewRef.current) URL.revokeObjectURL(localPreviewRef.current); + localPreviewRef.current = next; + setLocalPreview(next); + }; + + return ( +
+
+ +
+ + {hasPhoto && onRemove ? ( + + ) : null} + + {t("experts.avatarHint")} + +
+
+
+ {t("adminUsers.avatarSamples")} +
+
+ {presets.map((preset, index) => { + const selected = + !hasPhoto && (index === 0 ? !icon : icon === preset.id); + const label = + index === 0 ? t("adminUsers.avatarDefault") : t(preset.labelKey); + const plain = kind === "role"; + const portrait = Boolean(preset.src); + const PresetIcon = preset.Icon; + return ( + + ); + })} +
+
+ ); +} diff --git a/dashboard/src/pages/Admin/Users/RolesPanel.tsx b/dashboard/src/pages/Admin/Users/RolesPanel.tsx new file mode 100644 index 0000000000..7bd669770c --- /dev/null +++ b/dashboard/src/pages/Admin/Users/RolesPanel.tsx @@ -0,0 +1,669 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Button, + Drawer, + Empty, + Form, + Input, + Popconfirm, + Segmented, + Space, + Spin, + Tag, + Tooltip, + Typography, +} from "antd"; +import { + IdCard, + LayoutGrid, + List, + Pencil, + Plus, + RefreshCw, + Trash2, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { ResizableTable } from "@/components/ResizableTable"; +import { message } from "@/utils/antdMessage"; +import { + userRolesApi, + userRoleDescription, + userRoleLabel, + type UserRole, +} from "../../../api/modules/userRoles"; +import { request } from "../../../api/request"; +import { apiErrorMessage } from "../../../utils/apiError"; +import { useCardTableView } from "../../../hooks/useCardTableView"; +import { fetchFilesystemDefaults } from "../../Experts/components/agentBackendForm"; +import { HOST_FS_ROOT } from "../../Experts/components/rootDirTree"; +import styles from "./index.module.less"; +import { ProfileAvatar, ProfileAvatarPicker } from "./ProfileAvatar"; +import { + PermissionCheckboxPicker, + ResourcePolicyFields, + rolePoliciesFromForm, + type PermissionCatalogItem, + type PolicyFormValues, +} from "./UsersListPanel"; + +interface RoleFormValues extends PolicyFormValues { + name: string; + description?: string; + permissions?: string[]; +} + +export default function RolesPanel() { + const { t } = useTranslation(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [permCatalog, setPermCatalog] = useState([]); + const [fsTreeRoots, setFsTreeRoots] = useState([HOST_FS_ROOT]); + const [workspaceRootAllowed, setWorkspaceRootAllowed] = useState(true); + const [editor, setEditor] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [form] = Form.useForm(); + const [pendingAvatar, setPendingAvatar] = useState(null); + const [pendingIcon, setPendingIcon] = useState(null); + const { viewMode, setViewMode, showCardView } = useCardTableView("table"); + + const baselinePermissions = useMemo( + () => + permCatalog + .filter((item) => item.category === "settings") + .map((item) => item.key), + [permCatalog], + ); + + const refresh = useCallback(async () => { + setLoading(true); + try { + setRows(await userRolesApi.list()); + } catch (err) { + message.error(apiErrorMessage(err, t("adminUsers.roleLoadFailed"), t)); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void refresh(); + request("/users/permissions") + .then(setPermCatalog) + .catch(() => setPermCatalog([])); + fetchFilesystemDefaults() + .then((defaults) => { + setFsTreeRoots( + defaults.browse_roots?.length + ? defaults.browse_roots + : [defaults.tree_root], + ); + setWorkspaceRootAllowed(!defaults.in_container); + }) + .catch(() => { + setFsTreeRoots([HOST_FS_ROOT]); + setWorkspaceRootAllowed(true); + }); + }, [refresh]); + + const descriptionForForm = useCallback( + (row: UserRole) => { + const stored = row.description?.trim(); + if (stored) return stored; + if (row.user_role_id === "admin" || row.user_role_id === "user") { + return userRoleDescription(row, t); + } + return ""; + }, + [t], + ); + + const openCreate = () => { + setPendingAvatar(null); + setPendingIcon(null); + setEditor("new"); + }; + + const openEdit = (row: UserRole) => { + setPendingAvatar(null); + setPendingIcon(null); + setEditor(row); + }; + + const formInitialValues: RoleFormValues = + !editor || editor === "new" + ? { + name: "", + description: "", + permissions: [...baselinePermissions], + limit_workspace_root: false, + limit_token_quota: false, + limit_max_agents: false, + } + : { + name: editor.user_role_name, + description: descriptionForForm(editor), + permissions: + editor.system_role === "admin" ? [] : [...editor.permissions], + limit_workspace_root: workspaceRootAllowed + ? Boolean(editor.workspace_root_dir) + : false, + workspace_root_dir: workspaceRootAllowed + ? editor.workspace_root_dir ?? undefined + : undefined, + limit_token_quota: editor.token_quota != null, + token_quota: editor.token_quota ?? undefined, + limit_max_agents: editor.max_agents != null, + max_agents: editor.max_agents ?? undefined, + }; + const formKey = + editor === "new" ? "new" : editor ? editor.user_role_id : "closed"; + const editorRef = useRef(editor); + const formValuesRef = useRef(formInitialValues); + editorRef.current = editor; + formValuesRef.current = formInitialValues; + + useLayoutEffect(() => { + const current = editorRef.current; + if (!current) return; + form.setFieldsValue(formValuesRef.current); + }, [form, formKey]); + + const closeEditor = () => { + setEditor(null); + form.resetFields(); + }; + + const onSubmit = async (values: RoleFormValues) => { + const editing = editor !== "new" ? editor : null; + setSubmitting(true); + try { + if (editing?.immutable) { + await userRolesApi.update(editing.user_role_id, { + user_role_name: values.name.trim(), + description: values.description?.trim() || null, + }); + message.success(t("adminUsers.roleUpdateSuccess")); + } else if (editing) { + await userRolesApi.update(editing.user_role_id, { + user_role_name: values.name.trim(), + description: values.description?.trim() || null, + permissions: values.permissions ?? [], + policies: rolePoliciesFromForm(values, { workspaceRootAllowed }), + }); + message.success(t("adminUsers.roleUpdateSuccess")); + } else { + const created = await userRolesApi.create({ + user_role_name: values.name.trim(), + description: values.description?.trim() || null, + permissions: values.permissions ?? [], + policies: rolePoliciesFromForm(values, { workspaceRootAllowed }), + }); + if (pendingAvatar) { + await userRolesApi.uploadAvatar(created.user_role_id, pendingAvatar); + } else if (pendingIcon) { + await userRolesApi.update(created.user_role_id, { + avatar_icon: pendingIcon, + }); + } + message.success( + t("adminUsers.roleCreateSuccess", { name: values.name.trim() }), + ); + } + setPendingAvatar(null); + setPendingIcon(null); + closeEditor(); + void refresh(); + } catch (err) { + message.error( + apiErrorMessage( + err, + editing + ? t("adminUsers.roleUpdateFailed") + : t("adminUsers.roleCreateFailed"), + t, + ), + ); + } finally { + setSubmitting(false); + } + }; + + const onDelete = async (row: UserRole) => { + try { + await userRolesApi.remove(row.user_role_id); + message.success(t("adminUsers.roleDeleteSuccess")); + void refresh(); + } catch (err) { + message.error(apiErrorMessage(err, t("adminUsers.roleDeleteFailed"), t)); + } + }; + + const editing = editor !== "new" ? editor : null; + const locked = Boolean(editing?.immutable); + + return ( + <> +
+
+ + {t("adminUsers.tabRoles")} + +

+ {t("adminUsers.roleTemplateHint")} +

+
+
+ setViewMode(value as "table" | "card")} + options={[ + { + value: "card", + label: ( + + + {t("adminUsers.viewCard")} + + ), + }, + { + value: "table", + label: ( + + + {t("adminUsers.viewTable")} + + ), + }, + ]} + /> + + +
+
+ + {showCardView ? ( + loading && rows.length === 0 ? ( +
+ +
+ ) : rows.length === 0 ? ( + + ) : ( +
+ {rows.map((row) => { + const limited = + Boolean(row.workspace_root_dir) || + row.token_quota != null || + row.max_agents != null; + return ( +
+
+
+ +
+

+ {userRoleLabel(row, t)} + {!row.deletable ? ( + + {t("adminUsers.roleBuiltin")} + + ) : null} +

+ + {row.user_role_id} + +
+
+

+ {userRoleDescription(row, t)} +

+
+
+ + {row.system_role === "admin" + ? t("adminUsers.permAll") + : t("adminUsers.permCount", { + count: row.permissions.length, + })} + · + {limited + ? t("adminUsers.rolePolicyLimited") + : t("adminUsers.rolePolicyUnlimited")} + + +
+
+ ); + })} +
+ ) + ) : ( + , + }} + columns={[ + { + title: t("adminUsers.roleColName"), + dataIndex: "user_role_name", + width: 260, + render: (_name: string, row) => ( +
+ + + + {userRoleLabel(row, t)} + {!row.deletable ? ( + {t("adminUsers.roleBuiltin")} + ) : null} + + + {row.user_role_id} + + +
+ ), + }, + { + title: t("adminUsers.roleDescription"), + dataIndex: "description", + ellipsis: true, + render: (_description: string | null, row) => + userRoleDescription(row, t), + }, + { + title: t("adminUsers.colPermissions"), + width: 160, + render: (_, row) => + row.system_role === "admin" + ? t("adminUsers.permAll") + : t("adminUsers.permCount", { + count: row.permissions.length, + }), + }, + { + title: t("adminUsers.roleColPolicy"), + width: 140, + render: (_, row) => { + const limited = + Boolean(row.workspace_root_dir) || + row.token_quota != null || + row.max_agents != null; + return limited + ? t("adminUsers.rolePolicyLimited") + : t("adminUsers.rolePolicyUnlimited"); + }, + }, + { + title: t("adminUsers.colActions"), + width: 100, + render: (_, row) => ( + + + + + void onDelete(row)} + disabled={!row.deletable} + > + + + + + + ), + }, + ]} + /> + )} + + + + + + } + > + + form={form} + initialValues={{ + ...formInitialValues, + permissions: formInitialValues.permissions + ? [...formInitialValues.permissions] + : undefined, + }} + layout="vertical" + requiredMark={false} + onFinish={(values) => void onSubmit(values)} + className={styles.createUserForm} + > + { + if (!editing) { + setPendingIcon(icon); + setPendingAvatar(null); + return; + } + try { + const updated = await userRolesApi.update( + editing.user_role_id, + { + avatar_icon: icon, + }, + ); + setEditor({ + ...editing, + avatar_url: updated.avatar_url, + avatar_icon: updated.avatar_icon, + }); + void refresh(); + } catch (err) { + message.error( + apiErrorMessage(err, t("experts.avatarUploadFailed"), t), + ); + throw err; + } + }} + onPick={async (file) => { + if (!editing) { + setPendingAvatar(file); + return; + } + try { + const result = await userRolesApi.uploadAvatar( + editing.user_role_id, + file, + ); + setEditor({ ...editing, avatar_url: result.avatar_url }); + void refresh(); + } catch (err) { + message.error( + apiErrorMessage(err, t("experts.avatarUploadFailed"), t), + ); + throw err; + } + }} + onRemove={ + editing + ? async () => { + try { + await userRolesApi.deleteAvatar(editing.user_role_id); + setEditor({ ...editing, avatar_url: null }); + void refresh(); + } catch (err) { + message.error( + apiErrorMessage( + err, + t("experts.avatarRemoveFailed"), + t, + ), + ); + throw err; + } + } + : () => setPendingAvatar(null) + } + /> + + } /> + + {editing ? ( + + + {editing.user_role_id} + + + ) : null} + + + + {locked ? ( +
+ {t("adminUsers.roleAdminLocked")} +
+ ) : ( + <> +
+ {t("adminUsers.roleCustomHint")} +
+ + + + + + )} + +
+ + ); +} diff --git a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx index 4c5645ea4f..aca931d796 100644 --- a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx +++ b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx @@ -10,12 +10,13 @@ * Endpoints (all require admin role; backend returns 403 otherwise): * GET /api/users * POST /api/users + * POST /api/users/batch * PATCH /api/users/{id} * POST /api/users/{id}/reset-password * DELETE /api/users/{id} */ -import { useEffect, useMemo, useState, useCallback } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { Button, Modal, @@ -31,6 +32,7 @@ import { Spin, Tag, Segmented, + Select, Checkbox, InputNumber, } from "antd"; @@ -40,9 +42,8 @@ import { ResizableTable } from "@/components/ResizableTable"; import { Bot, Check, - ChevronRight, CircleHelp, - Clock, + Coins, IdCard, KeyRound, LayoutGrid, @@ -52,20 +53,31 @@ import { LockOpen, Pencil, Plus, + Power, + PowerOff, RefreshCw, Search, ShieldCheck, Trash2, User, - UserRound, Mail, + X, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { request } from "../../../api/request"; +import { + userRolesApi, + snapshotRoleLabel, + userRoleDrift, + userRoleLabel, + type UserRole, +} from "../../../api/modules/userRoles"; import { authApi } from "../../../api/modules/auth"; import { useCardTableView } from "../../../hooks/useCardTableView"; +import { useSetCurrentUser } from "../../../hooks/useCurrentUser"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { useServerTimezone } from "../../../hooks/useServerTimezone"; +import { apiErrorMessage } from "../../../utils/apiError"; import { formatServerDateTime } from "../../../utils/formatMessageTime"; import type { OctopAgent } from "../../../context/AgentContext"; import { AgentCard } from "../../Experts/components/AgentCard"; @@ -76,13 +88,21 @@ import { fetchFilesystemDefaults } from "../../Experts/components/agentBackendFo import { HOST_FS_ROOT } from "../../Experts/components/rootDirTree"; import expertStyles from "../../Experts/index.module.less"; import styles from "./index.module.less"; +import { + deleteUserAvatar, + ProfileAvatar, + ProfileAvatarPicker, + RoleSelectLabel, + uploadUserAvatar, +} from "./ProfileAvatar"; const { Text } = Typography; interface UserRow { id: number; username: string; - role: "admin" | "user"; + /** Role-template public id: admin | user | custom ULID. */ + role: string; display_name: string | null; email?: string | null; has_password?: boolean; @@ -94,11 +114,15 @@ interface UserRow { login_retry_after_seconds?: number; created_at?: number; permissions?: string[]; + role_name?: string | null; + avatar_url?: string | null; + avatar_icon?: string | null; workspace_root_dir?: string | null; token_quota?: number | null; + max_agents?: number | null; } -interface PermissionCatalogItem { +export interface PermissionCatalogItem { key: string; category: string; label: string; @@ -106,16 +130,18 @@ interface PermissionCatalogItem { page_label?: string; } -function permFullLabel(item: PermissionCatalogItem): string { +export function permFullLabel(item: PermissionCatalogItem): string { if (item.page_label) return `${item.page_label} / ${item.label}`; return item.label; } -interface PolicyFormValues { +export interface PolicyFormValues { limit_workspace_root?: boolean; workspace_root_dir?: string; limit_token_quota?: boolean; token_quota?: number | null; + limit_max_agents?: boolean; + max_agents?: number | null; } interface CreateValues extends PolicyFormValues { @@ -124,15 +150,17 @@ interface CreateValues extends PolicyFormValues { email?: string; password: string; confirm: string; - role: "admin" | "user"; + role: string; permissions?: string[]; + role_name?: string | null; } interface EditValues extends PolicyFormValues { display_name?: string; email?: string; - role: "admin" | "user"; + role: string; permissions?: string[]; + role_name?: string | null; } interface ResetValues { @@ -140,8 +168,208 @@ interface ResetValues { confirm: string; } -function roleToneClass(role: "admin" | "user"): string { - return role === "admin" ? styles.roleToneAdmin : styles.roleToneUser; +function policyFieldsFromRole( + role: UserRole, + workspaceRootAllowed: boolean, +): PolicyFormValues { + return { + limit_workspace_root: + workspaceRootAllowed && Boolean(role.workspace_root_dir), + workspace_root_dir: role.workspace_root_dir ?? undefined, + limit_token_quota: role.token_quota != null, + token_quota: role.token_quota ?? undefined, + limit_max_agents: role.max_agents != null, + max_agents: role.max_agents ?? undefined, + }; +} + +function UserRoleField({ + roles, + workspaceRootAllowed, + staleName, + confirmOverwrite, + actorIsAdmin, + diverged, + snapshotName, + disabled, +}: { + roles: UserRole[]; + workspaceRootAllowed: boolean; + staleName?: string | null; + confirmOverwrite?: boolean; + actorIsAdmin?: boolean; + diverged?: boolean; + snapshotName?: string | null; + disabled?: boolean; +}) { + const { t } = useTranslation(); + const form = Form.useFormInstance(); + const selectedId = Form.useWatch("role", form) as string | undefined; + const showStale = Boolean(staleName) && !selectedId; + const selectedRole = roles.find((role) => role.user_role_id === selectedId); + const showDiverged = + Boolean(diverged) && + Boolean(snapshotName) && + selectedRole?.user_role_name === snapshotName; + const visibleRoles = + actorIsAdmin === false + ? roles.filter((role) => role.system_role !== "admin") + : roles; + const committedId = useRef( + (form.getFieldValue("role") as string | null | undefined) ?? null, + ); + const committedName = useRef( + (form.getFieldValue("role_name") as string | null | undefined) ?? null, + ); + + const applyRole = (role: UserRole) => { + const next: Record = { + role: role.user_role_id, + role_name: role.user_role_name, + }; + if (role.system_role !== "admin") { + next.permissions = [...role.permissions]; + Object.assign(next, policyFieldsFromRole(role, workspaceRootAllowed)); + } else { + next.permissions = []; + } + form.setFieldsValue(next); + committedId.current = role.user_role_id; + committedName.current = role.user_role_name; + }; + + return ( + <> + + + } - value={searchQuery} - onChange={(event) => setSearchQuery(event.target.value)} - placeholder={t("adminUsers.searchPlaceholder")} - className={styles.userSearch} - /> +
+ {showCardView ? ( + 0 && + selectedVisibleCount === filteredRows.length + } + indeterminate={ + selectedVisibleCount > 0 && + selectedVisibleCount < filteredRows.length + } + disabled={filteredRows.length === 0} + onChange={(e) => setSelectAllVisible(e.target.checked)} + > + {t("adminUsers.batchSelectAll")} + + ) : null} + } + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder={t("adminUsers.searchPlaceholder")} + className={styles.userSearch} + /> +
+ {selectedIds.length > 0 ? ( +
+ + {t("adminUsers.batchSelected", { count: selectedIds.length })} + +
+ + + + + + +
+
+ ) : null} + {showCardView ? ( ) : ( setSelectedIds(keys.map((key) => Number(key))), + preserveSelectedRowKeys: true, + }} rowClassName={(row) => [ row.disabled ? styles.userTableRowDisabled : "", @@ -1350,13 +2021,12 @@ export default function UsersListPanel() { const displayName = row.display_name?.trim() || row.username; return (
- - {userInitials(displayName, row.username)} - + {displayName} @@ -1417,18 +2087,14 @@ export default function UsersListPanel() { }, }, { - title: t("adminUsers.colRole"), - width: 88, + title: t("adminUsers.colRoleName"), + width: 220, render: (_, row) => ( - - {row.role === "admin" - ? t("adminUsers.roleAdmin") - : t("adminUsers.roleUser")} - + ), }, { @@ -1632,6 +2298,7 @@ export default function UsersListPanel() { permissions: [], limit_workspace_root: false, limit_token_quota: false, + limit_max_agents: false, }} className={styles.createUserForm} > @@ -1639,6 +2306,18 @@ export default function UsersListPanel() {
{t("adminUsers.createSectionAccount")}
+ { + setPendingUserAvatar(file); + }} + onSelectIcon={(icon) => { + setPendingUserIcon(icon); + setPendingUserAvatar(null); + }} + onRemove={() => setPendingUserAvatar(null)} + /> {t("adminUsers.permEditHint")}
- - - + prev.role !== cur.role} @@ -1760,7 +2436,7 @@ export default function UsersListPanel() { @@ -1818,6 +2494,67 @@ export default function UsersListPanel() {
{t("adminUsers.createSectionAccount")}
+ {editTarget ? ( + { + try { + const updated = await request( + `/users/${editTarget.id}`, + { + method: "PATCH", + body: JSON.stringify({ avatar_icon: icon }), + }, + ); + setEditTarget({ + ...editTarget, + avatar_url: updated.avatar_url, + avatar_icon: updated.avatar_icon, + }); + syncSelfAvatar(editTarget.id, updated); + void refreshUsers(); + } catch (err) { + message.error( + apiErrorMessage(err, t("experts.avatarUploadFailed"), t), + ); + throw err; + } + }} + onPick={async (file) => { + try { + const result = await uploadUserAvatar(editTarget.id, file); + setEditTarget({ + ...editTarget, + avatar_url: result.avatar_url, + }); + syncSelfAvatar(editTarget.id, { + avatar_url: result.avatar_url, + }); + void refreshUsers(); + } catch (err) { + message.error( + apiErrorMessage(err, t("experts.avatarUploadFailed"), t), + ); + throw err; + } + }} + onRemove={async () => { + try { + await deleteUserAvatar(editTarget.id); + setEditTarget({ ...editTarget, avatar_url: null }); + syncSelfAvatar(editTarget.id, { avatar_url: null }); + void refreshUsers(); + } catch (err) { + message.error( + apiErrorMessage(err, t("experts.avatarRemoveFailed"), t), + ); + throw err; + } + }} + /> + ) : null} {t("adminUsers.permEditHint")} - - - + snapshotName={editTarget?.role_name} + staleName={ + editTarget?.role_name && + !userRoles.some( + (role) => + role.user_role_id === editTarget.role || + role.user_role_name === editTarget.role_name, + ) + ? editTarget.role_name + : null + } + /> + {editTarget && isSelfAdmin(editTarget) ? ( +
+ {t("adminUsers.demoteSelf")} +
+ ) : null} prev.role !== cur.role} @@ -1896,7 +2644,7 @@ export default function UsersListPanel() { @@ -1972,6 +2720,118 @@ export default function UsersListPanel() { + + { + setBatchTokenOpen(false); + batchTokenForm.resetFields(); + }} + onOk={() => batchTokenForm.submit()} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + confirmLoading={batchSubmitting} + destroyOnHidden + > + + {t("adminUsers.batchSetTokenHint")} + + + form={batchTokenForm} + layout="vertical" + onFinish={(values) => void submitBatchToken(values)} + > + + + + + + + prev.limit_token_quota !== cur.limit_token_quota + } + > + {({ getFieldValue }) => + getFieldValue("limit_token_quota") ? ( + + + + ) : null + } + + + + + { + setBatchMaxAgentsOpen(false); + batchMaxAgentsForm.resetFields(); + }} + onOk={() => batchMaxAgentsForm.submit()} + okText={t("common.confirm")} + cancelText={t("common.cancel")} + confirmLoading={batchSubmitting} + destroyOnHidden + > + + {t("adminUsers.batchSetMaxAgentsHint")} + + + form={batchMaxAgentsForm} + layout="vertical" + onFinish={(values) => void submitBatchMaxAgents(values)} + > + + + + + + + prev.limit_max_agents !== cur.limit_max_agents + } + > + {({ getFieldValue }) => + getFieldValue("limit_max_agents") ? ( + + + + ) : null + } + + + ); } diff --git a/dashboard/src/pages/Admin/Users/index.module.less b/dashboard/src/pages/Admin/Users/index.module.less index 86fb4409a9..a555524750 100644 --- a/dashboard/src/pages/Admin/Users/index.module.less +++ b/dashboard/src/pages/Admin/Users/index.module.less @@ -1,19 +1,18 @@ /* ================================================================ Admin → Users — card view - Profile-style cards: accent · avatar header · meta pills · footer + Profile-style cards: avatar header · meta pills · footer ================================================================ */ .userCardGrid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); row-gap: 16px; column-gap: 12px; - padding: 4px 0 32px; + padding: 4px 0 24px; - @media (max-width: 640px) { + @media (max-width: 767px) { grid-template-columns: 1fr; gap: 12px; - padding: 0 0 24px; } } @@ -83,6 +82,58 @@ margin-bottom: 12px !important; } +.tokenQuotaField { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tokenQuotaPresets { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.tokenQuotaPresetsLabel { + font-size: 12px; + color: var(--fn-text-tertiary); + margin-right: 2px; +} + +.tokenQuotaPreset { + appearance: none; + border: 1px solid var(--fn-border-primary); + background: var(--fn-bg-primary); + color: var(--fn-text-secondary); + border-radius: 6px; + padding: 2px 8px; + font-size: 12px; + line-height: 20px; + cursor: pointer; + transition: + border-color 0.15s ease, + color 0.15s ease, + background 0.15s ease; + + &:hover { + border-color: var(--fn-primary, #1677ff); + color: var(--fn-primary, #1677ff); + } +} + +.tokenQuotaPresetActive { + border-color: var(--fn-primary, #1677ff); + color: var(--fn-primary, #1677ff); + background: color-mix(in srgb, var(--fn-primary, #1677ff) 10%, transparent); +} + +.tokenQuotaPreview { + font-size: 12px; + line-height: 1.5; + color: var(--fn-text-tertiary); +} + .createUserPermItem { margin-bottom: 0 !important; } @@ -339,7 +390,7 @@ .roleToneUser { color: var(--fn-text-secondary); - background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)); + background: var(--fn-bg-tertiary, rgba(0, 0, 0, 0.04)); } .roleOptionIcon { @@ -384,10 +435,197 @@ margin-bottom: 16px; } +.roleToolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.roleViewModeLabel { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.roleCardLoading { + display: grid; + place-items: center; + min-height: 180px; +} + +.roleCardGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + row-gap: 16px; + column-gap: 12px; + padding: 4px 0 24px; + + @media (max-width: 767px) { + grid-template-columns: 1fr; + gap: 12px; + } +} + +.roleCard { + background: var(--fn-bg-primary); + border: 1px solid var(--fn-card-border-normal); + border-radius: 14px; + box-shadow: var(--fn-card-shadow-normal, 0 1px 2px rgba(16, 24, 40, 0.04)); + display: flex; + flex-direction: column; + min-width: 0; + transition: + transform 0.25s ease, + box-shadow 0.25s ease, + border-color 0.25s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + border-color: color-mix( + in srgb, + var(--fn-color-brand) 22%, + var(--fn-card-border-normal) + ); + + @media (max-width: 767px) { + transform: none; + box-shadow: var( + --fn-card-shadow-normal, + 0 1px 2px rgba(16, 24, 40, 0.04) + ); + border-color: var(--fn-card-border-normal); + } + } +} + +.roleCardBody { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 16px 12px; + flex: 1; + min-width: 0; +} + +.roleCardTop { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.roleCardTitle { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 2px; + + h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + line-height: 1.35; + color: var(--fn-text-primary); + overflow-wrap: anywhere; + } +} + +.roleCardTags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + + :global(.ant-tag), + :global(.octop-ant-tag) { + margin: 0 !important; + line-height: 18px; + padding-inline: 6px; + font-size: 12px; + } +} + +.roleCardDescription { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--fn-text-secondary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.roleCardFooter { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px 10px; + border-top: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.06)); +} + +.roleCardBuiltin { + margin-left: 8px; + font-size: 12px; + font-weight: 500; + color: var(--fn-text-tertiary); +} + +.roleCardFootNote { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--fn-text-tertiary); +} + .userSearch { width: min(320px, 100%); } +.userSearchRow { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.batchBar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 16px; + margin: -4px 0 14px; + padding: 10px 12px; + border: 1px solid var(--fn-border-primary); + border-radius: var(--fn-radius-md, 8px); + background: color-mix( + in srgb, + var(--fn-primary, #1677ff) 6%, + var(--fn-bg-primary) + ); +} + +.batchBarCount { + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.batchBarActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + /* Role legend: label + body, same type scale / line-height for alignment */ .roleLegend { display: grid; @@ -432,23 +670,47 @@ position: relative; background: var(--fn-bg-primary); border: 1px solid var(--fn-card-border-normal); - border-radius: var(--fn-radius-lg); + border-radius: 14px; + box-shadow: var(--fn-card-shadow-normal, 0 1px 2px rgba(16, 24, 40, 0.04)); display: flex; flex-direction: column; - overflow: hidden; min-width: 0; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); transition: - box-shadow 0.2s ease, - transform 0.2s ease, - border-color 0.2s ease; + transform 0.25s ease, + box-shadow 0.25s ease, + border-color 0.25s ease; &:hover { - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); transform: translateY(-2px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + border-color: color-mix( + in srgb, + var(--fn-color-brand) 22%, + var(--fn-card-border-normal) + ); + + @media (max-width: 767px) { + transform: none; + box-shadow: var( + --fn-card-shadow-normal, + 0 1px 2px rgba(16, 24, 40, 0.04) + ); + border-color: var(--fn-card-border-normal); + } } } +.userCardSelected { + border-color: var(--fn-primary, #1677ff); + box-shadow: 0 0 0 1px + color-mix(in srgb, var(--fn-primary, #1677ff) 35%, transparent); +} + +.userCardSelect { + flex-shrink: 0; + margin-top: 4px; +} + .userCardLocked { border-color: rgba(255, 77, 79, 0.3); } @@ -461,60 +723,336 @@ } } -.userCardAccent { - height: 2px; - flex-shrink: 0; +.roleNameMark { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + max-width: 100%; + min-width: 0; + white-space: normal; } -.userCardAccentAdmin { - background: var(--fn-color-brand); +.roleNameText { + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} + +.userCardBody { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 16px 12px; + flex: 1; + min-width: 0; } -.userCardAccentUser { - background: var(--fn-border-strong, var(--fn-border-primary)); +.userCardTop { + display: flex; + align-items: flex-start; + gap: 12px; } -.userCardAccentLocked { - background: #ff4d4f; +.presetPortrait { + background: transparent !important; + overflow: hidden; } -.userCardAccentDisabled { - background: var(--fn-text-tertiary); +.presetPortraitImg { + width: 100%; + height: 100%; + object-fit: contain; + display: block; } -.userCardInner { +.profileAvatarPicker { display: flex; flex-direction: column; - flex: 1; - padding: 16px 16px 12px; gap: 12px; + margin-bottom: 16px; + padding: 12px; + border: 1px solid var(--fn-card-border-normal); + border-radius: 14px; + background: var(--fn-bg-primary); } -/* ── Header ─────────────────────────────────────────────────────── */ - -.userCardHeader { +.profileAvatarPickerHead { display: flex; - align-items: flex-start; + align-items: center; gap: 12px; } -.userCardAvatar { +.profileAvatarPickerPreview { + width: 56px; + height: 56px; + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + svg { + width: 26px; + height: 26px; + } +} + +.profileAvatarPickerPreviewRole { + width: 64px; + height: 64px; + border-radius: 18px; + + svg { + width: 30px; + height: 30px; + } +} + +.profileAvatarPickerActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; +} + +.profileAvatarPickerHint { + color: var(--fn-text-tertiary); + font-size: 12px; +} + +.avatarPresetLabel { + font-size: 12px; + color: var(--fn-text-tertiary); +} + +.avatarPresetGrid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.avatarPreset { + width: 36px; + height: 36px; + border-radius: 10px; + border: 2px solid transparent; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + + svg { + width: 18px; + height: 18px; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + border-radius: 8px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.avatarPresetPortrait { + background: transparent; + overflow: hidden; +} + +.avatarPresetActive { + border-color: var(--fn-color-brand, #1570ef); + box-shadow: 0 0 0 2px + color-mix(in srgb, var(--fn-color-brand, #1570ef) 24%, transparent); +} + +.avatarPresetPlain { + width: 44px; + height: 44px; + border-radius: 12px; + background: transparent; + color: var(--fn-text-secondary); + + svg { + width: 22px; + height: 22px; + } + + &.avatarPresetActive { + background: transparent; + color: var(--fn-color-brand, #1570ef); + } +} + +.roleSelectOption { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + max-width: 100%; +} + +.roleSelectIcon { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--fn-text-secondary); + background: transparent; + + svg { + width: 16px; + height: 16px; + } + + img { + width: 16px; + height: 16px; + border-radius: 4px; + object-fit: cover; + } +} + +.roleSelectName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.roleCardAvatar { width: 48px; height: 48px; border-radius: 14px; - display: flex; + display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; - font-size: 16px; - font-weight: 700; - letter-spacing: 0.02em; + overflow: hidden; + color: var(--fn-text-secondary); + background: transparent; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + svg { + width: 26px; + height: 26px; + } +} + +.roleCellAvatar { + width: 40px; + height: 40px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + color: var(--fn-text-secondary); + background: transparent; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + svg { + width: 22px; + height: 22px; + } +} + +.userCardAvatarWrap { + position: relative; + flex-shrink: 0; +} + +.userCardStatusDot { + position: absolute; + right: -1px; + bottom: -1px; + width: 8px; + height: 8px; + border-radius: 50%; + background: #12b76a; + box-shadow: 0 0 0 2px var(--fn-bg-primary); +} + +.userCardStatusDotOff { + background: #98a2b3; +} + +.userCardYou { + flex-shrink: 0; + font-size: 11px; + font-weight: 600; line-height: 1; + color: var(--fn-color-brand); +} + +.userCardSub, +.userCardQuiet { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-width: 0; + margin: 0; + font-size: 12px; + line-height: 1.4; + color: var(--fn-text-tertiary, var(--fn-text-secondary)); +} + +.userCardAvatar { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + svg { + width: 18px; + height: 18px; + } } .userCardTitleBlock { - flex: 1; min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; padding-top: 2px; } @@ -526,16 +1064,47 @@ } .userCardName { + margin: 0; font-size: 15px; font-weight: 600; color: var(--fn-text-primary); - line-height: 1.3; + line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.userCardDesc { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--fn-text-secondary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.userCardDetailLink { + appearance: none; + border: none; + background: transparent; + padding: 0; + margin: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; + color: var(--fn-color-brand, #1570ef); + line-height: 1.2; + + &:hover { + text-decoration: underline; + } +} + .userCardYouTag { flex-shrink: 0; margin: 0 !important; @@ -570,9 +1139,26 @@ .userCardMeta { display: flex; - align-items: center; flex-wrap: wrap; + align-items: center; gap: 6px; + min-width: 0; + font-size: 12px; + line-height: 1.4; + color: var(--fn-text-tertiary, var(--fn-text-secondary)); + + :global(.ant-tag), + :global(.octop-ant-tag) { + margin: 0 !important; + line-height: 18px; + padding-inline: 6px; + font-size: 12px; + } +} + +.userCardMetaSep { + opacity: 0.4; + user-select: none; } .userCardInfo { @@ -587,7 +1173,7 @@ color: var(--fn-text-tertiary); padding: 1px 7px; border-radius: 999px; - background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)); + background: var(--fn-bg-tertiary, rgba(0, 0, 0, 0.04)); } .permBadge, @@ -604,7 +1190,7 @@ .permBadge { color: var(--fn-text-secondary); - background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)); + background: var(--fn-bg-tertiary, rgba(0, 0, 0, 0.04)); } .permBadgeAll { @@ -748,27 +1334,25 @@ .userCardFooter { display: flex; align-items: center; - gap: 6px; - padding-top: 2px; - border-top: 1px solid var(--fn-border-tertiary, rgba(0, 0, 0, 0.06)); - margin-top: auto; + gap: 8px; + padding: 10px 12px 12px; + border-top: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.06)); } .userCardIconBtn { display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; - border-radius: var(--fn-radius-sm); - border: 1px solid var(--fn-border-primary); + width: 28px; + height: 28px; + border-radius: 8px; + border: none; background: transparent; cursor: pointer; color: var(--fn-text-secondary); transition: background 0.15s, - color 0.15s, - border-color 0.15s; + color 0.15s; &:hover:not(:disabled) { background: var(--fn-sidebar-item-hover); @@ -801,7 +1385,7 @@ letter-spacing: 0.02em; padding: 4px 8px; border-radius: var(--fn-radius-sm); - background: var(--fn-bg-container, rgba(0, 0, 0, 0.03)); + background: var(--fn-bg-tertiary, rgba(0, 0, 0, 0.03)); } .userTable { @@ -852,8 +1436,18 @@ align-items: center; justify-content: center; flex-shrink: 0; - font-size: 11px; - font-weight: 700; + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + svg { + width: 16px; + height: 16px; + } } .userCellText { @@ -889,6 +1483,20 @@ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.roleIdMeta { + font-size: 12px; + line-height: 1.3; + color: var(--fn-text-tertiary); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.roleIdField { + font-size: 13px; +} + .userCellMuted { font-size: 12px; color: var(--fn-text-tertiary); @@ -1136,7 +1744,7 @@ height: 20px; padding: 0 8px; border-radius: 999px; - background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)); + background: var(--fn-bg-tertiary, rgba(0, 0, 0, 0.04)); color: var(--fn-text-secondary); font-size: 11px; font-weight: 600; @@ -1284,7 +1892,7 @@ .ssoStatusTagOff { color: var(--fn-text-tertiary) !important; - background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)) !important; + background: var(--fn-bg-tertiary, rgba(0, 0, 0, 0.04)) !important; border-color: var(--fn-border-primary) !important; } @@ -1350,7 +1958,7 @@ font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; - background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)); + background: var(--fn-bg-tertiary, rgba(0, 0, 0, 0.04)); color: inherit; } diff --git a/dashboard/src/pages/Admin/Users/index.tsx b/dashboard/src/pages/Admin/Users/index.tsx index 0048b96e6f..cbe2248079 100644 --- a/dashboard/src/pages/Admin/Users/index.tsx +++ b/dashboard/src/pages/Admin/Users/index.tsx @@ -1,12 +1,14 @@ import { useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Users } from "lucide-react"; +import { IdCard, Network, Users } from "lucide-react"; import PageShell from "../../../layouts/PageShell"; import TabBar, { type TabBarItem } from "../../../components/TabLabel/TabBar"; import { TAB_ICON_SIZE } from "../../../components/TabLabel"; import { TabPanelHeader } from "../../Settings/AdvancedSettings/TabPanelHeader"; +import RolesPanel from "./RolesPanel"; import UsersListPanel from "./UsersListPanel"; import SsoPanel from "./SsoPanel"; +import LdapPanel from "./LdapPanel"; import OauthProviderCard from "./OauthProviderCard"; import { OAUTH_APP_PROVIDERS, type OauthAppKind } from "./oauthProviders"; import ForbiddenPage from "../../../components/ForbiddenPage"; @@ -18,7 +20,7 @@ import dingtalkIcon from "../../../assets/channels/dingtalk.svg"; import openidIcon from "../../../assets/providers/openid.svg"; import styles from "./index.module.less"; -type TabKey = "local" | OauthAppKind | "oidc"; +type TabKey = "local" | "roles" | OauthAppKind | "oidc" | "ldap"; const OAUTH_BRAND_ICONS: Record = { feishu: feishuIcon, @@ -38,6 +40,7 @@ function BrandTabIcon({ const TABS: TabBarItem[] = [ { key: "local", labelKey: "adminUsers.tabLocal", icon: Users }, + { key: "roles", labelKey: "adminUsers.tabRoles", icon: IdCard }, { key: "feishu", labelKey: "adminUsers.tabFeishu", @@ -58,14 +61,21 @@ const TABS: TabBarItem[] = [ labelKey: "adminUsers.tabOidc", icon: , }, + { + key: "ldap", + labelKey: "adminUsers.tabLdap", + icon: Network, + }, ]; function parseTab(raw: string | null): TabKey { + if (raw === "roles") return "roles"; if ( raw === "feishu" || raw === "wecom" || raw === "dingtalk" || - raw === "oidc" + raw === "oidc" || + raw === "ldap" ) { return raw; } @@ -106,7 +116,9 @@ export default function AdminUsersPage() { if (forbidden) return ; let body: ReactNode = ; - if (activeTab === "oidc") { + if (activeTab === "roles") { + body = ; + } else if (activeTab === "oidc") { body = (
); + } else if (activeTab === "ldap") { + body = ( +
+ } + title={t("adminSso.ldapTitle")} + description={t("adminSso.ldapDesc")} + /> + +
+ ); } else if ( activeTab === "feishu" || activeTab === "wecom" || diff --git a/dashboard/src/pages/Agent/ACP/components/ACPCard.tsx b/dashboard/src/pages/Agent/ACP/components/ACPCard.tsx index a6e92f8014..4645c270a9 100644 --- a/dashboard/src/pages/Agent/ACP/components/ACPCard.tsx +++ b/dashboard/src/pages/Agent/ACP/components/ACPCard.tsx @@ -61,7 +61,7 @@ export function ACPCard({ .join(" "); const renderStatusBadge = () => { - if (interactionDisabled) { + if (interactionDisabled && !config.enabled) { return ( @@ -88,7 +88,7 @@ export function ACPCard({ return (
diff --git a/dashboard/src/pages/Agent/ACP/index.tsx b/dashboard/src/pages/Agent/ACP/index.tsx index 1d30558a94..5d6e31fdde 100644 --- a/dashboard/src/pages/Agent/ACP/index.tsx +++ b/dashboard/src/pages/Agent/ACP/index.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, App, Button, Empty, Form, Switch, Tooltip } from "antd"; +import { Alert, App, Button, Empty, Form, Switch } from "antd"; import { useTranslation } from "react-i18next"; import PageShell from "../../../layouts/PageShell"; import { CardSkeleton } from "../../../components/Skeleton"; +import PeerOnlyRemoteAlert from "../../../components/PeerOnlyRemoteAlert"; import { acpApi } from "../../../api/modules/acp"; import { ACP_DEFAULT_STDIO_BUFFER_LIMIT_BYTES, @@ -23,11 +24,20 @@ import styles from "./index.module.less"; const EMPTY_RUNNERS: Record = {}; +interface ACPPanelProps { + /** When set (Experts tools drawer), use this agent instead of the chat selection. */ + agentId?: string | null; +} + /** ACP runners manager — shared by `/acp` and Personalization → Tools. */ -export function ACPPanel() { +export function ACPPanel({ agentId: agentIdProp }: ACPPanelProps = {}) { const { t } = useTranslation(); const { modal, message } = App.useApp(); - const { activeAgentId, agents } = useAgent(); + const { activeAgentId: contextAgentId, agents } = useAgent(); + const activeAgentId = + agentIdProp !== undefined ? agentIdProp : contextAgentId; + /** Only the Experts tools drawer should tunnel account-global ACP runners. */ + const runnersScopeId = agentIdProp; const [runners, setRunners] = useState>(EMPTY_RUNNERS); const [toolEnabled, setToolEnabled] = useState(false); @@ -50,6 +60,9 @@ export function ACPPanel() { const outboundBlocked = blocksAcpOutboundFromConfig( activeAgent?.config ?? null, ); + // Under directory sandbox: lock runner enable/edit only. The per-agent + // acp_runner tool toggle stays available (host-spawned runners may already + // be enabled for other agents on this account). useEffect(() => { activeAgentIdRef.current = activeAgentId; @@ -60,7 +73,7 @@ export function ACPPanel() { setRunnersLoading(true); void (async () => { try { - const data = await acpApi.getGlobalRunners(); + const data = await acpApi.getGlobalRunners(runnersScopeId); if (!cancelled) { setRunners(data.runners || EMPTY_RUNNERS); } @@ -77,7 +90,7 @@ export function ACPPanel() { return () => { cancelled = true; }; - }, [t]); + }, [t, runnersScopeId]); useEffect(() => { if (!activeAgentId) { @@ -133,11 +146,11 @@ export function ACPPanel() { const persistRunners = useCallback( async (next: Record) => { - const saved = await acpApi.updateGlobalRunners(next); + const saved = await acpApi.updateGlobalRunners(next, runnersScopeId); setRunners(saved.runners || EMPTY_RUNNERS); return saved.runners; }, - [], + [runnersScopeId], ); const persistToolEnabled = useCallback( @@ -154,7 +167,6 @@ export function ACPPanel() { const handleToolToggle = async (checked: boolean) => { const agentId = activeAgentIdRef.current; if (!agentId || toolLoading) return; - if (checked && outboundBlocked) return; setToolToggleLoading(true); try { await persistToolEnabled(agentId, checked); @@ -191,6 +203,7 @@ export function ACPPanel() { }; const openEdit = (key: string) => { + if (outboundBlocked) return; const cfg = runners[key]; setIsCreateMode(false); setActiveKey(key); @@ -260,29 +273,23 @@ export function ACPPanel() { }); }; - const toolToggleDisabled = - !activeAgentId || toolLoading || (outboundBlocked && !toolEnabled); + const toolToggleDisabled = !activeAgentId || toolLoading; const toolSwitch = ( - - - + ); return ( <> + {agentIdProp === undefined ? ( + + ) : null}
{t("acp.description")}
@@ -331,14 +338,7 @@ export function ACPPanel() { image={Empty.PRESENTED_IMAGE_SIMPLE} /> ) : ( -
+
{t("acp.toolEnabled")} {toolSwitch}
diff --git a/dashboard/src/pages/Agent/Channels/components/constants.ts b/dashboard/src/pages/Agent/Channels/components/constants.ts index fc8545f9e9..fbf060071e 100644 --- a/dashboard/src/pages/Agent/Channels/components/constants.ts +++ b/dashboard/src/pages/Agent/Channels/components/constants.ts @@ -32,7 +32,7 @@ export type ChannelKey = | "octopbot"; /** - * Channel kinds backed by Octop ``ChannelKind`` / harness-gateway ``BUILTIN_CHANNELS``. + * Channel kinds backed by Octop ``ChannelKind`` / octop-gateway ``BUILTIN_CHANNELS``. * ``dashboard`` / ``agentchat`` are intentionally omitted until implemented. */ export const CHANNEL_KEYS: ChannelKey[] = [ @@ -271,7 +271,7 @@ export function normalizeQqGroupContextConfig( /** * Per-kind config field schema. Drives ``ChannelDrawer``'s manual config * form. Kinds not listed here fall back to a JSON textarea so any - * harness-gateway channel still works without UI changes. + * octop-gateway channel still works without UI changes. */ export const CHANNEL_FIELDS: Partial> = { feishu: [ @@ -417,7 +417,7 @@ export const CHANNEL_DISPLAY_CONFIG_KEYS = [ "c2c_streaming", ] as const; -/** Default per-channel display settings (harness-gateway ChannelConfig). */ +/** Default per-channel display settings (octop-gateway ChannelConfig). */ export const DEFAULT_CHANNEL_DISPLAY_CONFIG = { response_mode: "stream" as const, show_thinking: false, diff --git a/dashboard/src/pages/Agent/Connectors/AgentlyAuth.test.tsx b/dashboard/src/pages/Agent/Connectors/AgentlyAuth.test.tsx new file mode 100644 index 0000000000..e311d43fdb --- /dev/null +++ b/dashboard/src/pages/Agent/Connectors/AgentlyAuth.test.tsx @@ -0,0 +1,120 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + connectorsApi, + type AgentlyAuthStatus, +} from "../../../api/modules/connectors"; +import { AgentlyAuth } from "./AgentlyAuth"; + +const status = (value: AgentlyAuthStatus["status"]): AgentlyAuthStatus => ({ + status: value, + verification_url: + value === "pending" ? "https://mail.example/authorize" : null, + user_code: value === "pending" ? "MAIL-1234" : null, + expires_at: null, + error: null, +}); + +describe("AgentlyAuth", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("authorizes the saved instance, polls, then refreshes and logs out", async () => { + let current = status("idle"); + const api = vi + .spyOn(connectorsApi, "agentlyAuth") + .mockImplementation(async (_id, action) => { + if (action === "start") current = status("pending"); + if (action === "logout") current = status("idle"); + return current; + }); + const onChanged = vi.fn(); + await act(async () => { + render( + , + ); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "登录授权" })); + }); + expect(api).toHaveBeenCalledWith("mail-a", "start"); + expect(screen.getByRole("link", { name: "打开授权页" })).toHaveAttribute( + "href", + "https://mail.example/authorize", + ); + expect(screen.getByText("MAIL-1234")).toBeInTheDocument(); + current = status("authorized"); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(screen.getByText("邮箱已授权")).toBeInTheDocument(); + expect(onChanged).toHaveBeenCalledTimes(2); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "刷新授权" })); + }); + expect(api).toHaveBeenCalledWith("mail-a", "refresh"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "注销授权" })); + }); + expect(api).toHaveBeenCalledWith("mail-a", "logout"); + expect(screen.getByText("尚未授权此邮箱实例")).toBeInTheDocument(); + }); + + it("stops polling when closed and restores pending authorization on reopen", async () => { + const api = vi + .spyOn(connectorsApi, "agentlyAuth") + .mockResolvedValue(status("pending")); + let view: ReturnType; + await act(async () => { + view = render( + , + ); + }); + view!.unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + expect(api).toHaveBeenCalledTimes(1); + await act(async () => { + render(); + }); + expect(api).toHaveBeenCalledTimes(2); + expect(screen.getByText("MAIL-1234")).toBeInTheDocument(); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(api).toHaveBeenCalledTimes(3); + }); + + it("keeps action failures visible and requires an installed CLI to authorize", async () => { + const api = vi + .spyOn(connectorsApi, "agentlyAuth") + .mockResolvedValue(status("idle")); + let view: ReturnType; + await act(async () => { + view = render( + , + ); + }); + expect(screen.getByRole("button", { name: "登录授权" })).toBeDisabled(); + view!.rerender( + , + ); + api.mockRejectedValueOnce(new Error("Authorization unavailable")); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "登录授权" })); + }); + expect(screen.getByRole("alert")).toHaveTextContent( + "Authorization unavailable", + ); + expect(screen.getByRole("button", { name: "登录授权" })).toBeEnabled(); + }); +}); diff --git a/dashboard/src/pages/Agent/Connectors/AgentlyAuth.tsx b/dashboard/src/pages/Agent/Connectors/AgentlyAuth.tsx new file mode 100644 index 0000000000..e26baa3f81 --- /dev/null +++ b/dashboard/src/pages/Agent/Connectors/AgentlyAuth.tsx @@ -0,0 +1,173 @@ +import { useEffect, useRef, useState } from "react"; +import { Alert, Button, Spin } from "antd"; +import { CheckCircleOutlined } from "@ant-design/icons"; +import { useTranslation } from "react-i18next"; + +import { + connectorsApi, + type AgentlyAuthStatus, +} from "../../../api/modules/connectors"; +import { apiErrorMessage } from "../../../utils/apiError"; +import styles from "./index.module.less"; + +export function AgentlyAuth({ + instanceId, + installed, + onChanged, +}: { + instanceId: string; + installed: boolean; + onChanged: () => void; +}) { + const { t } = useTranslation(); + const [auth, setAuth] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [checking, setChecking] = useState(false); + const [revision, setRevision] = useState(0); + const mounted = useRef(false); + const changed = useRef(onChanged); + + useEffect(() => { + changed.current = onChanged; + }, [onChanged]); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + if (busy) return; + let active = true; + let timer: ReturnType; + const check = async () => { + setChecking(true); + try { + const result = await connectorsApi.agentlyAuth(instanceId, "status"); + if (!active) return; + setAuth(result); + if (result.status === "pending") { + timer = setTimeout(() => void check(), 2000); + } else { + changed.current(); + } + } catch (err) { + if (active) setError(err); + } finally { + if (active) setChecking(false); + } + }; + void check(); + return () => { + active = false; + clearTimeout(timer); + }; + }, [instanceId, busy, revision]); + + const run = async (action: "start" | "logout" | "refresh") => { + setBusy(true); + setError(null); + try { + const result = await connectorsApi.agentlyAuth(instanceId, action); + if (mounted.current) setAuth(result); + } catch (err) { + if (mounted.current) setError(err); + } finally { + if (mounted.current) { + setBusy(false); + setRevision((v) => v + 1); + } + } + }; + + const statusLabels = { + idle: t("connectors.agentlyIdle", "尚未授权此邮箱实例"), + pending: t( + "connectors.agentlyPending", + "请打开授权页完成登录,授权结果会自动更新。", + ), + authorized: t("connectors.agentlyAuthorized", "邮箱已授权"), + expired: t("connectors.agentlyExpired", "授权已过期,请重新登录授权"), + error: t("connectors.agentlyAuthFailed", "邮箱授权失败,请重试"), + }; + + return ( +
+
+ {t("connectors.agentlyAuthTitle", "Agent Mail 邮箱授权")} +
+
+ {auth?.status === "authorized" && } + {auth ? statusLabels[auth.status] : !error && } +
+ {(error || auth?.error) && ( + + )} + {auth?.status === "pending" && auth.user_code && ( +

+ {t("connectors.agentlyUserCode", "授权码")}:{" "} + {auth.user_code} +

+ )} +
+ + {auth?.status === "pending" && auth.verification_url && ( + + )} + + {(auth?.status === "authorized" || auth?.status === "expired") && ( + + )} + {auth && auth.status !== "idle" && ( + + )} +
+
+ ); +} diff --git a/dashboard/src/pages/Agent/Connectors/ConnectorCard.tsx b/dashboard/src/pages/Agent/Connectors/ConnectorCard.tsx index 37b92efe88..785abb15cb 100644 --- a/dashboard/src/pages/Agent/Connectors/ConnectorCard.tsx +++ b/dashboard/src/pages/Agent/Connectors/ConnectorCard.tsx @@ -28,13 +28,9 @@ export const ConnectorCard = memo(function ConnectorCard({ onClick={() => !disabled && onConfigure(entry, null)} role="button" tabIndex={disabled ? -1 : 0} - aria-disabled={disabled || undefined} - onKeyDown={(e) => { - if ((e.key === "Enter" || e.key === " ") && !disabled) { - e.preventDefault(); - onConfigure(entry, null); - } - }} + onKeyDown={(e) => + e.key === "Enter" && !disabled && onConfigure(entry, null) + } >
diff --git a/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx b/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx index 1724f463a1..00e1d7e0f4 100644 --- a/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx +++ b/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx @@ -74,12 +74,7 @@ export function ConnectorInstanceCard({ onClick={() => editable && onEdit(instance)} role={editable ? "button" : undefined} tabIndex={editable ? 0 : -1} - onKeyDown={(e) => { - if ((e.key === "Enter" || e.key === " ") && editable) { - e.preventDefault(); - onEdit(instance); - } - }} + onKeyDown={(e) => e.key === "Enter" && editable && onEdit(instance)} >
diff --git a/dashboard/src/pages/Agent/Connectors/CustomMcpTab.tsx b/dashboard/src/pages/Agent/Connectors/CustomMcpTab.tsx index 7ee8d82bd4..2559c9ac95 100644 --- a/dashboard/src/pages/Agent/Connectors/CustomMcpTab.tsx +++ b/dashboard/src/pages/Agent/Connectors/CustomMcpTab.tsx @@ -26,6 +26,11 @@ import { type EditorMode, type ServerCardState, } from "./customMcpUtils"; +import { + isAuthPopupBlocked, + navigateAuthWindow, + tryOpenAuthPopup, +} from "./openAuthWindow"; import styles from "./index.module.less"; interface CustomMcpTabProps { @@ -442,8 +447,8 @@ export function CustomMcpTab({ focusServerName }: CustomMcpTabProps) { message.warning(t("connectors.customMcp.emptyName", "请填写服务器名称")); return; } - const popup = window.open("", "octop-oauth", "width=520,height=720"); - if (!popup) { + const popup = tryOpenAuthPopup("", "octop-oauth", "width=520,height=720"); + if (isAuthPopupBlocked(popup)) { message.error( t( "connectors.oauthPopupBlocked", @@ -470,7 +475,7 @@ export function CustomMcpTab({ focusServerName }: CustomMcpTabProps) { settled = true; cleanup(); try { - popup.close(); + popup?.close(); } catch { // ignore } @@ -519,7 +524,7 @@ export function CustomMcpTab({ focusServerName }: CustomMcpTabProps) { const servers = resolveServersForSave(); if (!servers) { try { - popup.close(); + popup?.close(); } catch { // ignore } @@ -548,12 +553,12 @@ export function CustomMcpTab({ focusServerName }: CustomMcpTabProps) { t("connectors.oauthTimedOut", "授权超时,请重试一键授权"), ); }, 120_000); - popup.location.replace(authorize_url); + navigateAuthWindow(popup, authorize_url); } catch (e) { cleanup(); setAuthorizingKey(null); try { - popup.close(); + popup?.close(); } catch { // ignore } diff --git a/dashboard/src/pages/Agent/Connectors/connectorDefs.tsx b/dashboard/src/pages/Agent/Connectors/connectorDefs.tsx index d9e5407671..ad5f5f1309 100644 --- a/dashboard/src/pages/Agent/Connectors/connectorDefs.tsx +++ b/dashboard/src/pages/Agent/Connectors/connectorDefs.tsx @@ -1,4 +1,4 @@ -import { Cable } from "lucide-react"; +import { Cable, Mail } from "lucide-react"; import { getConnectorLogo } from "../../../assets/connectors"; import type { ConnectorCatalogEntry } from "../../../api/modules/connectors"; @@ -70,8 +70,9 @@ export function ConnectorLogo({ const src = getConnectorLogo(kind) ?? (icon ? getConnectorLogo(icon) : undefined); if (!src) { + const Icon = kind === "agently-cli" ? Mail : Cable; return ( - button, + > a { + padding-inline: 10px; + } + } +} + +.agentlyAuthSuccess { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + line-height: 1.5; + color: var(--fn-color-success, #389e0d); +} + .feishuUserAuthWhy { margin: 0; font-size: 12px; diff --git a/dashboard/src/pages/Agent/Connectors/index.tsx b/dashboard/src/pages/Agent/Connectors/index.tsx index 4398f9274e..ae8e90b62e 100644 --- a/dashboard/src/pages/Agent/Connectors/index.tsx +++ b/dashboard/src/pages/Agent/Connectors/index.tsx @@ -44,6 +44,7 @@ import { type FeishuUserAuthStartResult, } from "../../../api/modules/connectors"; import { ConnectorCard } from "./ConnectorCard"; +import { AgentlyAuth } from "./AgentlyAuth"; import { ConnectorInstanceCard } from "./ConnectorInstanceCard"; import { CustomMcpTab } from "./CustomMcpTab"; import { @@ -58,6 +59,13 @@ import { isDifyMcpServerUrl, isGuidedConnector, } from "./guidedConnectorUtils"; +import { oauthCallbackSupported } from "./oauthCallback"; +import { + isAuthPopupBlocked, + navigateAuthWindow, + openExternalBrowserUrl, + tryOpenAuthPopup, +} from "./openAuthWindow"; import { useConnectorInstances } from "./useConnectors"; import styles from "./index.module.less"; @@ -70,6 +78,13 @@ function buildCredentials( const token = String(values.token ?? "").trim(); if (token) credentials.token = token; } else if (entry.auth_kind === "oauth2") { + if (entry.kind === "qcc") { + const api_key = String(values.api_key ?? "").trim(); + if (api_key) { + credentials.api_key = api_key; + return credentials; + } + } const access_token = String(values.access_token ?? "").trim(); if (access_token && access_token !== "__configured__") { credentials.access_token = access_token; @@ -198,6 +213,10 @@ function hasFreshCredentialInput( return Boolean(String(values.token ?? "").trim()); } if (entry.auth_kind === "oauth2") { + if (entry.kind === "qcc") { + const apiKey = String(values.api_key ?? "").trim(); + if (apiKey) return true; + } const token = String(values.access_token ?? "").trim(); return Boolean(token && token !== "__configured__"); } @@ -288,7 +307,7 @@ function configuredExtra( } function isHostCliConnector(kind: string): boolean { - return kind === "feishu-cli" || kind === "wecom-cli"; + return ["feishu-cli", "wecom-cli", "agently-cli"].includes(kind); } function ConnectorConfigDrawer({ @@ -302,7 +321,7 @@ function ConnectorConfigDrawer({ entry: ConnectorCatalogEntry | null; instance: ConnectorInstance | null; onClose: () => void; - onSaved: () => void; + onSaved: (created?: ConnectorInstance) => void; }) { const { t } = useTranslation(); const user = useCurrentUser(); @@ -334,7 +353,9 @@ function ConnectorConfigDrawer({ string | null >(null); - const hasStoredCredentials = Boolean(instance?.has_credentials); + const hasStoredCredentials = Boolean( + instanceDetail?.has_credentials ?? instance?.has_credentials, + ); const mailProvider = Form.useWatch("mail_provider", form) ?? "qq"; const defaultOpen = Form.useWatch("default_open", form) === true; const selectedMailProvider = mailProviderById(String(mailProvider)); @@ -434,12 +455,12 @@ function ConnectorConfigDrawer({ const openUrl = (url: string | null | undefined) => { if (!url) return; - window.open(url, "octop-connector-auth", "width=720,height=800"); + openExternalBrowserUrl(url); }; /** Open sync under the click gesture so popup blockers don't swallow async opens. */ const openAuthPopupPlaceholder = (): Window | null => { - const popup = window.open( + const popup = tryOpenAuthPopup( "about:blank", "octop-connector-auth", "width=720,height=800", @@ -456,23 +477,6 @@ function ConnectorConfigDrawer({ return popup; }; - const navigateAuthPopup = ( - popup: Window | null, - url: string | null | undefined, - ) => { - if (!url) return; - if (popup && !popup.closed) { - try { - popup.location.replace(url); - popup.focus(); - return; - } catch { - // Fall through to a fresh open. - } - } - openUrl(url); - }; - const handleOpenAuthorize = async () => { if (!entry) return; setOpeningAuthorize(true); @@ -620,7 +624,7 @@ function ConnectorConfigDrawer({ form.setFieldsValue({ cli_config_key: started.cli_config_key }); setFeishuUserAuth(started); setFeishuUserReady(false); - navigateAuthPopup(popup, started.verification_url); + navigateAuthWindow(popup, started.verification_url); message.success( t( "connectors.feishuUserAuthStarted", @@ -838,8 +842,8 @@ function ConnectorConfigDrawer({ const handleOAuth = async () => { if (!entry || authorizing) return; - const popup = window.open("", "octop-oauth", "width=520,height=720"); - if (!popup) { + const popup = tryOpenAuthPopup("", "octop-oauth", "width=520,height=720"); + if (isAuthPopupBlocked(popup)) { message.error( t( "connectors.oauthPopupBlocked", @@ -866,7 +870,7 @@ function ConnectorConfigDrawer({ settled = true; cleanup(); try { - popup.close(); + popup?.close(); } catch { // ignore } @@ -965,7 +969,7 @@ function ConnectorConfigDrawer({ settled = true; cleanup(); try { - popup.close(); + popup?.close(); } catch { // ignore } @@ -976,11 +980,11 @@ function ConnectorConfigDrawer({ }, 5 * 60 * 1000, ); - popup.location.replace(authorize_url); + navigateAuthWindow(popup, authorize_url); } catch (e) { cleanup(); try { - popup.close(); + popup?.close(); } catch { // ignore } @@ -1099,9 +1103,16 @@ function ConnectorConfigDrawer({ const values = form.getFieldsValue(); if (entry.auth_kind === "oauth2") { const token = String(values.access_token ?? "").trim(); - if (!hasStoredCredentials && !token) { + const apiKey = + entry.kind === "qcc" ? String(values.api_key ?? "").trim() : ""; + if (!hasStoredCredentials && !token && !apiKey) { message.warning( - t("connectors.oauthNeedToken", "请先完成授权或手动填写 Token"), + entry.kind === "qcc" + ? t( + "connectors.qccNeedAuthOrKey", + "请先完成一键授权,或填写 API Key", + ) + : t("connectors.oauthNeedToken", "请先完成授权或手动填写 Token"), ); return; } @@ -1118,7 +1129,7 @@ function ConnectorConfigDrawer({ shared: values.shared === true, }); } else { - await connectorsApi.createInstance({ + const created = await connectorsApi.createInstance({ kind: entry.kind, display_name: values.display_name as string, description: values.description as string, @@ -1126,9 +1137,15 @@ function ConnectorConfigDrawer({ default_open: values.default_open === true, shared: values.shared === true, }); + if (entry.kind === "agently-cli") { + clearFormDraft(draftScope); + message.success(t("connectors.createSuccess", "连接器已创建")); + onSaved(created); + return; + } } message.success( - hasStoredCredentials + instance ? t("connectors.saveSuccess", "连接器已保存") : t("connectors.createSuccess", "连接器已创建"), ); @@ -1147,12 +1164,22 @@ function ConnectorConfigDrawer({ if (!entry) return null; - const hasOAuthPopup = entry.auth_kind === "oauth2" && entry.oauth_ready; + const hasOAuthPopup = + entry.auth_kind === "oauth2" && + entry.oauth_ready && + oauthCallbackSupported(); const hasAuthorizeUrl = Boolean(authInfo?.authorize_url); const hasLoginUrl = Boolean(authInfo?.login_url); const guideUrl = authInfo?.guide_url ?? entry.guide_url ?? entry.doc_url; const manualUrl = authInfo?.manual_url ?? entry.manual_url ?? guideUrl; - const authHint = authInfo?.auth_hint ?? entry.auth_hint; + const catalogAuthHint = authInfo?.auth_hint ?? entry.auth_hint; + const authHint = + entry.kind === "qcc" && !hasOAuthPopup + ? t( + "connectors.qccApiKeyOnlyHint", + "当前环境无法完成 OAuth 回调。请打开授权页获取 API Key,粘贴后探测并保存。", + ) + : catalogAuthHint; const guidedKind = isGuidedConnector(entry.kind) ? entry.kind : null; const preview = instanceDetail?.credentials_preview; @@ -1170,7 +1197,7 @@ function ConnectorConfigDrawer({ return ( } loading={probing} + disabled={entry.kind === "agently-cli" && !hasStoredCredentials} onClick={() => void handleProbe()} > {t("connectors.probe", "探测")} @@ -1213,6 +1241,31 @@ function ConnectorConfigDrawer({ {authHint &&
{authHint}
} + {entry.kind === "agently-cli" && ( + <> + +

+ {t( + "connectors.agentlyQuota", + "参考配额:每日发送 50 封、每小时 200 次请求、每分钟 10 次请求;附件最多 50 个、总容量 20 MB,此连接器单文件上限 10 MB。以账户实际配额及服务最新限制为准。可在任务页选择「Agent Mail 新邮件」触发任务。", + )} +

+

+ {t( + "connectors.agentlyInstanceHint", + "先保存连接器,再登录授权。每个实例独立保存邮箱授权,可为不同 Agent 选择不同实例。", + )} +

+ + )} + {guidedKind && (
@@ -1373,7 +1426,18 @@ function ConnectorConfigDrawer({ loading={openingAuthorize} onClick={() => void handleOpenAuthorize()} > - {t("connectors.openAuthorizePage", "打开授权页")} + {entry.kind === "qcc" + ? t("connectors.qccOpenKeyPage", "打开授权页") + : t("connectors.openAuthorizePage", "打开授权页")} + + )} + {hasOAuthPopup && entry.kind === "qcc" && hasAuthorizeUrl && ( + )} {hasLoginUrl && !hideTopAuth && ( @@ -1467,6 +1531,27 @@ function ConnectorConfigDrawer({
)} + {open && entry.kind === "agently-cli" && instance && ( + { + void connectorsApi + .getInstance(instance.instance_id) + .then((detail) => + setInstanceDetail((current) => + current?.instance_id === detail.instance_id + ? detail + : current, + ), + ) + .catch(() => undefined); + onSaved(); + }} + /> + )} +
)} - {entry.oauth_ready && !preview?.oauth_configured && ( -
- {t( - "connectors.oauthHint", - "点击「一键授权」完成登录后将自动保存;也可手动粘贴 Token", - )} -
- )} -
setShowManual((v) => !v)} - role="button" - tabIndex={0} - > - {showManual - ? t("connectors.hideManual", "收起手动输入") - : t("connectors.showManual", "手动粘贴 Token")} -
- {showManual && ( + {entry.kind === "qcc" && + preview?.api_key_configured && + !showManual && ( +
+ {t( + "connectors.qccApiKeyConfigured", + "已配置 API Key,可直接探测或保存", + )} +
+ )} + {entry.oauth_ready && + hasOAuthPopup && + !preview?.oauth_configured && + !(entry.kind === "qcc" && preview?.api_key_configured) && ( +
+ {entry.kind === "qcc" + ? t( + "connectors.qccOauthHint", + "点击「一键授权」完成登录后将自动保存;也可打开授权页获取 API Key 后粘贴", + ) + : t( + "connectors.oauthHint", + "点击「一键授权」完成登录后将自动保存;也可手动粘贴 Token", + )} +
+ )} + {entry.kind === "qcc" ? ( - {t("connectors.manualTokenDoc", "手动获取 Token 文档")} - - ) : undefined - } + name="api_key" + label={t("connectors.qccApiKey", "API Key")} + extra={configuredExtra(preview, "api_key_configured", t)} > - form.setFieldValue("access_token", e.target.value) + placeholder={ + preview?.api_key_configured + ? t("connectors.secretPlaceholder", "留空表示不修改") + : t( + "connectors.qccApiKeyPlaceholder", + "粘贴企查查 API Key", + ) } /> + ) : ( + <> +
setShowManual((v) => !v)} + role="button" + tabIndex={0} + > + {showManual + ? t("connectors.hideManual", "收起手动输入") + : t("connectors.showManual", "手动粘贴 Token")} +
+ {showManual && ( + + {t( + "connectors.manualTokenDoc", + "手动获取 Token 文档", + )} + + ) : undefined + } + > + + form.setFieldValue("access_token", e.target.value) + } + /> + + )} + )} )} @@ -2212,45 +2339,44 @@ export default function ConnectorsPage() {
) : 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", - }} - /> +
+ } + 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", + }} + /> +
) : ( <>
@@ -2333,7 +2459,10 @@ export default function ConnectorsPage() { entry={drawerEntry} instance={drawerInstance} onClose={handleCloseDrawer} - onSaved={() => void handleSaved()} + onSaved={(created) => { + if (created) setDrawerInstance(created); + void handleSaved(); + }} /> ); diff --git a/dashboard/src/pages/Agent/Connectors/oauthCallback.test.ts b/dashboard/src/pages/Agent/Connectors/oauthCallback.test.ts new file mode 100644 index 0000000000..be987f3fe3 --- /dev/null +++ b/dashboard/src/pages/Agent/Connectors/oauthCallback.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { oauthCallbackSupported } from "./oauthCallback"; + +describe("oauthCallbackSupported", () => { + it("allows HTTPS and loopback HTTP", () => { + expect(oauthCallbackSupported("https://octop.example/connectors")).toBe( + true, + ); + expect(oauthCallbackSupported("http://localhost:8088/")).toBe(true); + expect(oauthCallbackSupported("http://127.0.0.1:8088/")).toBe(true); + expect(oauthCallbackSupported("http://[::1]:8088/")).toBe(true); + }); + + it("rejects public HTTP (no OAuth callback)", () => { + expect(oauthCallbackSupported("http://192.168.1.10:8088/")).toBe(false); + expect(oauthCallbackSupported("http://octop.example/")).toBe(false); + }); +}); diff --git a/dashboard/src/pages/Agent/Connectors/oauthCallback.ts b/dashboard/src/pages/Agent/Connectors/oauthCallback.ts new file mode 100644 index 0000000000..27f40758ec --- /dev/null +++ b/dashboard/src/pages/Agent/Connectors/oauthCallback.ts @@ -0,0 +1,20 @@ +/** Mirror backend public-HTTP OAuth rejection: loopback HTTP and any HTTPS are OK. */ +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +export function oauthCallbackSupported(href?: string): boolean { + const raw = + href ?? + (typeof window !== "undefined" + ? window.location.href + : "https://localhost"); + try { + const url = new URL(raw); + const host = (url.hostname || "").toLowerCase(); + if (url.protocol === "http:" && !LOOPBACK_HOSTS.has(host)) { + return false; + } + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} diff --git a/dashboard/src/pages/Agent/Connectors/openAuthWindow.test.ts b/dashboard/src/pages/Agent/Connectors/openAuthWindow.test.ts new file mode 100644 index 0000000000..af6f093f29 --- /dev/null +++ b/dashboard/src/pages/Agent/Connectors/openAuthWindow.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + isAuthPopupBlocked, + navigateAuthWindow, + openExternalBrowserUrl, + tryOpenAuthPopup, +} from "./openAuthWindow"; + +type DesktopWindow = Window & { + _wails?: { invoke?: (message: string) => void }; +}; + +const originalOpen = window.open; + +afterEach(() => { + window.open = originalOpen; + delete (window as DesktopWindow)._wails; +}); + +describe("tryOpenAuthPopup", () => { + it("skips the placeholder window in the desktop shell", () => { + const open = vi.fn(); + window.open = open as typeof window.open; + (window as DesktopWindow)._wails = { invoke: () => undefined }; + expect( + tryOpenAuthPopup("", "octop-oauth", "width=520,height=720"), + ).toBeNull(); + expect(open).not.toHaveBeenCalled(); + expect(isAuthPopupBlocked(null)).toBe(false); + }); + + it("still reports a blocked popup in the browser", () => { + window.open = vi.fn(() => null) as typeof window.open; + expect( + tryOpenAuthPopup("", "octop-oauth", "width=520,height=720"), + ).toBeNull(); + expect(isAuthPopupBlocked(null)).toBe(true); + }); +}); + +describe("navigateAuthWindow", () => { + it("opens the system-browser tab on desktop even if a dummy popup exists", () => { + const open = vi.fn(() => null); + window.open = open as typeof window.open; + (window as DesktopWindow)._wails = { invoke: () => undefined }; + const popup = { + closed: false, + location: { replace: vi.fn() }, + focus: vi.fn(), + } as unknown as Window; + navigateAuthWindow(popup, "https://example.com/authorize"); + expect(popup.location.replace).not.toHaveBeenCalled(); + expect(open).toHaveBeenCalledWith( + "https://example.com/authorize", + "_blank", + "noopener,noreferrer", + ); + }); + + it("reuses the web popup when it is still open", () => { + const open = vi.fn(); + window.open = open as typeof window.open; + const popup = { + closed: false, + location: { replace: vi.fn() }, + focus: vi.fn(), + } as unknown as Window; + navigateAuthWindow(popup, "https://example.com/authorize"); + expect(popup.location.replace).toHaveBeenCalledWith( + "https://example.com/authorize", + ); + expect(open).not.toHaveBeenCalled(); + }); +}); + +describe("openExternalBrowserUrl", () => { + it("opens _blank so the desktop hook can hand off to the OS browser", () => { + const open = vi.fn(); + window.open = open as typeof window.open; + openExternalBrowserUrl("https://example.com/key"); + expect(open).toHaveBeenCalledWith( + "https://example.com/key", + "_blank", + "noopener,noreferrer", + ); + }); +}); diff --git a/dashboard/src/pages/Agent/Connectors/openAuthWindow.ts b/dashboard/src/pages/Agent/Connectors/openAuthWindow.ts new file mode 100644 index 0000000000..c9d9bb8d82 --- /dev/null +++ b/dashboard/src/pages/Agent/Connectors/openAuthWindow.ts @@ -0,0 +1,37 @@ +import { isDesktopShell } from "../../../utils/desktopChrome"; + +/** System browser on desktop; a new tab in the web dashboard. */ +export function openExternalBrowserUrl(url: string): void { + window.open(url, "_blank", "noopener,noreferrer"); +} + +/** Named popups do not appear in the Wails WebView. */ +export function tryOpenAuthPopup( + url: string, + name: string, + features: string, +): Window | null { + if (isDesktopShell()) return null; + return window.open(url, name, features); +} + +export function isAuthPopupBlocked(popup: Window | null): boolean { + return popup == null && !isDesktopShell(); +} + +export function navigateAuthWindow( + popup: Window | null, + url: string | null | undefined, +): void { + if (!url) return; + if (!isDesktopShell() && popup && !popup.closed) { + try { + popup.location.replace(url); + popup.focus(); + return; + } catch { + // Fall through to a fresh open. + } + } + openExternalBrowserUrl(url); +} diff --git a/dashboard/src/pages/Agent/Memory/ConversationRecords.tsx b/dashboard/src/pages/Agent/Memory/ConversationRecords.tsx index 66174028ce..2a53126eb9 100644 --- a/dashboard/src/pages/Agent/Memory/ConversationRecords.tsx +++ b/dashboard/src/pages/Agent/Memory/ConversationRecords.tsx @@ -12,7 +12,6 @@ import { Drawer, Empty, Pagination, - Popconfirm, Space, Spin, Table, @@ -37,7 +36,10 @@ import { } from "../../../utils/messageParser"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { useServerTimezone } from "../../../hooks/useServerTimezone"; -import { showConfirmModal } from "../../../utils/confirmModal"; +import { + confirmDeleteConversation, + deleteConversation, +} from "../../Chat/utils/deleteConversation"; import { formatMessageTime, formatServerDateTime, @@ -296,43 +298,30 @@ export default function ConversationRecords({ resetHistoryState(); }, [resetHistoryState]); - const handleDelete = useCallback( - async (thread: OctopThread) => { - setDeletingId(thread.thread_id); - try { - await octopThreadsApi.delete(agentId, thread.thread_id); - message.success(t("memory.conversationDeleteSuccess")); - if (selectedThread?.thread_id === thread.thread_id) { - closeDrawer(); - } - setThreads((prev) => - prev.filter((row) => row.thread_id !== thread.thread_id), - ); - } catch { - message.error(t("memory.conversationDeleteFailed")); - } finally { - setDeletingId(null); - } - }, - [agentId, closeDrawer, selectedThread?.thread_id, t], - ); - const confirmDelete = useCallback( (thread: OctopThread) => { - showConfirmModal( - { - title: t("memory.conversationDeleteConfirm"), - okText: t("common.delete"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: async () => { - await handleDelete(thread); - }, - }, - { isMobile }, - ); + confirmDeleteConversation(t, async (compact) => { + setDeletingId(thread.thread_id); + try { + const deleted = await deleteConversation( + agentId, + thread.thread_id, + compact, + t, + ); + if (!deleted) return; + if (selectedThread?.thread_id === thread.thread_id) { + closeDrawer(); + } + setThreads((prev) => + prev.filter((row) => row.thread_id !== thread.thread_id), + ); + } finally { + setDeletingId(null); + } + }); }, - [handleDelete, isMobile, t], + [agentId, closeDrawer, selectedThread?.thread_id, t], ); const roleLabel = (role: string) => { @@ -426,29 +415,22 @@ export default function ConversationRecords({ onClick={() => void openThread(row)} /> - void handleDelete(row)} - > - -
diff --git a/dashboard/src/pages/Agent/Skills/components/SkillsTabs.tsx b/dashboard/src/pages/Agent/Skills/components/SkillsTabs.tsx index 53e71cf76f..1ccb87d657 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillsTabs.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillsTabs.tsx @@ -43,16 +43,23 @@ export default function SkillsTabs({ agentId }: SkillsTabsProps) { const currentUser = useCurrentUser(); // Hide the skill-packages tab for users without the `skill_packages` permission. const canSkillPackages = userCanAny(currentUser, PERM.skillPackages); + const remoteAgent = Boolean(agentId?.startsWith("bridge:")); const [activeTab, setActiveTab] = useState("custom"); const tabs = useMemo( () => - SKILL_TABS.filter((tab) => tab.key !== "packages" || canSkillPackages), - [canSkillPackages], + SKILL_TABS.filter( + (tab) => tab.key !== "packages" || (canSkillPackages && !remoteAgent), + ), + [canSkillPackages, remoteAgent], ); + const resolvedTab = + activeTab === "packages" && (!canSkillPackages || remoteAgent) + ? "custom" + : activeTab; const onInstalledTab = - activeTab === "custom" || - activeTab === "builtin" || - activeTab === "packages"; + resolvedTab === "custom" || + resolvedTab === "builtin" || + resolvedTab === "packages"; const installedSkills = useSkills(agentId, { enabled: onInstalledTab }); const noAgent = ( @@ -64,23 +71,23 @@ export default function SkillsTabs({ agentId }: SkillsTabsProps) { return (
- +
- {activeTab === "custom" || activeTab === "builtin" ? ( + {resolvedTab === "custom" || resolvedTab === "builtin" ? ( agentId ? ( ) : ( noAgent ) - ) : activeTab === "skillhub" && agentId ? ( + ) : resolvedTab === "skillhub" && agentId ? ( - ) : activeTab === "packages" && agentId && canSkillPackages ? ( + ) : resolvedTab === "packages" && agentId && canSkillPackages ? ( ( [], async () => { - const rows = await request(`/agents/${agentId}/skills`, { - cache: "no-store", - }); + const rows = await request( + `/agents/${encodeURIComponent(agentId!)}/skills`, + { + cache: "no-store", + }, + ); const list = rows || []; const corrupt = list.filter((row) => row.corrupt); if (corrupt.length > 0) { @@ -147,7 +150,9 @@ export function useSkills( if (!agentId) return null; try { const row = await request( - `/agents/${agentId}/skills/${slug}`, + `/agents/${encodeURIComponent(agentId)}/skills/${encodeURIComponent( + slug, + )}`, { cache: "no-store" }, ); return toDetail(row); diff --git a/dashboard/src/pages/Agent/Tools/ToolsTabs.tsx b/dashboard/src/pages/Agent/Tools/ToolsTabs.tsx index a016ade952..9c0f6a53c9 100644 --- a/dashboard/src/pages/Agent/Tools/ToolsTabs.tsx +++ b/dashboard/src/pages/Agent/Tools/ToolsTabs.tsx @@ -52,7 +52,7 @@ export default function ToolsTabs({ agentId }: ToolsTabsProps) { ) : resolvedTab === "plugin" ? ( ) : ( - + )}
diff --git a/dashboard/src/pages/Agent/Workspace/components/CodeEditor.tsx b/dashboard/src/pages/Agent/Workspace/components/CodeEditor.tsx index b46b1117cb..5cef1a730b 100644 --- a/dashboard/src/pages/Agent/Workspace/components/CodeEditor.tsx +++ b/dashboard/src/pages/Agent/Workspace/components/CodeEditor.tsx @@ -10,29 +10,15 @@ * when resizing ends. */ -import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useRef } from "react"; import { Spin } from "antd"; import type { OnMount } from "@monaco-editor/react"; +import { useTheme } from "../../../../context/ThemeContext"; import { getEditorLanguage } from "../utils/editorLanguage"; import styles from "../index.module.less"; const MonacoEditor = lazy(() => import("@monaco-editor/react")); -function useIsDark(): boolean { - const [dark, setDark] = useState( - () => - typeof window !== "undefined" && - window.matchMedia?.("(prefers-color-scheme: dark)").matches, - ); - useEffect(() => { - const mql = window.matchMedia("(prefers-color-scheme: dark)"); - const handler = (e: MediaQueryListEvent) => setDark(e.matches); - mql.addEventListener("change", handler); - return () => mql.removeEventListener("change", handler); - }, []); - return dark; -} - interface CodeEditorProps { path: string; value: string; @@ -50,7 +36,7 @@ export default function CodeEditor({ language, }: CodeEditorProps) { const resolvedLanguage = language ?? getEditorLanguage(path); - const isDark = useIsDark(); + const { isDark } = useTheme(); const wrapRef = useRef(null); const editorRef = useRef<{ updateOptions: (opts: { automaticLayout?: boolean }) => void; diff --git a/dashboard/src/pages/Agent/Workspace/components/DocumentPreview.tsx b/dashboard/src/pages/Agent/Workspace/components/DocumentPreview.tsx index 8cbd787abb..73356ef813 100644 --- a/dashboard/src/pages/Agent/Workspace/components/DocumentPreview.tsx +++ b/dashboard/src/pages/Agent/Workspace/components/DocumentPreview.tsx @@ -10,31 +10,42 @@ import { useTranslation } from "react-i18next"; import DocumentPreviewCore from "../../../../components/DocumentPreviewCore"; import { requestBlob } from "../../../../api/request"; import { apiErrorMessage } from "../../../../utils/apiError"; +import { saveBlobAsFile } from "../../../../utils/saveBlobAsFile"; +import { withFromWorkspace } from "../../../../utils/fromWorkspace"; import type { DocKind } from "../utils/docKind"; import styles from "../index.module.less"; interface DocumentPreviewProps { - agentId: string; + agentId?: string; path: string; kind: DocKind; + /** Workspace UI paths use true; chat/tool paths use false. */ + fromWorkspace?: boolean; + fetchUrl?: string; } -function documentDownloadUrl(agentId: string, path: string): string { - // SEC-2 durable fix: no from_workspace param — paths resolve - // workspace-relative on the API. - return `/agents/${encodeURIComponent( +function documentDownloadUrl( + agentId: string, + path: string, + fromWorkspace: boolean, +): string { + const url = `/agents/${encodeURIComponent( agentId, )}/workspace/download?path=${encodeURIComponent(path)}`; + return fromWorkspace ? withFromWorkspace(url) : `${url}&from_workspace=false`; } export default function DocumentPreview({ agentId, path, kind, + fromWorkspace = true, + fetchUrl, }: DocumentPreviewProps) { const { t } = useTranslation(); const { message } = App.useApp(); - const apiPath = documentDownloadUrl(agentId, path); + const apiPath = + fetchUrl || documentDownloadUrl(agentId ?? "", path, fromWorkspace); const filename = path.split("/").filter(Boolean).pop() || path; const fetchBlob = useCallback( @@ -48,12 +59,7 @@ export default function DocumentPreview({ const onDownload = useCallback(async () => { try { const blob = await requestBlob(apiPath); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); + await saveBlobAsFile(blob, filename); } catch (error) { message.error(apiErrorMessage(error, t("workspace.downloadFailed"), t)); } diff --git a/dashboard/src/pages/Agent/Workspace/components/FileViewer.tsx b/dashboard/src/pages/Agent/Workspace/components/FileViewer.tsx index e173e8c84b..1321e796c9 100644 --- a/dashboard/src/pages/Agent/Workspace/components/FileViewer.tsx +++ b/dashboard/src/pages/Agent/Workspace/components/FileViewer.tsx @@ -32,9 +32,12 @@ import { isProbablyText } from "../utils/fileKind"; import styles from "../index.module.less"; interface FileViewerProps { - agentId: string; + agentId?: string; path: string; /** Whether leading-slash paths come from the workspace UI. */ + fromWorkspace?: boolean; + /** Authenticated blob URL for media/docs (storage browse uses this). */ + fetchUrl?: string; /** When true, text files render with the Monaco editor. */ editMode: boolean; /** Current text content (for text/preview modes). */ @@ -51,6 +54,8 @@ interface FileViewerProps { export default function FileViewer({ agentId, path, + fromWorkspace = true, + fetchUrl, editMode, value, onChange, @@ -74,6 +79,8 @@ export default function FileViewer({ path={path} kind={mediaKind} refreshToken={refreshToken} + fromWorkspace={fromWorkspace} + fetchUrl={fetchUrl} /> ); } @@ -85,6 +92,8 @@ export default function FileViewer({ agentId={agentId} path={path} kind={docKind} + fromWorkspace={fromWorkspace} + fetchUrl={fetchUrl} /> ); } diff --git a/dashboard/src/pages/Agent/Workspace/components/MediaPreview.tsx b/dashboard/src/pages/Agent/Workspace/components/MediaPreview.tsx index e2d4a445c8..4bb689d1fc 100644 --- a/dashboard/src/pages/Agent/Workspace/components/MediaPreview.tsx +++ b/dashboard/src/pages/Agent/Workspace/components/MediaPreview.tsx @@ -33,22 +33,27 @@ function MediaFallback({ label }: { label: string }) { } function useWorkspaceBlob( - agentId: string, + agentId: string | undefined, path: string, filename: string, toBlob: (blob: Blob, filename: string) => Blob, refreshToken = 0, fromWorkspace = true, + fetchUrl?: string, ) { const [src, setSrc] = useState(""); const objectUrlRef = useRef(undefined); const apiPath = useMemo(() => { - const source = toMediaPreviewSource(path, { agentId, fromWorkspace }); + if (fetchUrl) return fetchUrl; + const source = toMediaPreviewSource(path, { + agentId: agentId ?? "", + fromWorkspace, + }); return `/agents/${encodeURIComponent( - agentId, + agentId ?? "", )}/media/preview?${new URLSearchParams({ source }).toString()}`; - }, [agentId, path, fromWorkspace]); + }, [agentId, path, fromWorkspace, fetchUrl]); useLayoutEffect(() => { let cancelled = false; @@ -99,12 +104,14 @@ function WorkspaceImage({ filename, refreshToken = 0, fromWorkspace = true, + fetchUrl, }: { - agentId: string; + agentId?: string; path: string; filename: string; refreshToken?: number; fromWorkspace?: boolean; + fetchUrl?: string; }) { const { t } = useTranslation(); const src = useWorkspaceBlob( @@ -114,6 +121,7 @@ function WorkspaceImage({ asImageBlob, refreshToken, fromWorkspace, + fetchUrl, ); if (src === "missing") { @@ -147,12 +155,14 @@ function WorkspaceVideo({ filename, refreshToken = 0, fromWorkspace = true, + fetchUrl, }: { - agentId: string; + agentId?: string; path: string; filename: string; refreshToken?: number; fromWorkspace?: boolean; + fetchUrl?: string; }) { const { t } = useTranslation(); const toBlob = useMemo( @@ -169,6 +179,7 @@ function WorkspaceVideo({ toBlob, refreshToken, fromWorkspace, + fetchUrl, ); if (src === "missing") { @@ -208,12 +219,14 @@ function WorkspaceAudio({ filename, refreshToken = 0, fromWorkspace = true, + fetchUrl, }: { - agentId: string; + agentId?: string; path: string; filename: string; refreshToken?: number; fromWorkspace?: boolean; + fetchUrl?: string; }) { const { t } = useTranslation(); const toBlob = useMemo( @@ -230,6 +243,7 @@ function WorkspaceAudio({ toBlob, refreshToken, fromWorkspace, + fetchUrl, ); if (src === "missing") { @@ -268,13 +282,15 @@ export default function MediaPreview({ kind, refreshToken = 0, fromWorkspace = true, + fetchUrl, }: { - agentId: string; + agentId?: string; path: string; kind: MediaKind; refreshToken?: number; /** Workspace tree paths use leading ``/`` as workspace-relative keys. */ fromWorkspace?: boolean; + fetchUrl?: string; }) { const filename = path.split("/").filter(Boolean).pop() || path; @@ -287,6 +303,7 @@ export default function MediaPreview({ filename={filename} refreshToken={refreshToken} fromWorkspace={fromWorkspace} + fetchUrl={fetchUrl} /> ); case "video": @@ -297,6 +314,7 @@ export default function MediaPreview({ filename={filename} refreshToken={refreshToken} fromWorkspace={fromWorkspace} + fetchUrl={fetchUrl} /> ); case "audio": @@ -307,6 +325,7 @@ export default function MediaPreview({ filename={filename} refreshToken={refreshToken} fromWorkspace={fromWorkspace} + fetchUrl={fetchUrl} /> ); default: diff --git a/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.race.test.tsx b/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.race.test.tsx deleted file mode 100644 index 87a78effe9..0000000000 --- a/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.race.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { I18nextProvider } from "react-i18next"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import i18n from "../../../../i18n"; - -const requestMock = vi.fn(); - -vi.mock("../../../../api/request", () => ({ - request: (...args: unknown[]) => requestMock(...args), - requestBlob: vi.fn(async () => new Blob(["x"])), - requestUpload: vi.fn(async () => ({})), -})); - -vi.mock("../../../../api/modules/settings", () => ({ - octopSettingsApi: { - timezone: vi.fn(async () => ({ timezone: "UTC" })), - upload: vi.fn(async () => ({})), - capabilities: vi.fn(async () => ({})), - captcha: vi.fn(async () => ({})), - }, -})); - -vi.mock("../../../../api/modules/workspace", () => ({ - workspaceApi: { - deleteWorkspaceFile: vi.fn(async () => ({})), - exportArchive: vi.fn(async () => new Blob(["x"])), - importArchive: vi.fn(async () => ({})), - }, -})); - -vi.mock("react-router-dom", () => ({ - useNavigate: () => vi.fn(), -})); - -vi.mock("../../../../context/AgentContext", () => ({ - useAgent: () => ({ - agents: [{ agent_id: "agent-1", name: "Agent 1", state: "running" }], - activeAgentId: "agent-1", - }), -})); - -vi.mock("react-pdf", () => ({ - Document: () => null, - Page: () => null, - pdfjs: { GlobalWorkerOptions: { workerSrc: "" } }, -})); - -vi.mock("antd", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - App: { - ...(actual.App ?? {}), - useApp: () => ({ - message: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, - modal: { confirm: vi.fn() }, - }), - }, - }; -}); - -import WorkspaceDrawer from "./WorkspaceDrawer"; - -function treePayload() { - return [ - { path: "/a.txt", is_dir: false, size: 4 }, - { path: "/b.txt", is_dir: false, size: 4 }, - ]; -} - -/** Deferred request so tests control response ordering. */ -function deferred() { - let resolve!: (v: { content: string }) => void; - const promise = new Promise<{ content: string }>((r) => { - resolve = r; - }); - return { promise, resolve }; -} - -describe("WorkspaceDrawer stale-response race (audit RACE-1)", () => { - beforeEach(() => { - requestMock.mockReset(); - requestMock.mockImplementation(async (path: string) => { - if (path.includes("/workspace/tree")) return treePayload(); - if (path.includes("/workspace/file")) { - return { - content: `content-for-${new URL(path, "http://x").searchParams.get( - "path", - )}`, - }; - } - return {}; - }); - }); - - it("discards a slow read for file A after switching to file B", async () => { - const slowA = deferred(); - requestMock.mockImplementation(async (path: string) => { - const p = new URL(path, "http://x"); - if (p.pathname.includes("/workspace/tree")) return treePayload(); - if (p.pathname.includes("/workspace/file")) { - const target = p.searchParams.get("path"); - if (target === "/a.txt") return slowA.promise; - return { content: "content-for-b" }; - } - return {}; - }); - - render( - - {}} embedded /> - , - ); - - const a = await screen.findAllByText("a.txt"); - await userEvent.click(a[0]); - const b = await screen.findAllByText("b.txt"); - await userEvent.click(b[0]); - - await waitFor(() => { - expect(screen.getByText("content-for-b")).toBeTruthy(); - }); - - // The stale A response arrives now — it must NOT overwrite B's content. - slowA.resolve({ content: "STALE-A-CONTENT" }); - await Promise.resolve(); - await new Promise((r) => setTimeout(r, 0)); - - expect(screen.getByText("content-for-b")).toBeTruthy(); - expect(screen.queryByText("STALE-A-CONTENT")).toBeNull(); - }); - - it("refuses to save content that was loaded for a different path", async () => { - render( - - {}} embedded /> - , - ); - - const a = await screen.findAllByText("a.txt"); - await userEvent.click(a[0]); - await waitFor(() => { - expect(screen.getByText("content-for-/a.txt")).toBeTruthy(); - }); - - const b = await screen.findAllByText("b.txt"); - await userEvent.click(b[0]); - await waitFor(() => { - expect(screen.getByText("content-for-/b.txt")).toBeTruthy(); - }); - - // Force the mismatch: rewrite the ref through a stale selection is not - // directly possible from the test; instead assert the happy path PUT goes - // to the currently loaded path only. - requestMock.mockClear(); - requestMock.mockResolvedValue({}); - // Enter edit mode first — save only appears in the editor toolbar. - const editButton = screen.getByRole("button", { name: /edit/i }); - await userEvent.click(editButton); - const saveButton = await screen.findByRole("button", { name: /save/i }); - await userEvent.click(saveButton); - await waitFor(() => { - const put = requestMock.mock.calls.find( - (c) => c[1] && (c[1] as { method?: string }).method === "PUT", - ); - expect(put).toBeTruthy(); - const url = String(put?.[0]); - expect(url).toContain("b.txt"); - expect(url).not.toContain("a.txt"); - }); - }); -}); diff --git a/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.test.tsx b/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.test.tsx new file mode 100644 index 0000000000..c88b0ea828 --- /dev/null +++ b/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.test.tsx @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { App } from "antd"; + +const TREE_COLLAPSED_KEY = "octop:workspace-drawer-tree-collapsed"; + +vi.mock("../../../../api/request", () => ({ + request: vi.fn(async () => []), + requestBlob: vi.fn(), + requestUpload: vi.fn(), +})); + +vi.mock("../../../../api/modules/workspace", () => ({ + workspaceApi: { + deleteWorkspaceFile: vi.fn(), + mkdirWorkspaceDir: vi.fn(), + createWorkspaceFile: vi.fn(), + moveWorkspaceFile: vi.fn(), + downloadWorkspaceArchive: vi.fn(), + importWorkspaceArchive: vi.fn(), + }, +})); + +vi.mock("../../../../context/AgentContext", () => ({ + useAgent: () => ({ + agents: [{ agent_id: "agent-1", name: "Demo", state: "running" }], + }), +})); + +vi.mock("../../../../hooks/useIsMobile", () => ({ + useIsMobile: () => false, +})); + +vi.mock("../../../../hooks/useServerTimezone", () => ({ + useServerTimezone: () => "UTC", +})); + +vi.mock("./FileViewer", () => ({ + default: () =>
, +})); + +import WorkspaceDrawer from "./WorkspaceDrawer"; + +function renderEmbedded() { + return render( + + undefined} + embedded + /> + , + ); +} + +describe("WorkspaceDrawer embedded tree", () => { + beforeEach(() => { + localStorage.removeItem(TREE_COLLAPSED_KEY); + }); + + it("hides the folder tree by default in the chat dock", async () => { + renderEmbedded(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "显示目录树" }), + ).toBeInTheDocument(); + }); + expect(screen.queryByRole("tree")).not.toBeInTheDocument(); + }); + + it("keeps the folder tree visible when the user last expanded it", async () => { + localStorage.setItem(TREE_COLLAPSED_KEY, "0"); + renderEmbedded(); + + await waitFor(() => { + expect(screen.getByRole("tree")).toBeInTheDocument(); + }); + expect( + screen.getByRole("button", { name: "收起目录树" }), + ).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx b/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx index ef0ed4a0f2..e73ecf4640 100644 --- a/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx +++ b/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx @@ -4,7 +4,7 @@ * without modifying the global active-agent selection. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { App, Tree, @@ -47,12 +47,15 @@ import { workspaceApi } from "../../../../api/modules/workspace"; import { useAgent } from "../../../../context/AgentContext"; import { useIsMobile } from "../../../../hooks/useIsMobile"; import { useHorizontalResize } from "../../../../hooks/useHorizontalResize"; +import { useListPanelCollapsed } from "../../../../hooks/useListPanelCollapsed"; import { useServerTimezone } from "../../../../hooks/useServerTimezone"; import { formatServerIsoDateTime } from "../../../../utils/formatMessageTime"; import { isAgentChatReady } from "../../../../utils/agentError"; import { apiErrorMessage } from "../../../../utils/apiError"; +import { saveBlobAsFile } from "../../../../utils/saveBlobAsFile"; import AgentNotReadyScreen from "../../../Chat/components/AgentNotReadyScreen"; import { fileTreeIcon } from "../../../../utils/fileTreeIcon"; +import { dedupeFileTreeInfos } from "../../../../utils/fileTreeNodes"; import { workspaceEntryPath } from "../../../../utils/workspacePath"; import FileViewer from "./FileViewer"; import { @@ -199,20 +202,8 @@ function joinPath(dir: string, name: string): string { return `${base}/${name}`; } -function toTreeNodes(infos: FileInfo[]): TreeDataNode[] { - const sorted = [...infos].sort((a, b) => { - const ad = a.is_dir ? 0 : 1; - const bd = b.is_dir ? 0 : 1; - if (ad !== bd) return ad - bd; - const an = ( - a.path.split("/").filter(Boolean).pop() || a.path - ).toLowerCase(); - const bn = ( - b.path.split("/").filter(Boolean).pop() || b.path - ).toLowerCase(); - return an.localeCompare(bn); - }); - return sorted.map((info) => { +function toTreeNodes(infos: FileInfo[], listedPath?: string): TreeDataNode[] { + return dedupeFileTreeInfos(infos, listedPath).map((info) => { const fullPath = workspaceEntryPath(info.path); const fname = fullPath.split("/").filter(Boolean).pop() || fullPath; const key = nodeKey({ path: fullPath, is_dir: !!info.is_dir }); @@ -276,11 +267,6 @@ export default function WorkspaceDrawer({ const [editMode, setEditMode] = useState(false); const [fileLoading, setFileLoading] = useState(false); const [saving, setSaving] = useState(false); - // Guards against the stale-response race (audit RACE-1): a slow read for - // file A must never populate the editor after the user has switched to B, - // and save must refuse to write content loaded for a different path. - const loadedPathRef = useRef(null); - const fetchSeqRef = useRef(0); const [previewMode, setPreviewMode] = useState(true); const [archiveExporting, setArchiveExporting] = useState(false); const [archiveImportOpen, setArchiveImportOpen] = useState(false); @@ -305,28 +291,14 @@ export default function WorkspaceDrawer({ const [expandedKeys, setExpandedKeys] = useState([ workspaceRootKey(), ]); - const [treeCollapsed, setTreeCollapsed] = useState(() => { - try { - return localStorage.getItem(TREE_COLLAPSED_KEY) === "1"; - } catch { - return false; - } - }); + const { collapsed: treeCollapsed, toggle: toggleTreeCollapsed } = + useListPanelCollapsed(TREE_COLLAPSED_KEY, { + // Chat dock (embedded) starts with the folder tree hidden. + defaultCollapsed: embedded, + }); const rootLabel = t("workspace.root", "工作区"); - const toggleTreeCollapsed = useCallback(() => { - setTreeCollapsed((prev) => { - const next = !prev; - try { - localStorage.setItem(TREE_COLLAPSED_KEY, next ? "1" : "0"); - } catch { - /* ignore */ - } - return next; - }); - }, []); - const { size: treeWidth, onResizeStart } = useHorizontalResize({ min: 180, max: 420, @@ -357,14 +329,16 @@ export default function WorkspaceDrawer({ const data = await request( withFromWorkspace(`/agents/${agentId}/workspace/tree?path=/`), ); - setTreeData([buildWorkspaceRootNode(toTreeNodes(data))]); + setTreeData([ + buildWorkspaceRootNode(toTreeNodes(data, WORKSPACE_ROOT_PATH)), + ]); setExpandedKeys([workspaceRootKey()]); if (opts?.activateRoot) { setSelectedKey(workspaceRootKey()); setEditMode(false); setPreviewMode(false); setContent(""); - setDirEntries(data); + setDirEntries(dedupeFileTreeInfos(data, WORKSPACE_ROOT_PATH)); setDirLoading(false); if (isMobile) setMobilePane("viewer"); } @@ -385,8 +359,6 @@ export default function WorkspaceDrawer({ setTreeData([]); setSelectedKey(null); setContent(""); - loadedPathRef.current = null; - fetchSeqRef.current += 1; setEditMode(false); setMobilePane("tree"); setDirEntries([]); @@ -406,7 +378,7 @@ export default function WorkspaceDrawer({ `/agents/${agentId}/workspace/tree?path=${encodeURIComponent(path)}`, ), ); - const children = toTreeNodes(data); + const children = toTreeNodes(data, path); const replace = (nodes: TreeDataNode[]): TreeDataNode[] => nodes.map((n) => n.key === node.key @@ -440,7 +412,7 @@ export default function WorkspaceDrawer({ )}`, ), ); - const children = toTreeNodes(data); + const children = toTreeNodes(data, dirPath); const replace = (nodes: TreeDataNode[]): TreeDataNode[] => nodes.map((n) => n.key === dirKey @@ -472,7 +444,7 @@ export default function WorkspaceDrawer({ )}`, ), ); - setDirEntries(data); + setDirEntries(dedupeFileTreeInfos(data, dirPath)); } catch (err: unknown) { message.error( (err instanceof Error ? err.message : String(err)) || @@ -495,8 +467,6 @@ export default function WorkspaceDrawer({ if (selectedKey === nodeKey(target)) { setSelectedKey(null); setContent(""); - loadedPathRef.current = null; - fetchSeqRef.current += 1; setDirEntries([]); } await reloadBranch(parentDir(target.path)); @@ -564,7 +534,6 @@ export default function WorkspaceDrawer({ if (isMobile) setMobilePane("viewer"); if (!getMediaKind(dest) && isProbablyText(dest)) { setFileLoading(true); - const seq = ++fetchSeqRef.current; try { const r = await request<{ content: string }>( withFromWorkspace( @@ -573,11 +542,9 @@ export default function WorkspaceDrawer({ )}`, ), ); - if (seq !== fetchSeqRef.current) return; - loadedPathRef.current = dest; setContent(r.content); } finally { - if (seq === fetchSeqRef.current) setFileLoading(false); + setFileLoading(false); } } } @@ -775,9 +742,6 @@ export default function WorkspaceDrawer({ setEditMode(false); setPreviewMode(is_dir ? false : defaultPreviewMode(path)); setContent(""); - // Any new selection invalidates in-flight reads and the save binding. - loadedPathRef.current = null; - fetchSeqRef.current += 1; if (is_dir) { setDirEntries([]); if (isMobile) setMobilePane("viewer"); @@ -789,25 +753,20 @@ export default function WorkspaceDrawer({ if (getMediaKind(path)) return; if (!isProbablyText(path)) return; setFileLoading(true); - const seq = ++fetchSeqRef.current; try { const r = await request<{ content: string }>( withFromWorkspace( `/agents/${agentId}/workspace/file?path=${encodeURIComponent(path)}`, ), ); - if (seq !== fetchSeqRef.current) return; - loadedPathRef.current = path; setContent(r.content); } catch (err: unknown) { - if (seq === fetchSeqRef.current) { - message.error( - (err instanceof Error ? err.message : String(err)) || - t("workspace.readFailed", "读取失败"), - ); - } + message.error( + (err instanceof Error ? err.message : String(err)) || + t("workspace.readFailed", "读取失败"), + ); } finally { - if (seq === fetchSeqRef.current) setFileLoading(false); + setFileLoading(false); } }; @@ -925,39 +884,27 @@ export default function WorkspaceDrawer({ return; } setFileLoading(true); - const seq = ++fetchSeqRef.current; try { const r = await request<{ content: string }>( withFromWorkspace( `/agents/${agentId}/workspace/doc?path=${encodeURIComponent(path)}`, ), ); - if (seq !== fetchSeqRef.current) return; - loadedPathRef.current = path; setContent(r.content); setEditMode(true); } catch (err: unknown) { - if (seq === fetchSeqRef.current) { - message.error( - (err instanceof Error ? err.message : String(err)) || - t("workspace.readFailed", "读取失败"), - ); - } + message.error( + (err instanceof Error ? err.message : String(err)) || + t("workspace.readFailed", "读取失败"), + ); } finally { - if (seq === fetchSeqRef.current) setFileLoading(false); + setFileLoading(false); } }; const save = async () => { if (!agentId || !selectedKey) return; const { path } = pathFromKey(selectedKey); - // RACE-1 guard: never write editor content that was loaded for a - // different path (stale-response overwrite), or that never finished - // loading at all. - if (loadedPathRef.current !== path) { - message.error(t("workspace.readFailed", "读取失败")); - return; - } setSaving(true); try { if (isEditableDoc(path)) { @@ -1006,11 +953,7 @@ export default function WorkspaceDrawer({ )}`, ), ); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = path.split("/").pop() || "download"; - a.click(); - URL.revokeObjectURL(a.href); + await saveBlobAsFile(blob, path.split("/").pop() || "download"); } catch (err: unknown) { message.error( (err instanceof Error ? err.message : String(err)) || @@ -1058,11 +1001,7 @@ export default function WorkspaceDrawer({ setArchiveExporting(true); try { const blob = await workspaceApi.downloadWorkspaceArchive(agentId); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = `workspace-${agentId}.zip`; - a.click(); - URL.revokeObjectURL(a.href); + await saveBlobAsFile(blob, `workspace-${agentId}.zip`); message.success(t("workspace.archiveExportSuccess")); } catch (err: unknown) { message.error( diff --git a/dashboard/src/pages/Chat/ChatFilePreviewContext.tsx b/dashboard/src/pages/Chat/ChatFilePreviewContext.tsx index db84ea7f90..4db5f82127 100644 --- a/dashboard/src/pages/Chat/ChatFilePreviewContext.tsx +++ b/dashboard/src/pages/Chat/ChatFilePreviewContext.tsx @@ -3,7 +3,7 @@ import type { KnowledgeCitation } from "../../utils/parseKnowledgeCitations"; interface ChatFilePreviewContextValue { /** Open the shared file panel on a workspace path (preview / download cards). */ - openFilePreview: (path: string) => void; + openFilePreview: (path: string, agentId?: string | null) => void; /** Open a knowledge citation in the shared chat dock (same shell as file tabs). */ openKnowledgeCitation: (citation: KnowledgeCitation) => void; } @@ -16,7 +16,7 @@ export function ChatFilePreviewProvider({ openKnowledgeCitation, children, }: { - openFilePreview: (path: string) => void; + openFilePreview: (path: string, agentId?: string | null) => void; openKnowledgeCitation: (citation: KnowledgeCitation) => void; children: ReactNode; }) { diff --git a/dashboard/src/pages/Chat/chatAgentCard.partial.less b/dashboard/src/pages/Chat/chatAgentCard.partial.less index 8948b02b0f..0b5ceb612d 100644 --- a/dashboard/src/pages/Chat/chatAgentCard.partial.less +++ b/dashboard/src/pages/Chat/chatAgentCard.partial.less @@ -24,6 +24,7 @@ align-items: center; justify-content: center; flex-shrink: 0; + overflow: hidden; } .agentCardInfo { @@ -119,6 +120,46 @@ white-space: nowrap; } +.remoteExpertFlag { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; + max-width: 12em; + padding: 0 5px; + height: 16px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.02em; + color: var(--fn-color-brand); + background: color-mix(in srgb, var(--fn-color-brand) 12%, transparent); + white-space: nowrap; + overflow: hidden; +} + +.remoteExpertFlagCompact { + max-width: none; + padding: 0 4px; + gap: 0; +} + +.remoteExpertIcon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + line-height: 0; + opacity: 0.9; +} + +.remoteExpertName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + .teamChatBadge { display: inline-flex; align-items: center; @@ -132,8 +173,8 @@ font-weight: 600; line-height: 1; letter-spacing: 0.02em; - color: #0f766e; - background: color-mix(in srgb, #0d9488 16%, transparent); + color: var(--fn-color-brand); + background: color-mix(in srgb, var(--fn-color-brand) 12%, transparent); white-space: nowrap; } @@ -237,6 +278,15 @@ margin-left: auto; } +.sessionRowLead { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 12px; + height: 12px; +} + .sessionRowIcon { flex-shrink: 0; opacity: 0.55; @@ -413,6 +463,7 @@ align-items: center; justify-content: center; flex-shrink: 0; + overflow: hidden; } .agentRowInfo { diff --git a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less index b684038bba..b6c75e3e49 100644 --- a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less +++ b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less @@ -469,6 +469,24 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; +} + +.dockFileTreeFileMeta { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; +} + +.dockFileTreeFileAgent { + font-size: 11px; + font-weight: 400; + color: var(--fn-text-tertiary, #8b8b8f); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .dockFileTreeDownload { diff --git a/dashboard/src/pages/Chat/chatInputCore.partial.less b/dashboard/src/pages/Chat/chatInputCore.partial.less index 2f90d6797e..b5a0f9061e 100644 --- a/dashboard/src/pages/Chat/chatInputCore.partial.less +++ b/dashboard/src/pages/Chat/chatInputCore.partial.less @@ -30,13 +30,25 @@ } } -.askQuestionDockInner { +.askQuestionDockInner, +.modelConfigDockInner { width: 100%; max-width: var(--chat-column-max, 960px); margin: 0 auto; box-sizing: border-box; } +.modelConfigDock { + flex-shrink: 0; + padding: 0 var(--chat-column-pad-x, 24px) 8px; + background: var(--fn-bg-elevated, #fff); + box-sizing: border-box; + + @media (max-width: 767px) { + padding: 0 var(--chat-column-pad-x-narrow, 12px) 6px; + } +} + .chatInput { flex-shrink: 0; padding: 16px var(--chat-column-pad-x, 32px) 20px; @@ -64,6 +76,7 @@ } .inputWrapper { + position: relative; max-width: var(--chat-column-max, 960px); min-width: 280px; margin: 0 auto; @@ -96,7 +109,7 @@ .actionsRow { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; padding-top: 6px; border-top: 1px solid var(--fn-border-secondary); margin-top: 4px; @@ -108,19 +121,67 @@ align-items: center; gap: 8px; min-width: 0; - flex: 1; + flex: 0 0 auto; + order: 1; padding-top: 4px; padding-right: 2px; } +.modeHint { + margin: 0; + display: inline-flex; + align-items: center; + gap: 5px; + flex: 0 1 auto; + order: 2; + min-width: 0; + overflow: hidden; + color: var(--fn-text-quaternary); + font-size: 12px; + line-height: 1.3; + white-space: nowrap; + text-overflow: ellipsis; + pointer-events: none; + user-select: none; + + svg { + flex-shrink: 0; + } + + span { + overflow: hidden; + text-overflow: ellipsis; + } +} + +.actionsRowCompact { + flex-wrap: wrap; + gap: 4px; + + .modeHint { + order: 0; + flex: 1 0 100%; + padding-top: 0; + padding-bottom: 2px; + } +} + @media (max-width: 767px) { .actionsRow { + flex-wrap: wrap; gap: 4px; } + .modeHint { + order: 0; + flex: 1 0 100%; + padding-top: 0; + padding-bottom: 2px; + } + .secondaryActions { gap: 4px; - flex: 1; + flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: visible; @@ -166,6 +227,8 @@ align-items: center; gap: 6px; flex-shrink: 0; + order: 3; + margin-left: auto; } .secondaryBtn { @@ -811,6 +874,30 @@ } } +.dropOverlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + border-radius: inherit; + border: 1.5px dashed var(--fn-color-brand); + background: color-mix( + in srgb, + var(--fn-bg-primary) 82%, + var(--fn-color-brand) + ); + color: var(--fn-color-brand); + font-size: 13px; + font-weight: 600; + line-height: 1.4; + text-align: center; + pointer-events: none; + user-select: none; +} + /* A right-docked workspace can narrow the chat without narrowing the viewport. Respond to the chat column itself so composer controls remain usable. */ @container chat-main (max-width: 560px) { @@ -828,11 +915,19 @@ } .actionsRow { + flex-wrap: wrap; gap: 4px; } + .modeHint { + order: 0; + flex: 1 0 100%; + padding-top: 0; + padding-bottom: 2px; + } + .secondaryActions { - flex: 0 1 auto; + flex: 1 1 auto; padding-right: 0; overflow-x: auto; scrollbar-width: none; diff --git a/dashboard/src/pages/Chat/chatInputPickers.partial.less b/dashboard/src/pages/Chat/chatInputPickers.partial.less index 09be71764e..50e8726d4a 100644 --- a/dashboard/src/pages/Chat/chatInputPickers.partial.less +++ b/dashboard/src/pages/Chat/chatInputPickers.partial.less @@ -53,6 +53,16 @@ } } +.modelMenuFooterMuted { + color: var(--fn-text-tertiary); + cursor: default; + + &:hover { + background: transparent; + color: var(--fn-text-tertiary); + } +} + .modelPickerPanel { display: flex; align-items: stretch; @@ -529,6 +539,14 @@ margin-left: auto; } +.expertPickerGroup { + padding: 8px 12px 4px; + font-size: 11px; + font-weight: 500; + color: var(--fn-text-tertiary); + letter-spacing: 0.02em; +} + .expertPickerItemActive { background: rgba(245, 158, 11, 0.1); @@ -641,6 +659,21 @@ white-space: nowrap; } +.expertRemoteBadge { + flex-shrink: 0; + max-width: 10em; + font-size: 11px; + font-weight: 600; + line-height: 16px; + padding: 0 6px; + border-radius: 4px; + color: #0f766e; + background: color-mix(in srgb, #0f766e 16%, transparent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .expertAgentAvatar { flex-shrink: 0; border-radius: 50%; @@ -864,17 +897,156 @@ } } -.compactPickerPanel { - min-width: 280px; +.plusMenuPopover { + :global(.ant-popover-content), + :global(.ant-popover-inner), + :global(.ant-popover-inner-content) { + overflow: visible; + height: auto; + } + + :global(.ant-popover-inner) { + padding: 0; + background: transparent; + box-shadow: none; + } +} + +.plusFlyout { + position: relative; + width: 220px; +} + +.plusFlyoutMenu, +.plusFlyoutPanel, +.plusFlyoutPanelInPlace { + background: var(--fn-bg-elevated, #fff); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +.plusFlyoutMenu { + width: 220px; + height: fit-content; +} + +.plusFlyoutPanel, +.plusFlyoutPanelInPlace { + display: flex; + flex-direction: column; + width: max-content; + min-width: 0; + height: fit-content; + + .modeMenu { + box-sizing: border-box; + padding: 4px; + } + + .modelPickerPanel { + display: flex; + flex-direction: column; + min-width: 240px; + max-width: 280px; + padding: 6px; + box-sizing: border-box; + } + + .modelMenuColumn { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + max-width: none; + } + + .modelMenu { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + max-width: none; + max-height: none; + overflow-y: auto; + } + + .modelMenuLabel { + word-break: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .skillPickerSearch, + .modelMenuDivider, + .modelMenuFooter { + flex-shrink: 0; + } + + .reasoningMenuPanel { + width: 100%; + min-width: 0; + max-height: none; + padding: 0; + overflow: auto; + } + + [data-picker-panel] { + display: flex; + flex-direction: column; + min-height: 0; + } + + [data-picker-search], + [data-picker-footer] { + flex-shrink: 0; + } + + [data-picker-list] { + flex: 1 1 auto; + min-height: 0; + max-height: none; + overflow-y: auto; + } +} + +.plusFlyoutPanel { + position: absolute; + top: 0; + left: calc(100% + 16px); + max-width: calc(100vw - 48px); +} + +.plusFlyoutPanelAlignBottom { + top: auto; + bottom: 0; +} + +.plusFlyoutPanelInPlace { + max-width: calc(100vw - 32px); + max-height: min(72vh, 560px); +} + +.plusFlyoutPanelBody { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + + > * { + flex: 1 1 auto; + min-height: 0; + } } .compactPickerBack { display: flex; align-items: center; - gap: 6px; + gap: 8px; width: 100%; min-height: 40px; - padding: 8px 10px; + padding: 8px 12px; border: none; border-bottom: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.06)); background: transparent; @@ -891,43 +1063,101 @@ .mobileOverflowMenu { display: flex; flex-direction: column; - padding: 4px 0 calc(8px + env(safe-area-inset-bottom)); + width: 100%; + padding: 4px 0; } .mobileOverflowItem { display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 8px; width: 100%; - min-height: 48px; - padding: 10px 16px; + min-height: 40px; + padding: 8px 12px; border: none; background: transparent; color: var(--fn-text-primary); cursor: pointer; text-align: left; + &:hover, &:active { background: var(--fn-bg-hover, rgba(0, 0, 0, 0.04)); } } +.mobileOverflowItemActive { + background: color-mix(in srgb, var(--fn-color-brand) 8%, transparent); + + &:hover, + &:active { + background: color-mix(in srgb, var(--fn-color-brand) 12%, transparent); + } +} + .mobileOverflowItemMain { display: inline-flex; align-items: center; - gap: 10px; + gap: 8px; min-width: 0; - font-size: 14px; + flex: 1; + font-size: 13px; font-weight: 500; + + svg { + flex-shrink: 0; + } } .mobileOverflowItemMeta { display: inline-flex; align-items: center; + justify-content: flex-end; gap: 8px; flex-shrink: 0; color: var(--fn-text-quaternary); + + svg { + flex-shrink: 0; + } +} + +.mobileOverflowItemMetaLabel { + max-width: 88px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobileOverflowItemCount { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 16px; + height: 16px; + padding: 0 5px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + line-height: 16px; + color: #fff; + background: #2563eb; + position: relative; + top: 1px; +} + +.mobileOverflowItemCountExpert { + background: #d97706; +} + +.mobileOverflowItemCountSubagent { + background: #0d9488; +} + +.mobileOverflowItemCountText { + text-box: trim-both cap alphabetic; } .connectorMenuIcon { diff --git a/dashboard/src/pages/Chat/chatMessages.partial.less b/dashboard/src/pages/Chat/chatMessages.partial.less index cb99a646a3..c4fbaa9520 100644 --- a/dashboard/src/pages/Chat/chatMessages.partial.less +++ b/dashboard/src/pages/Chat/chatMessages.partial.less @@ -119,6 +119,21 @@ animation: scrollBtnEnter 0.24s cubic-bezier(0.34, 1.56, 0.64, 1) both; } +.scrollToBottomBtnHasNew { + color: var(--fn-color-brand); + border-color: color-mix(in srgb, var(--fn-color-brand) 35%, transparent); + background: color-mix( + in srgb, + var(--fn-color-brand) 8%, + var(--fn-bg-elevated, var(--fn-bg-primary)) + ); + + &:hover { + color: var(--fn-color-brand); + border-color: color-mix(in srgb, var(--fn-color-brand) 50%, transparent); + } +} + /* Hidden state — exits downward, pointer-events disabled to avoid ghost clicks */ .scrollToBottomBtnHidden { opacity: 0; @@ -170,7 +185,7 @@ justify-content: center; gap: 8px; padding: 12px 0 16px; - color: var(--text-secondary, #888); + color: var(--fn-text-secondary); font-size: 12px; } @@ -363,11 +378,22 @@ display: inline-flex; align-items: center; justify-content: center; - background: var(--fn-color-brand); - color: #fff; - font-size: 15px; - font-weight: 600; + overflow: hidden; + flex-shrink: 0; user-select: none; + + img, + svg { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + svg { + width: 20px; + height: 20px; + } } .avatarCol { @@ -664,6 +690,51 @@ } } +.inlineToolSummaryRunning { + border-color: color-mix(in srgb, var(--fn-color-brand) 28%, transparent); + background: color-mix( + in srgb, + var(--fn-color-brand) 5%, + var(--fn-bg-secondary, #f8fafc) + ); + animation: inlineToolRunningPulse 1.6s ease-in-out infinite; + + .inlineToolIcon { + color: var(--fn-color-brand); + } + + .inlineToolStatus { + color: var(--fn-color-brand); + } +} + +@keyframes inlineToolRunningPulse { + 0%, + 100% { + box-shadow: 0 0 0 0 + color-mix(in srgb, var(--fn-color-brand) 0%, transparent); + } + 50% { + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--fn-color-brand) 12%, transparent); + } +} + +.inlineToolStatusLive { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + flex-shrink: 0; + color: var(--fn-color-brand); + font-size: 12px; + + .thinkingDot { + width: 5px; + height: 5px; + } +} + .inlineToolIcon { display: inline-flex; align-items: center; @@ -1026,6 +1097,26 @@ min-width: 0; } +.teamWrapupTurn { + margin-top: 6px; + padding-top: 10px; + border-top: 1px dashed var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); +} + +.teamWrapupBadge { + display: inline-flex; + align-items: center; + margin: 0 0 6px; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + background: var(--fn-bg-secondary, rgba(0, 0, 0, 0.03)); + color: var(--fn-text-tertiary); + font-size: 11px; + line-height: 1.6; + letter-spacing: 0.02em; +} + .knowledgeCitations { display: flex; flex-direction: column; @@ -1182,11 +1273,41 @@ } } +.processSummaryToggleLive { + color: var(--fn-text-secondary); + + &:hover { + color: var(--fn-text-primary); + } +} + .processSummaryText { flex: 1; min-width: 0; } +.processLiveHint { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--fn-color-brand); + + .thinkingDot { + width: 5px; + height: 5px; + flex-shrink: 0; + } +} + +.processLiveLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + animation: thinkingFadeInOut 2s ease-in-out infinite; +} + .processSummaryChevron { flex-shrink: 0; color: var(--fn-text-quaternary); diff --git a/dashboard/src/pages/Chat/chatSidebar.partial.less b/dashboard/src/pages/Chat/chatSidebar.partial.less index fe3bc53902..fba8dbaf80 100644 --- a/dashboard/src/pages/Chat/chatSidebar.partial.less +++ b/dashboard/src/pages/Chat/chatSidebar.partial.less @@ -150,7 +150,7 @@ .sessionItems { flex: 1; overflow-y: auto; - padding: 0 8px 12px; + padding: 0 0 12px; } .sessionItem { diff --git a/dashboard/src/pages/Chat/chatThemeOverrides.partial.less b/dashboard/src/pages/Chat/chatThemeOverrides.partial.less index 5b572742bf..8a10094b9b 100644 --- a/dashboard/src/pages/Chat/chatThemeOverrides.partial.less +++ b/dashboard/src/pages/Chat/chatThemeOverrides.partial.less @@ -91,7 +91,8 @@ } .sessionList { - padding: 22px 12px 10px; + /* No right padding — .sessionItems is the scrollport and must reach the rail edge. */ + padding: 22px 0 10px 12px; display: flex; flex-direction: column; gap: 0; @@ -205,6 +206,7 @@ display: inline-flex; align-items: center; justify-content: center; + overflow: hidden; transition: opacity var(--fn-transition-fast); } @@ -274,6 +276,20 @@ gap: 0; /* Align session title with agent name (8 + 22 + 6 − row pad 10). */ padding: 1px 4px 4px 26px; + + /* Status icon sits in the indent, not in the title flex track. */ + .sessionRowCompact { + position: relative; + } + + .sessionRowLead { + position: absolute; + left: -20px; + top: 50%; + transform: translateY(-50%); + width: 14px; + height: 14px; + } } .minimalAgentEmpty { @@ -283,29 +299,34 @@ color: var(--fn-text-tertiary); } -.sessionRowWorkingDot { +.sessionRowWorkingSpinner, +.sessionRowWaitingIcon { + display: inline-flex; + align-items: center; + justify-content: center; flex-shrink: 0; - width: 6px; - height: 6px; - border-radius: 999px; - background: var(--fn-color-brand); - animation: sessionWorkingPulse 1.2s ease-in-out infinite; + width: 12px; + height: 12px; + color: var(--fn-color-brand); } -@keyframes sessionWorkingPulse { - 0%, - 100% { - opacity: 0.25; - } - 50% { - opacity: 1; +.sessionRowWorkingSpinner { + animation: sessionWorkingSpin 0.8s linear infinite; +} + +.sessionRowWaitingIcon { + color: var(--fn-text-tertiary); +} + +@keyframes sessionWorkingSpin { + to { + transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { - .sessionRowWorkingDot { + .sessionRowWorkingSpinner { animation: none; - opacity: 1; } } @@ -338,7 +359,7 @@ .sessionSearchWrap { position: relative; flex-shrink: 0; - margin: 0 0 10px; + margin: 0 12px 10px 0; } .sessionSearchIcon { @@ -429,12 +450,18 @@ } .sessionItems { flex: 1; + min-width: 0; overflow-y: auto; padding: 0; display: flex; flex-direction: column; gap: 4px; - scrollbar-gutter: stable; +} + +/* Keep rows inset; width:auto so 100% children do not overflow the right margin. */ +.sessionItems > * { + margin-right: 12px; + width: auto; } .sessionItem { diff --git a/dashboard/src/pages/Chat/components/AgentNotReadyScreen.tsx b/dashboard/src/pages/Chat/components/AgentNotReadyScreen.tsx index 55598a825f..e15915fbc2 100644 --- a/dashboard/src/pages/Chat/components/AgentNotReadyScreen.tsx +++ b/dashboard/src/pages/Chat/components/AgentNotReadyScreen.tsx @@ -66,11 +66,26 @@ export default function AgentNotReadyScreen({ const state = agent.state; const errorText = formatAgentError(agent.last_error, t); const isModelError = isAgentModelConfigError(agent.last_error); + const disconnected = Boolean(agent.bridge && agent.bridge_disconnected); let title = t("chat.agentNotRunning"); let subTitle = t("chat.agentNotRunningHint"); - if (state === "failed") { + if (disconnected) { + const inbound = Boolean(agent.bridge_inbound); + title = t("chat.remoteExpert.disconnectedTitle"); + subTitle = inbound + ? agent.bridge_connection_name + ? t("chat.remoteExpert.waitForPeerNamed", { + name: agent.bridge_connection_name, + }) + : t("chat.remoteExpert.waitForPeer") + : agent.bridge_connection_name + ? t("chat.remoteExpert.disconnectedHintNamed", { + name: agent.bridge_connection_name, + }) + : t("chat.remoteExpert.disconnectedHint"); + } else if (state === "failed") { title = t("chat.agentFailed"); subTitle = errorText || t("chat.agentFailedHint"); } else if (state === "stopped" || state === "created") { @@ -85,7 +100,9 @@ export default function AgentNotReadyScreen({
navigate("/bridge")}> + {t( + agent.bridge_inbound + ? "chat.remoteExpert.openBridge" + : "chat.remoteExpert.reconnect", + )} + + ) : isModelError ? ( @@ -113,15 +124,21 @@ function TreeNodes({ onOpenFile, onDownload, downloadLabel, + defaultAgentId, + agentNameById, + showAgentLabels, }: { nodes: DockPathTreeNode[]; depth: number; expanded: Set; toggle: (path: string) => void; downloading: string | null; - onOpenFile: (path: string) => void; - onDownload: (path: string) => void; + onOpenFile: (path: string, agentId?: string | null) => void; + onDownload: (path: string, agentId?: string | null) => void; downloadLabel: string; + defaultAgentId: string; + agentNameById?: Record; + showAgentLabels: boolean; }) { return ( <> @@ -146,20 +163,30 @@ function TreeNodes({ onOpenFile={onOpenFile} onDownload={onDownload} downloadLabel={downloadLabel} + defaultAgentId={defaultAgentId} + agentNameById={agentNameById} + showAgentLabels={showAgentLabels} /> ) : null}
); } + const fileAgent = node.agentId || defaultAgentId; + const downloadKey = `${fileAgent}\0${node.path}`; + const agentLabel = + showAgentLabels && fileAgent + ? agentNameById?.[fileAgent] || fileAgent + : undefined; return ( onOpenFile(node.path)} - onDownload={() => onDownload(node.path)} + downloading={downloading === downloadKey} + onOpen={() => onOpenFile(node.path, fileAgent)} + onDownload={() => onDownload(node.path, fileAgent)} downloadLabel={downloadLabel} + agentLabel={agentLabel} /> ); })} @@ -174,16 +201,22 @@ export default function ChatDockFileList({ agentId, filePaths, onOpenFile, + agentNameById, }: ChatDockFileListProps) { const { t } = useTranslation(); - const paths = useMemo( + const refs = useMemo( () => listDockFilePathsForTree(filePaths, agentId), [filePaths, agentId], ); - const tree = useMemo( - () => buildDockPathTree(paths, agentId), - [paths, agentId], - ); + const showAgentLabels = useMemo(() => { + const ids = new Set( + refs.map((ref) => (ref.agentId || agentId || "").trim()).filter(Boolean), + ); + if (ids.size > 1) return true; + const only = [...ids][0]; + return Boolean(only && only !== agentId); + }, [refs, agentId]); + const tree = useMemo(() => buildDockPathTree(refs, agentId), [refs, agentId]); const [expanded, setExpanded] = useState>(() => new Set()); const [downloading, setDownloading] = useState(null); const seenFoldersRef = useRef>(new Set()); @@ -212,22 +245,20 @@ export default function ChatDockFileList({ }, []); const handleDownload = useCallback( - async (path: string) => { - if (!agentId || !path) return; - setDownloading(path); + async (path: string, fileAgentId?: string | null) => { + const owner = (fileAgentId || agentId || "").trim(); + if (!owner || !path) return; + const downloadKey = `${owner}\0${path}`; + setDownloading(downloadKey); try { const blob = await requestBlob( `/agents/${encodeURIComponent( - agentId, + owner, )}/workspace/download?path=${encodeURIComponent( - toDockWorkspaceApiPath(path, agentId), + toDockWorkspaceApiPath(path, owner), )}`, ); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = dockFileBasename(path) || "download"; - a.click(); - URL.revokeObjectURL(a.href); + await saveBlobAsFile(blob, dockFileBasename(path) || "download"); } catch (err: unknown) { if (isNotFoundApiError(err)) { message.warning( @@ -266,7 +297,7 @@ export default function ChatDockFileList({
); - if (paths.length === 0) { + if (refs.length === 0) { return (
{listHint} @@ -291,7 +322,7 @@ export default function ChatDockFileList({
{t("chat.dockFileListCount", { - count: paths.length, + count: refs.length, defaultValue: "{{count}} 个文件", })}
@@ -303,8 +334,11 @@ export default function ChatDockFileList({ toggle={toggle} downloading={downloading} onOpenFile={onOpenFile} - onDownload={(p) => void handleDownload(p)} + onDownload={(p, aid) => void handleDownload(p, aid)} downloadLabel={downloadLabel} + defaultAgentId={agentId} + agentNameById={agentNameById} + showAgentLabels={showAgentLabels} />
diff --git a/dashboard/src/pages/Chat/components/ChatDockPanel.tsx b/dashboard/src/pages/Chat/components/ChatDockPanel.tsx index 8576ad8015..4c0d639026 100644 --- a/dashboard/src/pages/Chat/components/ChatDockPanel.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockPanel.tsx @@ -60,13 +60,16 @@ interface ChatDockPanelProps { onClose: () => void; style?: React.CSSProperties; agentId: string; - filePaths: string[]; + filePaths: Array; + agentNameById?: Record; openTabs: DockTab[]; activeTabId: DockTabId | null; onSelectTab: (id: DockTabId) => void; onCloseTab: (id: DockTabId) => void; - onOpenFile: (path: string) => void; + onOpenFile: (path: string, agentId?: string | null) => void; browserEnvironment?: DisplayEnvironment; + /** When set, Chat dock browser talks to the peer harness via Bridge. */ + bridgeConnectionId?: string | null; threadId?: string | null; isStreamingTurn?: boolean; /** @@ -192,12 +195,14 @@ const ChatDockPanel: React.FC = ({ style, agentId, filePaths, + agentNameById, openTabs, activeTabId, onSelectTab, onCloseTab, onOpenFile, browserEnvironment = "desktop", + bridgeConnectionId = null, threadId = null, isStreamingTurn = false, surfaceVisible = true, @@ -215,9 +220,9 @@ const ChatDockPanel: React.FC = ({ const [terminalMounted, setTerminalMounted] = useState( openTabs.some((tab) => tab.kind === "terminal"), ); - const [mountedFilePaths, setMountedFilePaths] = useState(() => - openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), - ); + const [mountedFileTabs, setMountedFileTabs] = useState< + Extract[] + >(() => openTabs.filter((tab) => tab.kind === "file")); const [mountedKnowledgeTabs, setMountedKnowledgeTabs] = useState< Extract[] >(() => openTabs.filter((tab) => tab.kind === "knowledge")); @@ -225,7 +230,7 @@ const ChatDockPanel: React.FC = ({ () => openTabs.filter((tab) => tab.kind === "toolUi").map((tab) => tab.callId), ); - const [fileActionsByPath, setFileActionsByPath] = useState< + const [fileActionsById, setFileActionsById] = useState< Record >({}); const [knowledgeActionsById, setKnowledgeActionsById] = useState< @@ -246,8 +251,13 @@ const ChatDockPanel: React.FC = ({ setWorkspaceMounted(hasWorkspace); setBrowserMounted(hasBrowser); setTerminalMounted(hasTerminal); - const openFilePaths = new Set( - openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), + const openFileById = new Map( + openTabs + .filter( + (tab): tab is Extract => + tab.kind === "file", + ) + .map((tab) => [tab.id, tab]), ); const openKnowledgeById = new Map( openTabs @@ -260,12 +270,15 @@ const ChatDockPanel: React.FC = ({ const openToolUiCallIds = new Set( openTabs.filter((tab) => tab.kind === "toolUi").map((tab) => tab.callId), ); - setMountedFilePaths((prev) => { - const next = prev.filter((path) => openFilePaths.has(path)); - let changed = next.length !== prev.length; - for (const path of openFilePaths) { - if (!next.includes(path)) { - next.push(path); + setMountedFileTabs((prev) => { + const next = prev + .filter((tab) => openFileById.has(tab.id)) + .map((tab) => openFileById.get(tab.id) ?? tab); + let changed = + next.length !== prev.length || next.some((tab, i) => tab !== prev[i]); + for (const tab of openFileById.values()) { + if (!next.some((row) => row.id === tab.id)) { + next.push(tab); changed = true; } } @@ -296,15 +309,15 @@ const ChatDockPanel: React.FC = ({ } return changed ? next : prev; }); - setFileActionsByPath((prev) => { + setFileActionsById((prev) => { let changed = false; const next: Record = {}; - for (const path of Object.keys(prev)) { - if (openFilePaths.has(path)) { - next[path] = prev[path]; + for (const id of Object.keys(prev)) { + if (openFileById.has(id)) { + next[id] = prev[id]; } else { changed = true; - delete fileActionsHandlersRef.current[path]; + delete fileActionsHandlersRef.current[id]; } } return changed ? next : prev; @@ -328,22 +341,22 @@ const ChatDockPanel: React.FC = ({ browserRefreshRef.current = refresh; }, []); - const getFileActionsHandler = useCallback((path: string) => { - const existing = fileActionsHandlersRef.current[path]; + const getFileActionsHandler = useCallback((tabId: string) => { + const existing = fileActionsHandlersRef.current[tabId]; if (existing) return existing; const handler = (actions: ReactNode | null) => { - setFileActionsByPath((prev) => { + setFileActionsById((prev) => { if (actions == null) { - if (!(path in prev)) return prev; + if (!(tabId in prev)) return prev; const next = { ...prev }; - delete next[path]; + delete next[tabId]; return next; } - if (prev[path] === actions) return prev; - return { ...prev, [path]: actions }; + if (prev[tabId] === actions) return prev; + return { ...prev, [tabId]: actions }; }); }; - fileActionsHandlersRef.current[path] = handler; + fileActionsHandlersRef.current[tabId] = handler; return handler; }, []); @@ -366,7 +379,9 @@ const ChatDockPanel: React.FC = ({ return handler; }, []); - const sessionId = resolveBrowserProfile(currentUser?.id); + const sessionId = bridgeConnectionId + ? null + : resolveBrowserProfile(currentUser?.id); const activeTab = openTabs.find((tab) => tab.id === activeTabId) ?? openTabs[0] ?? null; const terminalVisible = surfaceVisible && activeTab?.kind === "terminal"; @@ -416,7 +431,22 @@ const ChatDockPanel: React.FC = ({ ) : ( <> - {dockFileBasename(tab.path)} + + {dockFileBasename(tab.path)} + {tab.agentId && + tab.agentId !== agentId && + (agentNameById?.[tab.agentId] || tab.agentId) + ? ` · ${agentNameById?.[tab.agentId] || tab.agentId}` + : ""} + ); return ( @@ -478,13 +508,13 @@ const ChatDockPanel: React.FC = ({ ); } if (activeTab?.kind === "file") { - return fileActionsByPath[activeTab.path] ?? null; + return fileActionsById[activeTab.id] ?? null; } if (activeTab?.kind === "knowledge") { return knowledgeActionsById[activeTab.id] ?? null; } return null; - }, [activeTab, fileActionsByPath, knowledgeActionsById, t]); + }, [activeTab, fileActionsById, knowledgeActionsById, t]); return ( = ({
)} - {mountedFilePaths.map((path) => { + {mountedFileTabs.map((tab) => { const isActive = - activeTab?.kind === "file" && activeTab.path === path; + activeTab?.kind === "file" && activeTab.id === tab.id; + const fileAgent = tab.agentId || agentId; return ( ); @@ -578,6 +610,7 @@ const ChatDockPanel: React.FC = ({ ; + agentNameById?: Record; openTabs: DockTab[]; activeTabId: DockTabId | null; onSelectTab: (id: DockTabId) => void; onCloseTab: (id: DockTabId) => void; - onOpenFile: (path: string) => void; + onOpenFile: (path: string, agentId?: string | null) => void; browserEnvironment: DisplayEnvironment; + bridgeConnectionId?: string | null; threadId?: string | null; isStreamingTurn?: boolean; onModeChange: (mode: PanelMode) => void; @@ -47,12 +49,14 @@ export default function ChatDockPanels({ panelSizes, agentId, filePaths, + agentNameById, openTabs, activeTabId, onSelectTab, onCloseTab, onOpenFile, browserEnvironment, + bridgeConnectionId = null, threadId = null, isStreamingTurn = false, onModeChange, @@ -84,12 +88,14 @@ export default function ChatDockPanels({ } agentId={agentId} filePaths={filePaths} + agentNameById={agentNameById} openTabs={openTabs} activeTabId={activeTabId} onSelectTab={onSelectTab} onCloseTab={onCloseTab} onOpenFile={onOpenFile} browserEnvironment={browserEnvironment} + bridgeConnectionId={bridgeConnectionId} threadId={threadId} isStreamingTurn={isStreamingTurn} surfaceVisible={visible} diff --git a/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx b/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx index aec31504e6..13f64f4d2f 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx @@ -17,12 +17,20 @@ vi.mock("../../../hooks/useVoiceInput", () => ({ useVoiceInput: () => ({ recording: false, transcribing: false, + realtime: false, + start: vi.fn(), + stop: vi.fn(), toggle: vi.fn(), }), })); -vi.mock("../../../hooks/useKeyboardOffset", () => ({ - useKeyboardOffset: () => undefined, +vi.mock("../../../hooks/useKeepInVisualViewport", () => ({ + useKeepInVisualViewport: () => undefined, +})); + +vi.mock("../../../hooks/viewport", () => ({ + isPwaDisplay: () => false, + needsComposerVisualViewportFix: () => false, })); vi.mock("../hooks/useChatAttachments", () => ({ @@ -31,7 +39,9 @@ vi.mock("../hooks/useChatAttachments", () => ({ uploading: false, dragOver: false, fileInputRef: { current: null }, + folderInputRef: { current: null }, handleFileSelect: vi.fn(), + handleFolderSelect: vi.fn(), handleFileChange: vi.fn(), removeAttachment: vi.fn(), clearAttachments: vi.fn(), diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx index 9453ac8a5f..257688728e 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.tsx @@ -11,6 +11,11 @@ import { useTranslation } from "react-i18next"; import { App } from "antd"; import { useIsMobile } from "../../../hooks/useIsMobile"; +import { + isPwaDisplay, + needsComposerVisualViewportFix, +} from "../../../hooks/viewport"; +import { useKeepInVisualViewport } from "../../../hooks/useKeepInVisualViewport"; import { useSlashCommands } from "../../../hooks/useSlashCommands"; import SlashCommandMenu from "./SlashCommandMenu"; import { agentChatApi } from "../../../api/modules/agentChat"; @@ -25,7 +30,6 @@ import ChatInputPreviewBar from "./ChatInputPreviewBar"; import ChatInputActionsRow from "./ChatInputActionsRow"; import ChatQueuedMessages from "./ChatQueuedMessages"; import { useVoiceInput } from "../../../hooks/useVoiceInput"; -import { useKeyboardOffset } from "../../../hooks/useKeyboardOffset"; import { useChatAttachments } from "../hooks/useChatAttachments"; import { useSlashMentionInput } from "../hooks/useSlashMentionInput"; import { stripThinkingTags } from "../utils/chatAttachments"; @@ -187,7 +191,26 @@ const ChatInput = forwardRef( const { commands: slashCommands, labelFor } = useSlashCommands("ui"); const skillDisplayName = useSkillDisplayName(); const isMobile = useIsMobile(); - useKeyboardOffset(); + const shellRef = useRef(null); + const [nativeDropActive, setNativeDropActive] = useState(false); + useEffect(() => { + const el = shellRef.current; + if (!el) return; + const sync = () => { + setNativeDropActive(el.classList.contains("file-drop-target-active")); + }; + const observer = new MutationObserver(sync); + observer.observe(el, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + const keepComposerInView = + typeof window !== "undefined" && + !isPwaDisplay() && + (isMobile || needsComposerVisualViewportFix()); + useKeepInVisualViewport(shellRef, keepComposerInView); const [text, setText] = useState( () => initialText || readInputDraft(agentId, threadId), ); @@ -205,9 +228,19 @@ const ChatInput = forwardRef( setText(value); }, []); - const handleVoiceText = useCallback( + const textRef = useRef(text); + useEffect(() => { + textRef.current = text; + }, [text]); + // Text that existed before the current voice session started; the session's + // (possibly still refining) transcript is appended to it on every update. + const voiceBaselineRef = useRef(""); + + const applyVoiceText = useCallback( (spoken: string) => { - setText((prev) => (prev.trim() ? `${prev.trim()} ${spoken}` : spoken)); + const base = voiceBaselineRef.current.trim(); + const next = spoken.trim(); + setText(base ? (next ? `${base} ${next}` : base) : next); userHasEditedRef.current = true; onUserInput?.(); }, @@ -216,15 +249,26 @@ const ChatInput = forwardRef( const { recording, transcribing, + realtime: realtimeVoice, + start: startVoice, + stop: stopVoice, toggle: toggleVoice, - } = useVoiceInput(handleVoiceText); + } = useVoiceInput({ + onStart: () => { + voiceBaselineRef.current = textRef.current; + }, + onInterim: applyVoiceText, + onText: applyVoiceText, + }); const textareaRef = useRef(null); const { attachments, uploading, dragOver, fileInputRef, + folderInputRef, handleFileSelect, + handleFolderSelect, handleFileChange, removeAttachment, clearAttachments, @@ -712,10 +756,15 @@ const ChatInput = forwardRef( const canSend = Boolean( (text.trim() || attachments.length > 0) && !disabled, ); + const showDrop = dragOver || nativeDropActive; return (
( /> )}
+ {showDrop && ( +
+ {t("upload.dropHint", "Drop files or a folder to upload")} +
+ )} ( rows={1} disabled={disabled} enterKeyHint={isMobile ? "enter" : undefined} - // A11Y-11: expose the slash/mention popups as an ARIA combobox. - aria-label={t("chat.inputLabel", "Message input")} - aria-expanded={slashMenuOpen || mentionMenuOpen} - aria-haspopup="listbox" - aria-controls={ - slashMenuOpen || mentionMenuOpen - ? "octop-chat-pick-menu" - : undefined - } /> {/* Slash badge (plan §14.5): octop's HarnessProcessor handles slash @@ -851,6 +896,7 @@ const ChatInput = forwardRef( ( uploading={uploading} recording={recording} transcribing={transcribing} + voiceHoldToTalk={realtimeVoice} + onVoiceStart={() => void startVoice()} + onVoiceStop={() => void stopVoice()} browserRecording={browserRecording} browserReplayBusy={browserReplayBusy} browserLastRecordingId={browserLastRecordingId} @@ -897,6 +946,7 @@ const ChatInput = forwardRef( slashMenuItems={slashMenuItems} onSlashShortcutSelect={handleSlashSelect} onFileSelect={handleFileSelect} + onFolderSelect={handleFolderSelect} onNewChat={onNewChat} onPolish={() => void handlePolish()} onToggleVoice={() => toggleVoice()} @@ -911,6 +961,13 @@ const ChatInput = forwardRef( style={{ display: "none" }} onChange={handleFileChange} /> +
{!isMobile && (

{t("chatWelcome.aiDisclaimer")}

diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx index a63436dd76..b515b69255 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx @@ -1,9 +1,20 @@ -import { fireEvent, render, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; import type { ResolvedModel } from "../../../api/types"; import ChatInputActionsRow from "./ChatInputActionsRow"; +// `isSttAvailable()` runs at module load, so the browser APIs it probes must +// exist before this module graph is imported. +vi.hoisted(() => { + vi.stubGlobal("MediaRecorder", class {}); + Object.defineProperty(navigator, "mediaDevices", { + value: { getUserMedia: () => Promise.resolve() }, + configurable: true, + }); + return true; +}); + vi.mock("./ContextWindowRing", () => ({ default: () => null, })); @@ -19,50 +30,287 @@ const models: ResolvedModel[] = [ }, ]; -describe("ChatInputActionsRow compact pickers", () => { - it("uses a popover instead of a full-width drawer on narrow desktop", async () => { - const { container } = render( +const baseProps = { + isMobile: false, + isStreaming: false, + canSend: false, + text: "", + polishing: false, + uploading: false, + recording: false, + transcribing: false, + availableModels: models, + onModelChange: vi.fn(), + onConversationModeChange: vi.fn(), + onHitlPolicyChange: vi.fn(), + slashPickerGroups: null, + slashMenuItems: [], + onSlashShortcutSelect: vi.fn(), + onFileSelect: vi.fn(), + onFolderSelect: vi.fn(), + onNewChat: vi.fn(), + onPolish: vi.fn(), + onToggleVoice: vi.fn(), + onCancel: vi.fn(), + onSubmit: vi.fn(), +}; + +describe("ChatInputActionsRow plus menu", () => { + it("keeps approval, shortcuts, and attachments on the toolbar", () => { + render( - + + , + ); + + expect(screen.getByTestId("composer-plus")).toBeInTheDocument(); + expect(screen.getByTestId("hitl-policy-picker")).toBeInTheDocument(); + expect(screen.getByLabelText("快捷指令")).toBeInTheDocument(); + expect(screen.getByLabelText("Upload")).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: "Folder" })).toBeNull(); + expect( + screen.queryByTestId("conversation-mode-picker"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-mode-hint"), + ).not.toBeInTheDocument(); + }); + + it("starts a folder upload from the toolbar", () => { + const onFolderSelect = vi.fn(); + render( + + + , + ); + + fireEvent.click(screen.getByLabelText("Upload")); + fireEvent.click(screen.getByRole("menuitem", { name: "Folder" })); + expect(onFolderSelect).toHaveBeenCalledTimes(1); + }); + + it("starts a file upload from the same menu", () => { + const onFileSelect = vi.fn(); + render( + + , ); - const modelButton = container - .querySelector("svg.lucide-cpu") - ?.closest("button"); - expect(modelButton).not.toBeNull(); - expect(modelButton).not.toHaveTextContent("Auto"); - expect(modelButton).not.toHaveTextContent("Compact Model"); - expect(modelButton).not.toHaveTextContent("compact-model"); + fireEvent.click(screen.getByLabelText("Upload")); + fireEvent.click(screen.getByRole("menuitem", { name: "Files" })); + expect(onFileSelect).toHaveBeenCalledTimes(1); + }); + + it("shows a non-interactive Ask/Plan reminder in the toolbar gap", () => { + render( + + + , + ); + + const hint = screen.getByTestId("conversation-mode-hint"); + expect(hint).toHaveTextContent("chat.conversationMode.planReminder"); + expect(hint.parentElement?.className).not.toMatch(/actionsRowCompact/); + expect( + screen.queryByTestId("conversation-mode-picker"), + ).not.toBeInTheDocument(); + }); + + it("keeps the Ask/Plan reminder on mobile without a toolbar button", () => { + render( + + + , + ); + + const hint = screen.getByTestId("conversation-mode-hint"); + expect(hint).toHaveTextContent("chat.conversationMode.askReminder"); + expect(hint.parentElement?.className).toMatch(/actionsRowCompact/); + expect( + screen.queryByTestId("conversation-mode-picker"), + ).not.toBeInTheDocument(); + }); + + it("hides the approval picker when HITL policy cannot be changed", () => { + render( + + + , + ); + + expect(screen.queryByTestId("hitl-policy-picker")).not.toBeInTheDocument(); + expect(screen.getByLabelText("快捷指令")).toBeInTheDocument(); + }); + + it("opens a flyout beside the plus menu instead of a window drawer", async () => { + render( + + + , + ); - fireEvent.click(modelButton!); + fireEvent.click(screen.getByTestId("composer-plus")); await waitFor(() => { expect(document.querySelector(".ant-popover")).toBeInTheDocument(); }); - const popover = document.querySelector(".ant-popover"); - expect(popover?.querySelector("svg.lucide-sparkles")).not.toBeNull(); - expect(popover?.querySelector("img")).not.toBeNull(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.queryByTestId("composer-plus-panel")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("Model")); + + const panel = await screen.findByTestId("composer-plus-panel"); + expect(panel.getAttribute("data-align")).toMatch(/^(top|bottom)$/); + expect(panel.querySelector("img")).not.toBeNull(); + const menu = document.querySelector("[class*='plusFlyoutMenu']"); + if (menu && menu.getBoundingClientRect().height > 0) { + expect( + Number.parseFloat(getComputedStyle(panel).maxHeight), + ).toBeGreaterThanOrEqual(Math.round(menu.getBoundingClientRect().height)); + } expect(document.querySelector(".ant-drawer-content")).toBeNull(); + expect(document.querySelector(".ant-popover")).toBeInTheDocument(); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search models")).toBeInTheDocument(); + }); + + it("filters models from the plus-menu search box", async () => { + const extraModels: ResolvedModel[] = [ + ...models, + { + provider_id: 2, + provider_name: "Other", + provider_kind: "openai", + model: "other-model", + name: "Other Model", + context_window: 128_000, + }, + ]; + render( + + + , + ); + + fireEvent.click(screen.getByTestId("composer-plus")); + fireEvent.click(await screen.findByText("Model")); + expect( + await screen.findByText("Provider / Compact Model"), + ).toBeInTheDocument(); + expect(screen.getByText("Other / Other Model")).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("Search models"), { + target: { value: "compact" }, + }); + expect(screen.getByText("Provider / Compact Model")).toBeInTheDocument(); + expect(screen.queryByText("Other / Other Model")).not.toBeInTheDocument(); + }); + + it("shows a search box in the skill picker", async () => { + render( + + + , + ); + + fireEvent.click(screen.getByTestId("composer-plus")); + fireEvent.click(await screen.findByText("chat.skillPicker")); + expect( + await screen.findByPlaceholderText("Search skills"), + ).toBeInTheDocument(); + }); +}); + +function renderRow(overrides: Record = {}) { + const handlers = { + onVoiceStart: vi.fn(), + onVoiceStop: vi.fn(), + onToggleVoice: vi.fn(), + }; + const view = render( + + + , + ); + const mic = view.container + .querySelector("svg.lucide-mic") + ?.closest("button") as HTMLButtonElement; + return { ...view, ...handlers, mic }; +} + +describe("ChatInputActionsRow voice button", () => { + it("dictates while held down when realtime STT is on", () => { + const { mic, onVoiceStart, onVoiceStop, onToggleVoice } = renderRow({ + voiceHoldToTalk: true, + }); + + fireEvent.pointerDown(mic); + expect(onVoiceStart).toHaveBeenCalledTimes(1); + + fireEvent.pointerUp(mic); + expect(onVoiceStop).toHaveBeenCalledTimes(1); + + // A plain click must not toggle the recorder in hold-to-talk mode. + fireEvent.click(mic); + expect(onToggleVoice).not.toHaveBeenCalled(); + expect(onVoiceStart).toHaveBeenCalledTimes(1); + }); + + it("finishes when the pointer slides off the button", () => { + const { mic, onVoiceStop } = renderRow({ voiceHoldToTalk: true }); + + fireEvent.pointerDown(mic); + fireEvent.pointerLeave(mic); + fireEvent.pointerUp(mic); + + expect(onVoiceStop).toHaveBeenCalledTimes(1); + }); + + it("keeps click-to-toggle when realtime STT is off", () => { + const { mic, onToggleVoice, onVoiceStart } = renderRow(); + + fireEvent.click(mic); + expect(onToggleVoice).toHaveBeenCalledTimes(1); + + fireEvent.pointerDown(mic); + expect(onVoiceStart).not.toHaveBeenCalled(); + }); + + it("stays available while a reply is still streaming", () => { + const { mic } = renderRow({ voiceHoldToTalk: true, isStreaming: true }); + + expect(mic).not.toBeDisabled(); }); }); diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx index d1e302b739..34166c0dd9 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { @@ -6,6 +6,8 @@ import { Square, MessageSquarePlus, Paperclip, + FileUp, + FolderUp, Zap, Link2, Sparkles, @@ -19,13 +21,16 @@ import { GraduationCap, BookOpen, Bot, - MoreHorizontal, + Plus, Check, ChevronLeft, ChevronRight, Route, + Info, + Search, } from "lucide-react"; -import { Tooltip, Popover, Drawer } from "antd"; +import { Tooltip, Popover } from "antd"; +import { message } from "@/utils/antdMessage"; import type { ResolvedModel } from "../../../api/types"; import type { KnowledgeBase } from "../../../api/modules/knowledgeBases"; import type { SkillSpec } from "../../Agent/Skills/useSkills"; @@ -42,7 +47,10 @@ import ExpertPickerPopover from "./ExpertPickerPopover"; import SubagentPickerPopover from "./SubagentPickerPopover"; import ConnectorPickerPopover from "./ConnectorPickerPopover"; import KnowledgePickerPopover from "./KnowledgePickerPopover"; -import ConversationModePicker from "./ConversationModePicker"; +import { + ConversationModeMenu, + conversationModeIcon, +} from "./ConversationModePicker"; import HitlPolicyPicker from "./HitlPolicyPicker"; import SlashCommandMenu from "./SlashCommandMenu"; import type { SlashMenuGroup } from "../../../utils/slashCategories"; @@ -50,7 +58,6 @@ import type { SlashMenuItem } from "../hooks/useSlashMentionInput"; import type { HitlSessionPolicy } from "../utils/hitlSessionPolicy"; import { SHORTCUT_ICON_TONE_CLASS } from "../utils/slashShortcutStyles"; import { isSttAvailable } from "../../../hooks/useVoiceInput"; -import { resolveTurnModelOverride } from "../utils/chatMessages"; import { parseSkillSlugsInText } from "../utils/skillSlash"; import { useSkillDisplayName } from "../../Agent/Skills/skillDisplayNames"; import { @@ -59,15 +66,15 @@ import { } from "../utils/expertMention"; import styles from "../index.module.less"; -/** Shared by mobile drawers and narrow-desktop popovers. */ +/** Picker opened from the composer plus menu. */ type CompactPickerKey = + | "mode" | "model" | "connector" | "knowledge" | "skill" | "expert" - | "subagent" - | "shortcut"; + | "subagent"; function resolveModelLogo(model: { provider_name: string; @@ -86,10 +93,38 @@ function resolveModelLogo(model: { // These browser APIs never change at runtime — compute once. const _sttAvailable = isSttAvailable(); +const PICKER_PANEL_TARGET_HEIGHT = 400; +const PICKER_PANEL_VIEWPORT_GUTTER = 16; + +function capPickerPanelHeight(menuHeight: number): number { + const available = window.innerHeight - PICKER_PANEL_VIEWPORT_GUTTER; + return Math.max(menuHeight, Math.min(PICKER_PANEL_TARGET_HEIGHT, available)); +} + +function PlusMenuCount({ + count, + className, +}: { + count: number; + className?: string; +}) { + if (count <= 0) return null; + return ( + + {count} + + ); +} interface ChatInputActionsRowProps { isMobile: boolean; isStreaming: boolean; + /** Team host room — stop control uses clearer wording. */ + isTeam?: boolean; disabled?: boolean; canSend: boolean; text: string; @@ -97,6 +132,10 @@ interface ChatInputActionsRowProps { uploading: boolean; recording: boolean; transcribing: boolean; + /** Realtime STT streams while held down, so the mic is press-and-hold. */ + voiceHoldToTalk?: boolean; + onVoiceStart?: () => void; + onVoiceStop?: () => void; browserRecording?: boolean; browserReplayBusy?: boolean; browserLastRecordingId?: string | null; @@ -141,6 +180,7 @@ interface ChatInputActionsRowProps { slashMenuItems: SlashMenuItem[]; onSlashShortcutSelect: (command: string) => void; onFileSelect: () => void; + onFolderSelect: () => void; onNewChat: () => void; onPolish: () => void; onToggleVoice: () => void; @@ -151,6 +191,7 @@ interface ChatInputActionsRowProps { export default function ChatInputActionsRow({ isMobile, isStreaming, + isTeam = false, disabled, canSend, text, @@ -158,6 +199,9 @@ export default function ChatInputActionsRow({ uploading, recording, transcribing, + voiceHoldToTalk = false, + onVoiceStart, + onVoiceStop, browserRecording = false, browserReplayBusy = false, browserLastRecordingId = null, @@ -170,7 +214,6 @@ export default function ChatInputActionsRow({ contextMaxTokens = 128_000, availableModels, selectedModel, - defaultModel, onModelChange, reasoningMode = "auto", reasoningEffort = null, @@ -195,6 +238,7 @@ export default function ChatInputActionsRow({ slashMenuItems, onSlashShortcutSelect, onFileSelect, + onFolderSelect, onNewChat, onPolish, onToggleVoice, @@ -203,31 +247,78 @@ export default function ChatInputActionsRow({ }: ChatInputActionsRowProps) { const { t } = useTranslation(); const navigate = useNavigate(); + const remoteManaged = Boolean(agentId?.startsWith("bridge:")); const skillDisplayName = useSkillDisplayName(); const actionsRowRef = useRef(null); + const voiceHeldRef = useRef(false); + const [plusMenuEl, setPlusMenuEl] = useState(null); + const [plusPanelEl, setPlusPanelEl] = useState(null); + const [plusPanelMaxHeight, setPlusPanelMaxHeight] = useState( + null, + ); + const [plusPanelAlignBottom, setPlusPanelAlignBottom] = useState(false); + const [modelQuery, setModelQuery] = useState(""); const [isCompact, setIsCompact] = useState(false); - const [skillPickerOpen, setSkillPickerOpen] = useState(false); - const [expertPickerOpen, setExpertPickerOpen] = useState(false); - const [subagentPickerOpen, setSubagentPickerOpen] = useState(false); - const [connectorPickerOpen, setConnectorPickerOpen] = useState(false); - const [knowledgePickerOpen, setKnowledgePickerOpen] = useState(false); const [shortcutOpen, setShortcutOpen] = useState(false); - const [modelPickerOpen, setModelPickerOpen] = useState(false); + const [uploadOpen, setUploadOpen] = useState(false); const [reasoningModelRef, setReasoningModelRef] = useState( null, ); - /** Mobile-only bottom drawer for the overflow ("more") menu. */ - const [mobileOverflowOpen, setMobileOverflowOpen] = useState(false); - /** Narrow-desktop overflow popover (tools / skills / …). */ + + // Press-and-hold dictation: start on pointerdown, finish on release or when + // the pointer slides off the button (also covers touch cancel). + const handleVoicePressStart = () => { + if (voiceHeldRef.current) return; + voiceHeldRef.current = true; + onVoiceStart?.(); + }; + + const handleVoicePressEnd = () => { + if (!voiceHeldRef.current) return; + voiceHeldRef.current = false; + onVoiceStop?.(); + }; + /** Plus-button menu (model / skills / …). */ const [overflowPopoverOpen, setOverflowPopoverOpen] = useState(false); - /** Active sub-picker for compact layouts (drawer on mobile, panel in popover). */ + /** Picker panel opened from a plus-menu item. */ const [compactPicker, setCompactPicker] = useState( null, ); - const modelOverride = resolveTurnModelOverride(selectedModel, defaultModel); const useCompactControls = isMobile || isCompact; + useLayoutEffect(() => { + if (!plusMenuEl || isMobile) { + setPlusPanelMaxHeight(null); + setPlusPanelAlignBottom(false); + return; + } + const sync = () => { + const menuH = Math.round(plusMenuEl.getBoundingClientRect().height); + if (menuH > 0) { + const capped = capPickerPanelHeight(menuH); + setPlusPanelMaxHeight((prev) => (prev === capped ? prev : capped)); + } + if (!plusPanelEl) { + setPlusPanelAlignBottom(false); + return; + } + const panelH = plusPanelEl.getBoundingClientRect().height; + if (menuH > 0 && panelH > 0) { + setPlusPanelAlignBottom(panelH > menuH); + } + }; + sync(); + const observer = new ResizeObserver(sync); + observer.observe(plusMenuEl); + if (plusPanelEl) observer.observe(plusPanelEl); + window.addEventListener("resize", sync); + return () => { + observer.disconnect(); + window.removeEventListener("resize", sync); + }; + }, [plusMenuEl, plusPanelEl, isMobile]); + useEffect(() => { if (isMobile) { setIsCompact(false); @@ -247,12 +338,6 @@ export default function ChatInputActionsRow({ const showModelPicker = Boolean( availableModels && availableModels.length > 0 && onModelChange, ); - const effectiveModelRef = selectedModel || defaultModel || ""; - const selectedModelInfo = availableModels?.find( - (model) => modelOptionValue(model) === effectiveModelRef, - ); - const reasoningCapability = selectedModelInfo?.reasoning_config; - const reasoningIsStatusOnly = reasoningCapability?.adapter === "status_only"; const allowWriteTools = conversationMode === "craft"; const showConnectorPicker = Boolean( allowWriteTools && availableConnectors && onConnectorsChange, @@ -290,14 +375,15 @@ export default function ChatInputActionsRow({ [availableSkills, skillDisplayName], ); const activeSkillSlugs = parseSkillSlugsInText(text, skillTokenRefs); - const showShortcutPicker = true; + const showModePicker = Boolean(onConversationModeChange); const showOverflowMenu = + showModePicker || + showModelPicker || showConnectorPicker || showKnowledgePicker || showSkillPicker || showExpertPicker || - showSubagentPicker || - showShortcutPicker; + showSubagentPicker; const overflowBadgeCount = selectedConnectors.length + @@ -309,33 +395,38 @@ export default function ChatInputActionsRow({ const closeCompactPicker = () => { setCompactPicker(null); setReasoningModelRef(null); + setModelQuery(""); + }; + + const closePlusMenu = () => { + setOverflowPopoverOpen(false); + closeCompactPicker(); }; const openCompactPicker = (key: CompactPickerKey) => { - if (isMobile) setMobileOverflowOpen(false); - setCompactPicker(key); + setReasoningModelRef(null); + if (key !== "model") setModelQuery(""); + setCompactPicker((prev) => (prev === key ? null : key)); }; const handleExpertSelect = (agent: ChatAgentOption) => { onInsertExpertMention?.(agent); - setExpertPickerOpen(false); - closeCompactPicker(); + closePlusMenu(); }; const handleSubagentSelect = (subagent: AgentSubagentSummary) => { onInsertSubagentMention?.(subagent); - setSubagentPickerOpen(false); - closeCompactPicker(); + closePlusMenu(); }; const compactPickerTitle: Record = { - model: t("chat.selectModel", "Select model"), + mode: t("chat.conversationMode.picker"), + model: t("chat.selectModel", "Model"), connector: t("connectors.chatPicker"), knowledge: t("chat.knowledgePicker"), skill: t("chat.skillPicker"), expert: t("chat.expertPicker"), subagent: t("chat.subagentPicker"), - shortcut: t("shortcut.title", "快捷指令"), }; const reasoningModel = availableModels?.find( @@ -357,16 +448,23 @@ export default function ChatInputActionsRow({ model: selectedModel.split("/").slice(1).join("/") || selectedModel, }, ) - : t("chat.selectModel", "Select model"); - const selectedModelReasoningHint = - selectedModel && reasoningCapability - ? reasoningIsStatusOnly - ? t("chat.reasoningAlways", "始终推理") - : reasoningEffort || reasoningModeLabel(reasoningMode) - : null; - const modelTriggerTitle = selectedModelReasoningHint - ? `${selectedModelTriggerLabel} · ${selectedModelReasoningHint}` - : selectedModelTriggerLabel; + : t("chat.modelAuto", "Auto"); + + const modelQueryNormalized = modelQuery.trim().toLowerCase(); + const filteredModels = (availableModels ?? []).filter((model) => { + if (!modelQueryNormalized) return true; + return ( + modelOptionLabel(model).toLowerCase().includes(modelQueryNormalized) || + model.model.toLowerCase().includes(modelQueryNormalized) || + model.provider_name.toLowerCase().includes(modelQueryNormalized) + ); + }); + const autoLabel = t("chat.modelAuto", "Auto"); + const autoHint = t("chat.modelAutoHint", "Use agent default"); + const showAutoModel = !modelQueryNormalized + ? true + : autoLabel.toLowerCase().includes(modelQueryNormalized) || + autoHint.toLowerCase().includes(modelQueryNormalized); const reasoningSummary = (model: ResolvedModel, active: boolean) => { const capability = model.reasoning_config; @@ -391,16 +489,14 @@ export default function ChatInputActionsRow({ const reasoningMenu = reasoningModelCapability ? (
- {useCompactControls && ( - - )} + {reasoningModel ? modelOptionLabel(reasoningModel) : ""}
{reasoningModelCapability.adapter === "status_only" ? ( @@ -464,36 +560,43 @@ export default function ChatInputActionsRow({ ) : null; const modelMenu = ( -
- {(!useCompactControls || !reasoningMenu) && ( +
+ {!reasoningMenu && (
+
+ setModelQuery(event.target.value)} + /> + +
- - {availableModels?.map((model) => { + {autoHint} + + ) : null} + {filteredModels.map((model) => { const value = modelOptionValue(model); const active = selectedModel === value; const capability = model.reasoning_config; @@ -510,8 +613,7 @@ export default function ChatInputActionsRow({ className={styles.modelMenuSelect} onClick={() => { onModelChange?.(active ? null : value); - closeCompactPicker(); - setModelPickerOpen(false); + closePlusMenu(); }} > ); })} + {!showAutoModel && filteredModels.length === 0 ? ( +
+ {t("chat.modelPickerEmpty", "No matching models")} +
+ ) : null}
)} @@ -586,7 +706,6 @@ export default function ChatInputActionsRow({ } onSelect={(command) => { setShortcutOpen(false); - closeCompactPicker(); onSlashShortcutSelect(command); }} onHover={() => undefined} @@ -595,24 +714,64 @@ export default function ChatInputActionsRow({
); - const renderMobileOverflowMenu = () => ( + const ModeIcon = conversationModeIcon(conversationMode); + + const renderPlusMenu = () => (
+ {showModePicker && ( + + )} + {showModelPicker && ( + + )} {showConnectorPicker && ( @@ -620,19 +779,17 @@ export default function ChatInputActionsRow({ {showKnowledgePicker && ( @@ -640,19 +797,17 @@ export default function ChatInputActionsRow({ {showSkillPicker && ( @@ -660,21 +815,20 @@ export default function ChatInputActionsRow({ {showExpertPicker && ( @@ -682,34 +836,18 @@ export default function ChatInputActionsRow({ {showSubagentPicker && ( - )} - {showShortcutPicker && ( - - {renderCompactPickerContent()} -
- ) : ( - renderMobileOverflowMenu() - ); - - const renderSecondaryActions = () => { - if (useCompactControls) { - const modelButton = ( - - ); - const overflowButton = ( - - ); - - return ( - <> - {onConversationModeChange && ( - - )} - {onHitlPolicyChange && ( - - )} - {showModelPicker && - (isMobile ? ( - modelButton - ) : ( - { - setModelPickerOpen(open); - if (open) setOverflowPopoverOpen(false); - if (!open) setReasoningModelRef(null); - }} - overlayClassName={styles.modelPopover} - content={modelMenu} - > - {modelButton} - - ))} - - {showOverflowMenu && - (isMobile ? ( - overflowButton - ) : ( - { - setOverflowPopoverOpen(open); - if (open) { - setModelPickerOpen(false); - setCompactPicker(null); - setReasoningModelRef(null); - } else { - closeCompactPicker(); - } - }} - > - {overflowButton} - - ))} - {isMobile && ( - <> - setMobileOverflowOpen(false)} - placement="bottom" - height="auto" - title={t("chat.composerMore", "更多工具")} - className={styles.mobilePickerDrawer} - styles={{ body: { padding: 0 } }} - destroyOnHidden - > - {renderMobileOverflowMenu()} - - - {renderCompactPickerContent()} - - - )} - - ); - } + ); return ( <> - {onConversationModeChange && ( - - )} - {onHitlPolicyChange && ( - - )} - {showModelPicker && ( - { - setModelPickerOpen(open); - if (!open) setReasoningModelRef(null); - }} - overlayClassName={styles.modelPopover} - content={modelMenu} - > - - - - - )} - {showConnectorPicker && ( - setConnectorPickerOpen(false)} - /> - } - > - - - - - )} - {showKnowledgePicker && ( + {showOverflowMenu && ( setKnowledgePickerOpen(false)} - /> - } - > - - - - - )} - {showSkillPicker && ( - { - onInsertSkillCommand?.(slug); - setSkillPickerOpen(false); - }} - onNavigateAway={() => setSkillPickerOpen(false)} - /> - } - > - - - - - )} - {showExpertPicker && ( - setExpertPickerOpen(false)} - /> - } - > - - - - - )} - {showSubagentPicker && ( - setSubagentPickerOpen(false)} - /> + {isMobile ? ( + + ) : null} +
+ {renderCompactPickerContent()} +
+
+ ) : null} +
} + onOpenChange={(open) => { + setOverflowPopoverOpen(open); + if (!open) closeCompactPicker(); + }} > - - + + {plusButton} )} + {onHitlPolicyChange && ( + + )} - - + + +
+ } > - - + + + + ); }; return ( -
+
{renderSecondaryActions()}
+ {conversationMode !== "craft" ? ( +

+ + {t(`chat.conversationMode.${conversationMode}Reminder`)} +

+ ) : null}
{ + event.preventDefault(); + handleVoicePressStart(); + } + : undefined + } + onPointerUp={voiceHoldToTalk ? handleVoicePressEnd : undefined} + onPointerLeave={voiceHoldToTalk ? handleVoicePressEnd : undefined} + onPointerCancel={voiceHoldToTalk ? handleVoicePressEnd : undefined} + onContextMenu={ + voiceHoldToTalk ? (event) => event.preventDefault() : undefined + } + style={ + voiceHoldToTalk + ? { touchAction: "none", userSelect: "none" } + : undefined + } > @@ -1329,11 +1284,28 @@ export default function ChatInputActionsRow({ ) : ( - +
- {t("chat.contextWindow.breakdownHint")} + {usageEstimated + ? t("chat.contextWindow.estimateHint") + : t("chat.contextWindow.breakdownHint")}
{loading ? (
diff --git a/dashboard/src/pages/Chat/components/ConversationModePicker.tsx b/dashboard/src/pages/Chat/components/ConversationModePicker.tsx index c57e40ab15..ba33f90251 100644 --- a/dashboard/src/pages/Chat/components/ConversationModePicker.tsx +++ b/dashboard/src/pages/Chat/components/ConversationModePicker.tsx @@ -32,11 +32,45 @@ const MODE_OPTIONS: readonly { }, ]; +export function conversationModeIcon(mode: ConversationMode): LucideIcon { + return MODE_OPTIONS.find((item) => item.mode === mode)?.Icon ?? Hammer; +} + function ModeGlyph({ mode }: { mode: ConversationMode }) { - const Icon = MODE_OPTIONS.find((item) => item.mode === mode)?.Icon ?? Hammer; + const Icon = conversationModeIcon(mode); return ; } +export function ConversationModeMenu({ + conversationMode, + onChange, +}: { + conversationMode: ConversationMode; + onChange: (mode: ConversationMode) => void; +}) { + const { t } = useTranslation(); + return ( +
+ {MODE_OPTIONS.map(({ mode, Icon, hintKey }) => ( + + ))} +
+ ); +} + interface ConversationModePickerProps { conversationMode: ConversationMode; onChange: (mode: ConversationMode) => void; @@ -58,27 +92,13 @@ export default function ConversationModePicker({ onOpenChange={setOpen} overlayClassName={styles.modelPopover} content={ -
- {MODE_OPTIONS.map(({ mode, Icon, hintKey }) => ( - - ))} -
+ { + onChange(mode); + setOpen(false); + }} + /> } > diff --git a/dashboard/src/pages/Chat/components/ExpertAgentAvatar.tsx b/dashboard/src/pages/Chat/components/ExpertAgentAvatar.tsx index bc3508b4ac..a7b81f7a35 100644 --- a/dashboard/src/pages/Chat/components/ExpertAgentAvatar.tsx +++ b/dashboard/src/pages/Chat/components/ExpertAgentAvatar.tsx @@ -10,6 +10,13 @@ export interface ChatAgentOption { is_shared?: boolean; is_owner?: boolean; owner_username?: string | null; + /** Remote cloud-collab shadow expert. */ + bridge?: boolean; + bridge_connection_id?: string | null; + bridge_connection_name?: string | null; + bridge_connection_icon?: string | null; + bridge_disconnected?: boolean; + bridge_inbound?: boolean; } interface ExpertAgentAvatarProps { diff --git a/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx b/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx index 39633d401f..3cff18de52 100644 --- a/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx +++ b/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx @@ -1,11 +1,14 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { Eye, EyeOff, GraduationCap } from "lucide-react"; +import { Eye, EyeOff, GraduationCap, Info } from "lucide-react"; import SearchablePickerPanel, { pickerStyles, } from "../../../components/ChatPicker/SearchablePickerPanel"; +import { message } from "@/utils/antdMessage"; import ExpertAgentAvatar, { type ChatAgentOption } from "./ExpertAgentAvatar"; +import RemoteExpertHint from "./RemoteExpertHint"; +import { groupExpertsByConnection } from "../../../utils/remoteExpert"; import { useHiddenSharedExperts } from "../hooks/useHiddenSharedExperts"; import styles from "../index.module.less"; @@ -16,6 +19,7 @@ interface ExpertPickerPopoverProps { selectedAgentIds: string[]; onSelect: (agent: ChatAgentOption) => void; onNavigateAway?: () => void; + remoteManaged?: boolean; } export default function ExpertPickerPopover({ @@ -23,6 +27,7 @@ export default function ExpertPickerPopover({ selectedAgentIds, onSelect, onNavigateAway, + remoteManaged = false, }: ExpertPickerPopoverProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -42,6 +47,15 @@ export default function ExpertPickerPopover({ // Leave the hidden-only view once nothing remains hidden. const viewingHidden = showingHidden && hiddenAgents.length > 0; const listAgents = viewingHidden ? hiddenAgents : visibleAgents; + const grouped = useMemo( + () => groupExpertsByConnection(listAgents, t("agentSelector.localGroup")), + [listAgents, t], + ); + const showGroups = grouped.length > 1; + const pickerItems = useMemo( + () => (showGroups ? grouped.flatMap((group) => group.agents) : listAgents), + [grouped, listAgents, showGroups], + ); const filterFn = useCallback( (agent: ChatAgentOption, query: string) => @@ -52,7 +66,7 @@ export default function ExpertPickerPopover({ return ( } - footerLabel={t("chat.expertPickerManage")} + getGroupKey={ + showGroups + ? (agent) => + agent.bridge + ? agent.bridge_connection_id ?? + agent.bridge_connection_name ?? + "_" + : "local" + : undefined + } + renderGroupHeader={ + showGroups + ? (key, first) => ( +
+ {first.bridge + ? `${first.bridge_connection_name || key}${ + first.bridge_disconnected + ? ` · ${t("agentSelector.disconnected")}` + : "" + }` + : t("agentSelector.localGroup")} +
+ ) + : undefined + } + footerIcon={ + remoteManaged ? ( + + ) : ( + + ) + } + footerLabel={ + remoteManaged + ? t("chat.remoteExpert.manageOnPeer") + : t("chat.expertPickerManage") + } + footerMuted={remoteManaged} onFooterClick={() => { + if (remoteManaged) { + message.info(t("chat.remoteExpert.manageToast")); + return; + } onNavigateAway?.(); navigate("/experts"); }} @@ -113,6 +167,7 @@ export default function ExpertPickerPopover({ {t("chat.expertSharedBadge", "共享")} )} + {hideable ? ( diff --git a/dashboard/src/pages/Chat/components/FilePanelContent.tsx b/dashboard/src/pages/Chat/components/FilePanelContent.tsx index 42d19aa59f..41efeb8014 100644 --- a/dashboard/src/pages/Chat/components/FilePanelContent.tsx +++ b/dashboard/src/pages/Chat/components/FilePanelContent.tsx @@ -21,6 +21,7 @@ import { import { useTranslation } from "react-i18next"; import { probeAuthResource, request, requestBlob } from "../../../api/request"; import { isNotFoundApiError } from "../../../utils/apiError"; +import { saveBlobAsFile } from "../../../utils/saveBlobAsFile"; import FileViewer from "../../Agent/Workspace/components/FileViewer"; import { getDocKind } from "../../Agent/Workspace/utils/docKind"; import { isProbablyText } from "../../Agent/Workspace/utils/fileKind"; @@ -188,11 +189,7 @@ export default function FilePanelContent({ apiFilePath, )}`, ); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = dockFileBasename(resolvedPath) || "download"; - a.click(); - URL.revokeObjectURL(a.href); + await saveBlobAsFile(blob, dockFileBasename(resolvedPath) || "download"); } catch (err: unknown) { if (isNotFoundApiError(err)) { setFileMissing(true); @@ -359,6 +356,7 @@ export default function FilePanelContent({ void; /** When set, appends elapsed seconds (useful while waiting for the first token). */ startedAt?: number | null; showElapsed?: boolean; + /** + * Host turn already settled; only team members (or late peers) are still + * producing — softer named copy. + */ + membersOnly?: boolean; + /** Named speakers still generating (e.g. ["临床助手"]). */ + speakerNames?: ReadonlyArray; } -/** Single bottom waiting state for the whole turn — replaces thinking/continuing. */ +/** + * Bottom-left live status for the turn. Stop/cancel stays on the composer — + * this row is status text only so it can remain visible the whole time. + */ export default function GeneratingIndicator({ - onCancel, startedAt = null, showElapsed = false, + membersOnly = false, + speakerNames = [], }: GeneratingIndicatorProps) { const { t } = useTranslation(); const elapsedAnchor = startedAt != null && startedAt > 0 ? startedAt : Date.now(); const elapsed = useElapsedSince(elapsedAnchor); - const showTimer = Boolean(showElapsed && startedAt != null && startedAt > 0); + const showTimer = Boolean( + !membersOnly && showElapsed && startedAt != null && startedAt > 0, + ); + + const named = speakerNames.filter((n) => n.trim()); + let label: string; + if (membersOnly || named.length > 0) { + if (named.length === 1) { + label = t("chat.memberResponding", { + name: named[0], + defaultValue: "{{name}} 正在回答", + }); + } else if (named.length > 1) { + label = t("chat.membersRespondingNamed", { + names: named.join(t("chat.nameJoin", { defaultValue: "、" })), + defaultValue: "{{names}} 正在回答", + }); + } else { + label = t("chat.membersResponding", { + defaultValue: "成员回答中", + }); + } + } else if (showTimer) { + label = t("chat.generatingWithElapsed", { + seconds: elapsed, + defaultValue: "生成中 · {{seconds}}s", + }); + } else { + label = t("chat.generating", "生成中"); + } return (
@@ -31,24 +70,7 @@ export default function GeneratingIndicator({ - - {showTimer - ? t("chat.generatingWithElapsed", { - seconds: elapsed, - defaultValue: "生成中 · {{seconds}}s", - }) - : t("chat.generating", "生成中")} - - {onCancel && ( - - )} + {label}
); diff --git a/dashboard/src/pages/Chat/components/KnowledgeCitationPanelContent.tsx b/dashboard/src/pages/Chat/components/KnowledgeCitationPanelContent.tsx index 1810bc93dc..5b583a7623 100644 --- a/dashboard/src/pages/Chat/components/KnowledgeCitationPanelContent.tsx +++ b/dashboard/src/pages/Chat/components/KnowledgeCitationPanelContent.tsx @@ -19,6 +19,7 @@ import DocumentPreviewCore from "../../../components/DocumentPreviewCore"; import DocumentPreviewLoading from "../../../components/DocumentPreviewLoading"; import Markdown from "../../../components/Markdown"; import { apiErrorMessage, isNotFoundApiError } from "../../../utils/apiError"; +import { saveBlobAsFile } from "../../../utils/saveBlobAsFile"; import { getDocKind, type DocKind } from "../../../utils/docKind"; import { knowledgeCitationHref } from "../../../utils/knowledgeCitationDisplay"; import { @@ -238,12 +239,7 @@ export default function KnowledgeCitationPanelContent({ citation.docId, "attachment", ); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename || citation.filename || "download"; - a.click(); - URL.revokeObjectURL(url); + await saveBlobAsFile(blob, filename || citation.filename || "download"); } catch (error) { if (isNotFoundApiError(error)) { setCanDownload(false); diff --git a/dashboard/src/pages/Chat/components/KnowledgePickerPopover.tsx b/dashboard/src/pages/Chat/components/KnowledgePickerPopover.tsx index 192f9548c4..1a33befe8f 100644 --- a/dashboard/src/pages/Chat/components/KnowledgePickerPopover.tsx +++ b/dashboard/src/pages/Chat/components/KnowledgePickerPopover.tsx @@ -2,10 +2,11 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { Switch } from "antd"; -import { Settings2 } from "lucide-react"; +import { Info, Settings2 } from "lucide-react"; import SearchablePickerPanel, { pickerStyles, } from "../../../components/ChatPicker/SearchablePickerPanel"; +import { message } from "@/utils/antdMessage"; import type { KnowledgeBase } from "../../../api/modules/knowledgeBases"; import { useCurrentUser } from "../../../hooks/useCurrentUser"; import { knowledgeIconForName } from "../../KnowledgeBases/knowledgeIcons"; @@ -16,6 +17,7 @@ interface KnowledgePickerPopoverProps { selectedKnowledgeBaseIds: string[]; onKnowledgeBaseIdsChange: (ids: string[]) => void; onNavigateAway?: () => void; + remoteManaged?: boolean; } export default function KnowledgePickerPopover({ @@ -23,6 +25,7 @@ export default function KnowledgePickerPopover({ selectedKnowledgeBaseIds, onKnowledgeBaseIdsChange, onNavigateAway, + remoteManaged = false, }: KnowledgePickerPopoverProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -42,9 +45,24 @@ export default function KnowledgePickerPopover({ searchPlaceholder={t("chat.knowledgePickerSearch")} emptyMessage={t("chat.knowledgePickerEmpty")} width="narrow" - footerIcon={} - footerLabel={t("chat.manageKnowledgeBases")} + footerIcon={ + remoteManaged ? ( + + ) : ( + + ) + } + footerLabel={ + remoteManaged + ? t("chat.remoteExpert.manageOnPeer") + : t("chat.manageKnowledgeBases") + } + footerMuted={remoteManaged} onFooterClick={() => { + if (remoteManaged) { + message.info(t("chat.remoteExpert.manageToast")); + return; + } onNavigateAway?.(); navigate("/knowledge-bases"); }} diff --git a/dashboard/src/pages/Chat/components/MentionPickerMenu.tsx b/dashboard/src/pages/Chat/components/MentionPickerMenu.tsx index eeb08e825d..0e599d97ef 100644 --- a/dashboard/src/pages/Chat/components/MentionPickerMenu.tsx +++ b/dashboard/src/pages/Chat/components/MentionPickerMenu.tsx @@ -150,7 +150,7 @@ export default function MentionPickerMenu({ if (items.length === 0 && !fileHint) { return ( -
+
{t("mention.empty", "No matches")}
@@ -172,12 +172,7 @@ export default function MentionPickerMenu({ let flatIndex = -1; return ( -
+
{sections.map((section) => (
{section.title}
@@ -211,8 +206,6 @@ export default function MentionPickerMenu({ + ) : errorAction ? ( - )} + ) : null} {onRegenerate && (
)} @@ -744,7 +776,8 @@ export default function MessageList(props: MessageListProps) { showGenerating, thinkingStartedAt, isAwaitingAssistantReply, - onCancel, + generatingMembersOnly, + liveSpeakerNames, refreshFooter, ], ); @@ -778,15 +811,7 @@ export default function MessageList(props: MessageListProps) { } return ( -
+
scrollToBottom()} />
diff --git a/dashboard/src/pages/Chat/components/MinimalAgentSessionNav.tsx b/dashboard/src/pages/Chat/components/MinimalAgentSessionNav.tsx index eb1ef7098a..6ba78a9023 100644 --- a/dashboard/src/pages/Chat/components/MinimalAgentSessionNav.tsx +++ b/dashboard/src/pages/Chat/components/MinimalAgentSessionNav.tsx @@ -16,12 +16,22 @@ import { import type { OctopAgent } from "../../../context/AgentContext"; import { ExpertIcon } from "../../Experts/components/iconForName"; import { octopThreadsApi } from "../../../api/modules/octopThreads"; -import { showConfirmModal } from "../../../utils/confirmModal"; +import { + confirmDeleteConversation, + deleteConversation, +} from "../utils/deleteConversation"; import { isAgentChatReady } from "../../../utils/agentError"; import { sortSessions, toSession, type Session } from "../hooks/useSessions"; import { formatThreadTitle } from "../utils/threadTitle"; -import { onSessionEvent, onStreamEvent } from "../hooks/chatStore"; +import { onSessionEvent } from "../hooks/chatStore"; +import { useSessionWorkIds } from "../hooks/useSessionWorkIds"; +import { + resolveSessionWorkStatus, + type SessionWorkStatus, +} from "../utils/sessionWorkStatus"; import SharedExpertHint from "./SharedExpertHint"; +import RemoteExpertHint from "./RemoteExpertHint"; +import SessionWorkStatusIcon from "./SessionWorkStatusIcon"; import TeamChatBadge from "./TeamChatBadge"; import styles from "../index.module.less"; @@ -62,7 +72,10 @@ interface MinimalAgentSessionNavProps { onAgentSelect: (agentId: string) => void; /** Start a fresh (unsaved) chat with the given expert. */ onNewChat: (agentId: string) => void; - onDeleteActive: (id: string) => void; + onDeleteActive: ( + id: string, + compact: boolean, + ) => void | Promise; onRenameActive: (id: string, name: string) => void; onPinActive: (id: string, pinned: boolean) => void; onFork: (id: string, agentId?: string | null) => void; @@ -86,7 +99,7 @@ function AgentUnreadBadge({ count }: { count: number }) { const PreviewSessionRow = memo(function PreviewSessionRow({ session, isActive, - working, + workStatus, onSelect, onDelete, onRename, @@ -97,9 +110,9 @@ const PreviewSessionRow = memo(function PreviewSessionRow({ }: { session: Session; isActive: boolean; - working: boolean; + workStatus: SessionWorkStatus; onSelect: (id: string) => void; - onDelete: (id: string) => void; + onDelete: (id: string, compact: boolean) => void | Promise; onRename: (id: string, name: string) => void; onPin: (id: string, pinned: boolean) => void; onFork: (id: string) => void; @@ -176,14 +189,8 @@ const PreviewSessionRow = memo(function PreviewSessionRow({ danger: true, onClick: ({ domEvent }) => { domEvent.stopPropagation(); - showConfirmModal({ - title: t("chat.deleteSessionConfirm"), - okText: t("common.delete"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: () => { - onDelete(session.id); - }, + confirmDeleteConversation(t, (compact) => { + void onDelete(session.id, compact); }); }, }, @@ -221,13 +228,9 @@ const PreviewSessionRow = memo(function PreviewSessionRow({ /> ) : ( <> - {working ? ( - - ) : null} + + + {session.name} {session.pinned ? ( >({}); - const [workingIds, setWorkingIds] = useState>(new Set()); + const liveWorkingIds = useSessionWorkIds(); const [loading, setLoading] = useState(false); const [collapsedFolders, setCollapsedFolders] = useState>(() => loadCollapsedFolders(), @@ -400,21 +403,6 @@ export default function MinimalAgentSessionNav({ })); }, [activeAgentId, activeSessions]); - // Turns streamed by this browser tab keep running after the user navigates - // away, so the nav marks those threads as busy until the stream ends. - useEffect(() => { - return onStreamEvent((event) => { - setWorkingIds((prev) => { - const busy = event.kind !== "streamEnd"; - if (busy === prev.has(event.sessionId)) return prev; - const next = new Set(prev); - if (busy) next.add(event.sessionId); - else next.delete(event.sessionId); - return next; - }); - }); - }, []); - useEffect(() => { return onSessionEvent((event) => { if (event.kind === "sessionDeleted") { @@ -447,20 +435,18 @@ export default function MinimalAgentSessionNav({ ); const handleDelete = useCallback( - async (agentId: string, sessionId: string) => { + async (agentId: string, sessionId: string, compact: boolean) => { if (agentId === activeAgentId) { - onDeleteActive(sessionId); + const deleted = await onDeleteActive(sessionId, compact); + if (deleted === false) return; patchLocal(agentId, (prev) => prev.filter((s) => s.id !== sessionId)); return; } - try { - await octopThreadsApi.delete(agentId, sessionId); - patchLocal(agentId, (prev) => prev.filter((s) => s.id !== sessionId)); - } catch { - /* ignore */ - } + const deleted = await deleteConversation(agentId, sessionId, compact, t); + if (!deleted) return; + patchLocal(agentId, (prev) => prev.filter((s) => s.id !== sessionId)); }, - [activeAgentId, onDeleteActive, patchLocal], + [activeAgentId, onDeleteActive, patchLocal, t], ); const handleRename = useCallback( @@ -534,7 +520,7 @@ export default function MinimalAgentSessionNav({ @@ -599,9 +586,14 @@ export default function MinimalAgentSessionNav({ key={session.id} session={session} isActive={session.id === activeId} - working={workingIds.has(session.id)} + workStatus={resolveSessionWorkStatus( + session, + liveWorkingIds, + )} onSelect={(id) => onSelect(id, agent.agent_id)} - onDelete={(id) => void handleDelete(agent.agent_id, id)} + onDelete={(id, compact) => + void handleDelete(agent.agent_id, id, compact) + } onRename={(id, name) => handleRename(agent.agent_id, id, name) } diff --git a/dashboard/src/pages/Chat/components/ModelConfigEmpty.tsx b/dashboard/src/pages/Chat/components/ModelConfigEmpty.tsx new file mode 100644 index 0000000000..86745c7fb8 --- /dev/null +++ b/dashboard/src/pages/Chat/components/ModelConfigEmpty.tsx @@ -0,0 +1,43 @@ +import { Button } from "antd"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { OctopEmptyMascot } from "../../../components/EmptyState"; +import styles from "../index.module.less"; + +interface ModelConfigEmptyProps { + canConfigure: boolean; +} + +export default function ModelConfigEmpty({ + canConfigure, +}: ModelConfigEmptyProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( +
+
+
+ +
+

+ {t("modelConfig.promptTitle")} +

+

+ {canConfigure + ? t("modelConfig.promptMessage") + : t("modelConfig.promptMessageNoPermission")} +

+ {canConfigure ? ( + + ) : null} +
+
+ ); +} diff --git a/dashboard/src/pages/Chat/components/RemoteExpertHint.tsx b/dashboard/src/pages/Chat/components/RemoteExpertHint.tsx new file mode 100644 index 0000000000..93b53c7282 --- /dev/null +++ b/dashboard/src/pages/Chat/components/RemoteExpertHint.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "antd"; +import { iconForName } from "../../Experts/components/iconForName"; +import styles from "../index.module.less"; + +interface RemoteExpertHintProps { + agent?: { + bridge?: boolean | null; + bridge_connection_name?: string | null; + bridge_connection_icon?: string | null; + } | null; + /** Force show without checking ``agent.bridge`` (title bar). */ + show?: boolean; + connectionName?: string | null; + connectionIcon?: string | null; + /** Hide the connection name (chips / tight pickers). */ + compact?: boolean; +} + +/** Marks a sidebar / picker / experts row as a remote Bridge shadow expert. */ +export default function RemoteExpertHint({ + agent, + show, + connectionName, + connectionIcon, + compact, +}: RemoteExpertHintProps) { + const { t } = useTranslation(); + const visible = show ?? Boolean(agent?.bridge); + if (!visible) return null; + const name = + (connectionName ?? agent?.bridge_connection_name ?? "").trim() || ""; + const icon = + (connectionIcon ?? agent?.bridge_connection_icon ?? "").trim() || "cloudy"; + const tip = name + ? t("chat.remoteExpert.banner", { name }) + : t("chat.remoteExpert.flag"); + const label = compact ? null : name || null; + return ( + + event.stopPropagation()} + > + + {iconForName(icon, 11)} + + {label ? ( + {label} + ) : null} + + + ); +} diff --git a/dashboard/src/pages/Chat/components/ScrollToBottomButton.tsx b/dashboard/src/pages/Chat/components/ScrollToBottomButton.tsx index 99fe0c7be6..b1cfddccc5 100644 --- a/dashboard/src/pages/Chat/components/ScrollToBottomButton.tsx +++ b/dashboard/src/pages/Chat/components/ScrollToBottomButton.tsx @@ -6,14 +6,19 @@ import styles from "../index.module.less"; interface ScrollToBottomButtonProps { visible: boolean; onClick: () => void; + /** Live generation below the fold — emphasize "new replies". */ + hasNewActivity?: boolean; } export default function ScrollToBottomButton({ visible, onClick, + hasNewActivity = false, }: ScrollToBottomButtonProps) { const { t } = useTranslation(); - const label = t("chat.scrollToBottom"); + const label = hasNewActivity + ? t("chat.newRepliesBelow", { defaultValue: "有新回复" }) + : t("chat.scrollToBottom"); const buttonRef = useRef(null); const [overlapsTool, setOverlapsTool] = useState(false); @@ -65,7 +70,7 @@ export default function ScrollToBottomButton({ window.removeEventListener("resize", updateOverlap); resizeObserver?.disconnect(); }; - }, [visible]); + }, [visible, hasNewActivity]); const actuallyVisible = visible && !overlapsTool; return ( @@ -75,6 +80,10 @@ export default function ScrollToBottomButton({ actuallyVisible ? styles.scrollToBottomBtnVisible : styles.scrollToBottomBtnHidden + }${ + hasNewActivity && actuallyVisible + ? ` ${styles.scrollToBottomBtnHasNew}` + : "" }`} onClick={onClick} type="button" diff --git a/dashboard/src/pages/Chat/components/SessionList.tsx b/dashboard/src/pages/Chat/components/SessionList.tsx index 90ed7045fd..4a4c89d7cc 100644 --- a/dashboard/src/pages/Chat/components/SessionList.tsx +++ b/dashboard/src/pages/Chat/components/SessionList.tsx @@ -18,12 +18,19 @@ import { import type { Session } from "../hooks/useSessions"; import type { OctopAgent } from "../../../context/AgentContext"; import { isAgentChatReady } from "../../../utils/agentError"; -import { showConfirmModal } from "../../../utils/confirmModal"; +import { confirmDeleteConversation } from "../utils/deleteConversation"; import { ExpertIcon } from "../../Experts/components/iconForName"; import { useHiddenSharedExperts } from "../hooks/useHiddenSharedExperts"; import SessionChannelIcon from "./SessionChannelIcon"; +import SessionWorkStatusIcon from "./SessionWorkStatusIcon"; import SharedExpertHint from "./SharedExpertHint"; +import RemoteExpertHint from "./RemoteExpertHint"; import TeamChatBadge from "./TeamChatBadge"; +import { useSessionWorkIds } from "../hooks/useSessionWorkIds"; +import { + resolveSessionWorkStatus, + type SessionWorkStatus, +} from "../utils/sessionWorkStatus"; import styles from "../index.module.less"; function AgentUnreadBadge({ count }: { count: number }) { @@ -42,8 +49,9 @@ function AgentUnreadBadge({ count }: { count: number }) { interface SessionItemProps { session: Session; isActive: boolean; + workStatus?: SessionWorkStatus; onSelect: (id: string) => void; - onDelete: (id: string) => void; + onDelete: (id: string, compact: boolean) => void | Promise; onRename: (id: string, name: string) => void; onPin: (id: string, pinned: boolean) => void; onFork: (id: string) => void; @@ -54,6 +62,7 @@ interface SessionItemProps { const SessionItem = memo(function SessionItem({ session, isActive, + workStatus = "idle", onSelect, onDelete, onRename, @@ -132,14 +141,8 @@ const SessionItem = memo(function SessionItem({ danger: true, onClick: ({ domEvent }) => { domEvent.stopPropagation(); - showConfirmModal({ - title: t("chat.deleteSessionConfirm"), - okText: t("common.delete"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - onOk: () => { - onDelete(session.id); - }, + confirmDeleteConversation(t, (compact) => { + void onDelete(session.id, compact); }); }, }, @@ -159,11 +162,17 @@ const SessionItem = memo(function SessionItem({ if (e.key === "Enter" && !isEditing) onSelect(session.id); }} > - + + {workStatus !== "idle" ? ( + + ) : ( + + )} + {isEditing ? ( ) : ( <> - - {session.name} - + {session.name} {session.pinned ? ( void; onSelect: (sessionId: string, agentId: string) => void; onNewChat: (agentId: string) => void; - onDelete: (id: string) => void; + onDelete: (id: string, compact: boolean) => void | Promise; onRename: (id: string, name: string) => void; onPin: (id: string, pinned: boolean) => void; onFork: (id: string) => void; activeForkDisabled?: boolean; activeForkDisabledHint?: string; onHide?: () => void; + liveWorkingIds: ReadonlySet; } function ActiveAgentCard({ @@ -251,6 +259,7 @@ function ActiveAgentCard({ activeForkDisabled, activeForkDisabledHint, onHide, + liveWorkingIds, }: AgentCardProps) { const { t } = useTranslation(); const accent = agent.color || "#6366f1"; @@ -296,17 +305,16 @@ function ActiveAgentCard({
-
- {agent.name} -
+
{agent.name}
+
-
- {agent.name} -
+
{agent.name}
+
{onNewChat ? ( @@ -506,7 +514,7 @@ interface SessionListProps { onSelect: (sessionId: string, agentId: string) => void; onAgentSelect: (agentId: string) => void; onNewChat: (agentId: string) => void; - onDelete: (id: string) => void; + onDelete: (id: string, compact: boolean) => void | Promise; onRename: (id: string, name: string) => void; onPin: (id: string, pinned: boolean) => void; onFork: (id: string) => void; @@ -535,6 +543,7 @@ export default function SessionList({ }: SessionListProps) { const { t } = useTranslation(); const navigate = useNavigate(); + const liveWorkingIds = useSessionWorkIds(); const [searchQuery, setSearchQuery] = useState(""); const [showingHidden, setShowingHidden] = useState(false); const { filterVisible, pickHidden, hide, unhide, canHide } = @@ -640,6 +649,7 @@ export default function SessionList({ onHide={ canHide(agent) ? () => hide(agent.agent_id) : undefined } + liveWorkingIds={liveWorkingIds} /> ); } diff --git a/dashboard/src/pages/Chat/components/SessionWorkStatusIcon.tsx b/dashboard/src/pages/Chat/components/SessionWorkStatusIcon.tsx new file mode 100644 index 0000000000..a78313ff1d --- /dev/null +++ b/dashboard/src/pages/Chat/components/SessionWorkStatusIcon.tsx @@ -0,0 +1,35 @@ +import { Loader2, Clock } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { SessionWorkStatus } from "../utils/sessionWorkStatus"; +import styles from "../index.module.less"; + +export default function SessionWorkStatusIcon({ + status, +}: { + status: SessionWorkStatus; +}) { + const { t } = useTranslation(); + if (status === "idle") return null; + if (status === "waiting") { + const label = t("chat.sessionWaiting"); + return ( + + + + ); + } + const label = t("chat.sessionWorking"); + return ( + + + + ); +} diff --git a/dashboard/src/pages/Chat/components/SkillPickerPopover.tsx b/dashboard/src/pages/Chat/components/SkillPickerPopover.tsx index 3d22dccd81..38335cc874 100644 --- a/dashboard/src/pages/Chat/components/SkillPickerPopover.tsx +++ b/dashboard/src/pages/Chat/components/SkillPickerPopover.tsx @@ -1,10 +1,11 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { Check, Import } from "lucide-react"; +import { Check, Import, Info } from "lucide-react"; import SearchablePickerPanel, { pickerStyles, } from "../../../components/ChatPicker/SearchablePickerPanel"; +import { message } from "@/utils/antdMessage"; import type { SkillSpec } from "../../Agent/Skills/useSkills"; import { resolveSkillDisplayName, @@ -18,6 +19,8 @@ interface SkillPickerPopoverProps { activeSlugs?: readonly string[] | null; onSelectSkill: (slug: string) => void; onNavigateAway?: () => void; + /** Bridge shadow session — manage actions live on the peer. */ + remoteManaged?: boolean; } function SkillAvatar({ skill }: { skill: SkillSpec }) { @@ -44,6 +47,7 @@ export default function SkillPickerPopover({ activeSlugs = null, onSelectSkill, onNavigateAway, + remoteManaged = false, }: SkillPickerPopoverProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -80,12 +84,27 @@ export default function SkillPickerPopover({ } - footerLabel={t("skills.importSkills")} + footerIcon={ + remoteManaged ? ( + + ) : ( + + ) + } + footerLabel={ + remoteManaged + ? t("chat.remoteExpert.manageOnPeer") + : t("skills.importSkills") + } + footerMuted={remoteManaged} onFooterClick={() => { + if (remoteManaged) { + message.info(t("chat.remoteExpert.manageToast")); + return; + } onNavigateAway?.(); navigate("/personalization/skills"); }} diff --git a/dashboard/src/pages/Chat/components/SlashCommandMenu.tsx b/dashboard/src/pages/Chat/components/SlashCommandMenu.tsx index 4276a815cd..cd28d1d295 100644 --- a/dashboard/src/pages/Chat/components/SlashCommandMenu.tsx +++ b/dashboard/src/pages/Chat/components/SlashCommandMenu.tsx @@ -68,8 +68,6 @@ export default function SlashCommandMenu({ onMouseEnter={() => onHover(idx)} disabled={disabled} type="button" - role="option" - aria-selected={active} > @@ -93,8 +91,6 @@ export default function SlashCommandMenu({ onMouseEnter={() => onHover(idx)} disabled={disabled} type="button" - role="option" - aria-selected={active} title={`${item.label} ${item.command}`} > @@ -107,11 +103,7 @@ export default function SlashCommandMenu({ }; const renderGrid = (items: T[]) => ( -
+
{items.map((item) => renderRow(item))}
); diff --git a/dashboard/src/pages/Chat/components/SubagentPickerPopover.tsx b/dashboard/src/pages/Chat/components/SubagentPickerPopover.tsx index 2b446980e3..249f6b875c 100644 --- a/dashboard/src/pages/Chat/components/SubagentPickerPopover.tsx +++ b/dashboard/src/pages/Chat/components/SubagentPickerPopover.tsx @@ -1,10 +1,11 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { Bot } from "lucide-react"; +import { Bot, Info } from "lucide-react"; import SearchablePickerPanel, { pickerStyles, } from "../../../components/ChatPicker/SearchablePickerPanel"; +import { message } from "@/utils/antdMessage"; import type { AgentSubagentSummary } from "../../../api/modules/subagents"; import styles from "../index.module.less"; @@ -13,6 +14,7 @@ interface SubagentPickerPopoverProps { selectedSlugs: string[]; onSelect: (subagent: AgentSubagentSummary) => void; onNavigateAway?: () => void; + remoteManaged?: boolean; } export default function SubagentPickerPopover({ @@ -20,6 +22,7 @@ export default function SubagentPickerPopover({ selectedSlugs, onSelect, onNavigateAway, + remoteManaged = false, }: SubagentPickerPopoverProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -39,9 +42,24 @@ export default function SubagentPickerPopover({ searchPlaceholder={t("chat.subagentPickerSearch")} emptyMessage={t("chat.subagentPickerEmpty")} width="compact" - footerIcon={} - footerLabel={t("chat.subagentPickerManage")} + footerIcon={ + remoteManaged ? ( + + ) : ( + + ) + } + footerLabel={ + remoteManaged + ? t("chat.remoteExpert.manageOnPeer") + : t("chat.subagentPickerManage") + } + footerMuted={remoteManaged} onFooterClick={() => { + if (remoteManaged) { + message.info(t("chat.remoteExpert.manageToast")); + return; + } onNavigateAway?.(); navigate("/personalization/subagents"); }} diff --git a/dashboard/src/pages/Chat/components/TrajectoryDrawer.tsx b/dashboard/src/pages/Chat/components/TrajectoryDrawer.tsx index f0a5cee5cd..2e6b74d6b6 100644 --- a/dashboard/src/pages/Chat/components/TrajectoryDrawer.tsx +++ b/dashboard/src/pages/Chat/components/TrajectoryDrawer.tsx @@ -4,6 +4,7 @@ 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 { saveBlobAsFile } from "../../../utils/saveBlobAsFile"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { useTrajectorySession } from "../hooks/useTrajectorySession"; import { @@ -168,12 +169,7 @@ export default function TrajectoryDrawer({ 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); + await saveBlobAsFile(blob, `trajectory-${threadId}.jsonl`); } catch { message.error( t("chat.trajectoryExportFailed", "Failed to export trajectory"), diff --git a/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx b/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx index f35af7d41f..43ff5c06d2 100644 --- a/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx +++ b/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx @@ -69,6 +69,7 @@ describe("TrajectoryInspector", () => { is_error: false, summary: "thinking…", payload: { + model: "MiniMax-M2.7", llm_duration_ms: 120, ttft_ms: 40, input_tokens: 10, @@ -81,6 +82,8 @@ describe("TrajectoryInspector", () => { expect(screen.getByRole("tab", { name: "Summary" })).toBeInTheDocument(); expect(screen.getAllByText("ASSISTANT").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Model")).toBeInTheDocument(); + expect(screen.getByText("MiniMax-M2.7")).toBeInTheDocument(); expect(screen.getAllByText(/Request #3/).length).toBeGreaterThanOrEqual(1); expect(screen.getByText("Request Timing")).toBeInTheDocument(); expect(screen.getAllByText("120ms").length).toBeGreaterThanOrEqual(1); @@ -89,6 +92,24 @@ describe("TrajectoryInspector", () => { expect(screen.getByText("12.5 tok/s")).toBeInTheDocument(); }); + it("does not show Model on non-assistant summaries even if payload has it", () => { + render( + , + ); + + expect(screen.queryByText("Model")).not.toBeInTheDocument(); + expect(screen.queryByText("MiniMax-M2.7")).not.toBeInTheDocument(); + }); + it("jumps Source Request # to the parent assistant", () => { const onSelectEvent = vi.fn(); const assistant = event({ diff --git a/dashboard/src/pages/Chat/components/TrajectoryInspector.tsx b/dashboard/src/pages/Chat/components/TrajectoryInspector.tsx index b13a0e0c1a..176323f21b 100644 --- a/dashboard/src/pages/Chat/components/TrajectoryInspector.tsx +++ b/dashboard/src/pages/Chat/components/TrajectoryInspector.tsx @@ -34,6 +34,14 @@ function payloadNumber( return typeof value === "number" ? value : null; } +function payloadString( + payload: Record, + key: string, +): string | null { + const value = payload[key]; + return typeof value === "string" && value.trim() ? value : null; +} + /** Prefer the assistant row for a request; fall back to any matching event. */ export function findSourceEventId( events: readonly TrajectoryEvent[], @@ -125,6 +133,8 @@ function SummaryPane({ onSelectEvent?: (eventId: string) => void; }) { const { t } = useTranslation(); + const model = + event.kind === "assistant" ? payloadString(event.payload, "model") : null; const inputTokens = payloadNumber(event.payload, "input_tokens"); const outputTokens = payloadNumber(event.payload, "output_tokens"); const sourceId = @@ -145,6 +155,12 @@ function SummaryPane({
{t("chat.trajectoryInspectorKind", "Kind")}
{kindLabelFor(event.kind)}
+ {model != null ? ( +
+
{t("chat.trajectoryInspectorModel", "Model")}
+
{model}
+
+ ) : null} {event.request_seq != null ? (
{t("chat.trajectoryInspectorSource", "Source")}
diff --git a/dashboard/src/pages/Chat/components/TurnProcessBlocks.tsx b/dashboard/src/pages/Chat/components/TurnProcessBlocks.tsx index d6a09288c0..0fd2eb696f 100644 --- a/dashboard/src/pages/Chat/components/TurnProcessBlocks.tsx +++ b/dashboard/src/pages/Chat/components/TurnProcessBlocks.tsx @@ -9,6 +9,7 @@ import { ToolDetailsInline } from "./MessageBubble"; import { ExpertMessageAvatar } from "./MessageSender"; import { useAgent } from "../../../context/AgentContext"; import { isTeamAgent, isTeamHostSpeaker } from "../../../utils/teamAgent"; +import { rewritePeerSpeakerId } from "../../../utils/remoteExpert"; import { useTranslation } from "react-i18next"; import styles from "../index.module.less"; @@ -39,20 +40,22 @@ export function TurnProcessBlocks({ const { t } = useTranslation(); const { agents, activeAgent } = useAgent(); const isTeamRoom = isTeamAgent(activeAgent); + const speakerId = + rewritePeerSpeakerId(activeAgent?.agent_id, agentId) || agentId; const hostSpeaker = isTeamHostSpeaker( isTeamRoom, - agentId, + speakerId, activeAgent?.agent_id, ); const expert = - (agentId && agents.find((item) => item.agent_id === agentId)) || + (speakerId && agents.find((item) => item.agent_id === speakerId)) || (!isTeamRoom || hostSpeaker ? activeAgent : undefined); const avatarTooltip = hostSpeaker ? t("chat.teamHostHover", { name: activeAgent?.name || expert?.name || "" }) : expert?.name || undefined; const avatarProfileId = hostSpeaker ? activeAgent?.agent_id - : agentId || undefined; + : speakerId || undefined; const rendererVersion = useToolRendererVersion(); const { pinned, folded } = useMemo( () => partitionPinnedTools(split), @@ -83,6 +86,7 @@ export function TurnProcessBlocks({ split={folded} statsSplit={split} isStreaming={isStreaming} + isTeam={isTeamRoom} onAcpPermissionSelect={onAcpPermissionSelect} hideToolMedia={hideToolMedia} agentId={agentId} diff --git a/dashboard/src/pages/Chat/components/generatingGate.test.ts b/dashboard/src/pages/Chat/components/generatingGate.test.ts index a86b4e91d9..98eab4df41 100644 --- a/dashboard/src/pages/Chat/components/generatingGate.test.ts +++ b/dashboard/src/pages/Chat/components/generatingGate.test.ts @@ -17,6 +17,15 @@ describe("shouldShowGenerating", () => { false, ); }); + + it("stays true for late team members after the host unlocks the composer", () => { + expect( + shouldShowGenerating({ + isStreaming: false, + hasLiveSpeakers: true, + }), + ).toBe(true); + }); }); describe("chatGeneratingPhase", () => { @@ -27,7 +36,11 @@ describe("chatGeneratingPhase", () => { loading: false, lastMessageRole: "user", }), - ).toEqual({ showFooter: true, showElapsed: true }); + ).toEqual({ + showFooter: true, + showElapsed: true, + membersOnly: false, + }); }); it("hides elapsed once an assistant bubble exists", () => { @@ -36,7 +49,11 @@ describe("chatGeneratingPhase", () => { isStreaming: true, lastMessageRole: "assistant", }), - ).toEqual({ showFooter: true, showElapsed: false }); + ).toEqual({ + showFooter: true, + showElapsed: false, + membersOnly: false, + }); }); it("hides footer during initial history load", () => { @@ -46,6 +63,24 @@ describe("chatGeneratingPhase", () => { loading: true, lastMessageRole: "user", }), - ).toEqual({ showFooter: false, showElapsed: false }); + ).toEqual({ + showFooter: false, + showElapsed: false, + membersOnly: false, + }); + }); + + it("shows a members-only footer without cancel-phase elapsed", () => { + expect( + chatGeneratingPhase({ + isStreaming: false, + hasLiveSpeakers: true, + lastMessageRole: "assistant", + }), + ).toEqual({ + showFooter: true, + showElapsed: false, + membersOnly: true, + }); }); }); diff --git a/dashboard/src/pages/Chat/components/generatingGate.ts b/dashboard/src/pages/Chat/components/generatingGate.ts index aff954c2cc..0891bc87d4 100644 --- a/dashboard/src/pages/Chat/components/generatingGate.ts +++ b/dashboard/src/pages/Chat/components/generatingGate.ts @@ -1,12 +1,21 @@ /** * Whether the chat footer should show the unified "generating" indicator. * One bottom status for the whole turn (no separate thinking / continuing). + * + * Team rooms clear ``isStreaming`` when the host unlocks the composer, but + * members may still be generating — ``hasLiveSpeakers`` keeps the footer up. + * + * Stop/cancel lives only on the composer send button — this footer is status + * text only, so it can stay visible for the entire live turn. */ export function shouldShowGenerating(opts: { isStreaming: boolean; loading?: boolean; + /** Speakers still producing tokens/tools after the host turn settled. */ + hasLiveSpeakers?: boolean; }): boolean { - return Boolean(opts.isStreaming && !opts.loading); + const live = Boolean(opts.isStreaming) || Boolean(opts.hasLiveSpeakers); + return Boolean(live && !opts.loading); } /** @@ -17,10 +26,20 @@ export function chatGeneratingPhase(opts: { isStreaming: boolean; loading?: boolean; lastMessageRole?: string | null; -}): { showFooter: boolean; showElapsed: boolean } { + hasLiveSpeakers?: boolean; +}): { + showFooter: boolean; + showElapsed: boolean; + membersOnly: boolean; +} { const showFooter = shouldShowGenerating(opts); + const membersOnly = Boolean( + showFooter && !opts.isStreaming && opts.hasLiveSpeakers, + ); const showElapsed = Boolean( - showFooter && (!opts.lastMessageRole || opts.lastMessageRole === "user"), + showFooter && + !membersOnly && + (!opts.lastMessageRole || opts.lastMessageRole === "user"), ); - return { showFooter, showElapsed }; + return { showFooter, showElapsed, membersOnly }; } diff --git a/dashboard/src/pages/Chat/components/liveAssistantTurn.test.ts b/dashboard/src/pages/Chat/components/liveAssistantTurn.test.ts index 143d32107f..d882738e9f 100644 --- a/dashboard/src/pages/Chat/components/liveAssistantTurn.test.ts +++ b/dashboard/src/pages/Chat/components/liveAssistantTurn.test.ts @@ -36,14 +36,14 @@ describe("isLiveAssistantTurn", () => { ).toBe(false); }); - it("is false for earlier assistant groups while a later one is live", () => { + it("keeps earlier team speakers live while a later one is also streaming", () => { expect( isLiveAssistantTurn({ isStreaming: true, groupIndex: 1, lastAssistantGroupIndex: 3, - lastUserGroupIndex: 2, + lastUserGroupIndex: 0, }), - ).toBe(false); + ).toBe(true); }); }); diff --git a/dashboard/src/pages/Chat/components/liveAssistantTurn.ts b/dashboard/src/pages/Chat/components/liveAssistantTurn.ts index f6f3b7f491..fe695f9fd7 100644 --- a/dashboard/src/pages/Chat/components/liveAssistantTurn.ts +++ b/dashboard/src/pages/Chat/components/liveAssistantTurn.ts @@ -1,10 +1,13 @@ /** - * Whether this assistant group is the live in-progress turn. + * Whether this assistant group is part of the live in-progress turn. * - * After the user sends a new message, the previous assistant group is still - * `lastAssistantGroupIndex` until the model replies — it must NOT be treated - * as live, or its process summary re-expands and pushes the new user bubble - * off-screen. + * Any assistant group after the latest user message stays live while the + * session is streaming so concurrent team speakers do not collapse their + * process panels between tool rounds when another speaker is last in the list. + * + * After the user sends a new message, older assistant groups sit *before* + * ``lastUserGroupIndex`` and must NOT be treated as live (or their process + * summary re-expands and pushes the new user bubble off-screen). */ export function isLiveAssistantTurn(opts: { isStreaming: boolean; @@ -12,15 +15,6 @@ export function isLiveAssistantTurn(opts: { lastAssistantGroupIndex: number; lastUserGroupIndex: number; }): boolean { - const { - isStreaming, - groupIndex, - lastAssistantGroupIndex, - lastUserGroupIndex, - } = opts; - return ( - isStreaming && - groupIndex === lastAssistantGroupIndex && - groupIndex > lastUserGroupIndex - ); + const { isStreaming, groupIndex, lastUserGroupIndex } = opts; + return isStreaming && groupIndex > lastUserGroupIndex; } diff --git a/dashboard/src/pages/Chat/constants.ts b/dashboard/src/pages/Chat/constants.ts index 5480e6d31e..9192243796 100644 --- a/dashboard/src/pages/Chat/constants.ts +++ b/dashboard/src/pages/Chat/constants.ts @@ -6,6 +6,7 @@ export const FILE_TOOL_NAMES = [ "write_file", "edit_file", "send_file", + "send_file_to_user", ] as const; export const EMPTY_CHAT_SESSION_KEY = "__empty__"; diff --git a/dashboard/src/pages/Chat/hooks/chatStore.hitlAsk.test.ts b/dashboard/src/pages/Chat/hooks/chatStore.hitlAsk.test.ts new file mode 100644 index 0000000000..dd1c644520 --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/chatStore.hitlAsk.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getSnapshot, ingestHarnessChunk, removeSession } from "./chatStore"; + +const SESSION = "test-hitl-ask"; + +describe("ask_user_question HITL ingest", () => { + afterEach(() => { + removeSession(SESSION); + }); + + it("merges hitl_required onto the unanswered ask tool and stamps pending_id", () => { + ingestHarnessChunk(SESSION, { + type: "tool_call_chunk", + name: "ask_user_question", + id: "call-1", + args: JSON.stringify({ + questions: [{ question: "Which DB?" }], + }), + }); + ingestHarnessChunk(SESSION, { + type: "hitl_required", + request: { + pending_id: "ab12", + action_requests: [ + { + name: "ask_user_question", + args: { questions: [{ question: "Which DB?" }] }, + }, + ], + }, + }); + + const messages = getSnapshot(SESSION).messages; + expect(messages).toHaveLength(1); + expect(messages[0]?.hitlData?.pending_id).toBe("ab12"); + expect(messages[0]?.toolData?.name).toBe("ask_user_question"); + expect(messages[0]?.status).toBe("done"); + }); + + it("does not overwrite a pending tool approval with an ask interrupt", () => { + ingestHarnessChunk(SESSION, { + type: "hitl_required", + request: { + pending_id: "appr", + action_requests: [{ name: "execute", args: { command: "ls" } }], + }, + }); + ingestHarnessChunk(SESSION, { + type: "hitl_required", + request: { + pending_id: "ask1", + action_requests: [ + { + name: "ask_user_question", + args: { questions: [{ question: "Go?" }] }, + }, + ], + }, + }); + + const messages = getSnapshot(SESSION).messages; + expect(messages).toHaveLength(2); + expect(messages[0]?.hitlData?.pending_id).toBe("appr"); + expect(messages[0]?.hitlData?.action_requests?.[0]?.name).toBe("execute"); + expect(messages[1]?.hitlData?.pending_id).toBe("ask1"); + }); +}); diff --git a/dashboard/src/pages/Chat/hooks/chatStore.streamError.test.ts b/dashboard/src/pages/Chat/hooks/chatStore.streamError.test.ts new file mode 100644 index 0000000000..68b3886355 --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/chatStore.streamError.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getSnapshot, ingestHarnessChunk, removeSession } from "./chatStore"; + +const SESSION = "test-stream-error-finalize"; + +describe("finalizeStreamingMessages stream-error promotion", () => { + afterEach(() => { + removeSession(SESSION); + }); + + it("does not promote a normal answer that mentions rate limits", () => { + ingestHarnessChunk(SESSION, { + type: "token", + node: "agent", + content: "If you hit Error code: 429 rate_limit, wait a minute.", + }); + ingestHarnessChunk(SESSION, { type: "done" }); + const msgs = getSnapshot(SESSION).messages.filter( + (message) => message.role === "assistant", + ); + expect(msgs).toHaveLength(1); + expect(msgs[0]?.status).toBe("done"); + }); + + it("promotes model-retry envelopes to error", () => { + ingestHarnessChunk(SESSION, { + type: "token", + node: "agent", + content: "Model call failed after 3 attempts with RuntimeError: boom", + }); + ingestHarnessChunk(SESSION, { type: "done" }); + const msgs = getSnapshot(SESSION).messages.filter( + (message) => message.role === "assistant", + ); + expect(msgs).toHaveLength(1); + expect(msgs[0]?.status).toBe("error"); + expect(msgs[0]?.errorInfo).toEqual({ + code: "stream_error", + source: "model_retry", + }); + }); +}); diff --git a/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts b/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts index f8424a7937..5148a8e195 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.teamStream.test.ts @@ -695,10 +695,140 @@ describe("team member live stream", () => { agent_id: "doctor", team_snapshot: true, }); - const { messages } = getSnapshot(SESSION); + const { messages, liveSpeakers } = getSnapshot(SESSION); const member = messages.filter((item) => item.speakerAgentId === "doctor"); expect(member).toHaveLength(1); expect(member[0]?.content).toBe("please rest"); expect(member[0]?.status).toBe("done"); + expect(liveSpeakers).not.toContain("doctor"); + }); + + it("keeps a member answer on one bubble across completed tools", () => { + ingestHarnessChunk(SESSION, { + type: "token", + content: "我先查一下。", + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { + type: "tool_call_chunk", + index: 0, + id: "read-1", + name: "read_file", + args: "{}", + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { + type: "tool_result", + messages: [{ tool_call_id: "read-1", content: "labs ok" }], + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { + type: "token", + content: "结论如下。", + agent_id: "doctor", + }); + const member = getSnapshot(SESSION).messages.filter( + (item) => item.speakerAgentId === "doctor" && !item.toolData, + ); + expect(member).toHaveLength(1); + expect(member[0]?.content).toBe("我先查一下。结论如下。"); + }); + + it("clears stale member live state when host wrap-up finishes", () => { + ingestHarnessChunk(SESSION, { + type: "token", + content: "done work", + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { type: "done", agent_id: "doctor" }); + // Snapshot must not re-light a finished speaker. + ingestHarnessChunk(SESSION, { + type: "token", + content: "done work", + agent_id: "doctor", + team_snapshot: true, + }); + expect(getSnapshot(SESSION).liveSpeakers).not.toContain("doctor"); + + ingestHarnessChunk( + SESSION, + { + type: "token", + content: "汇总完成", + agent_id: "host", + team_wrapup: true, + }, + "host", + ); + ingestHarnessChunk( + SESSION, + { type: "done", agent_id: "host", team_wrapup: true }, + "host", + ); + expect(getSnapshot(SESSION).liveSpeakers).toEqual([]); + }); + + it("keeps a member live across tool gaps after the host unlocks the composer", () => { + ingestHarnessChunk(SESSION, { type: "token", content: "dispatch" }); + ingestHarnessChunk(SESSION, { type: "done" }); + expect(getSnapshot(SESSION).isStreaming).toBe(false); + + ingestHarnessChunk(SESSION, { + type: "token", + content: "我先查一下", + agent_id: "doctor", + }); + expect(getSnapshot(SESSION).liveSpeakers).toContain("doctor"); + + ingestHarnessChunk(SESSION, { + type: "tool_call_chunk", + index: 0, + id: "read-1", + name: "read_file", + args: "{}", + agent_id: "doctor", + }); + ingestHarnessChunk(SESSION, { + type: "tool_result", + messages: [{ tool_call_id: "read-1", content: "labs ok" }], + agent_id: "doctor", + }); + + const afterTool = getSnapshot(SESSION); + expect(afterTool.isStreaming).toBe(false); + expect(afterTool.liveSpeakers).toContain("doctor"); + expect( + afterTool.messages.some( + (item) => + item.speakerAgentId === "doctor" && item.status === "streaming", + ), + ).toBe(false); + + ingestHarnessChunk(SESSION, { + type: "token", + content: "结论如下", + agent_id: "doctor", + }); + expect(getSnapshot(SESSION).liveSpeakers).toContain("doctor"); + + ingestHarnessChunk(SESSION, { type: "done", agent_id: "doctor" }); + expect(getSnapshot(SESSION).liveSpeakers).not.toContain("doctor"); + }); + + it("rewrites peer-local member speakers onto Bridge shadow ids", () => { + ingestHarnessChunk( + SESSION, + { + type: "token", + content: "please rest", + agent_id: "doctor", + }, + "bridge:cid:host", + ); + const { messages } = getSnapshot(SESSION); + expect( + messages.find((item) => item.content.includes("please rest")) + ?.speakerAgentId, + ).toBe("bridge:cid:doctor"); }); }); diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts index 7e4b46a837..6aff674ca6 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.ts @@ -11,6 +11,7 @@ import { getApiUrl } from "../../../api/config"; import { getAuthToken } from "../../../api/request"; import type { TokenUsage } from "../../../api/types"; import { buildDashboardChatWsUrl } from "../../../api/modules/wsChat"; +import { rewritePeerSpeakerId } from "../../../utils/remoteExpert"; import { generateId } from "../../../utils/messageParser"; import type { ChatAttachment, @@ -37,6 +38,15 @@ import { } from "../utils/messageGrouping"; import { turnStatusAction } from "./turnStatusGate"; import { mergePatchedToolOutput } from "../../../plugins/toolRenderers/parseToolOutput"; +import { + isAskHitl, + normalizeHitlRequest, + parseActionArgs, +} from "../../../api/types/hitl"; +import { + isPausedAskTool, + promoteAskUserToolMessage, +} from "../utils/pendingHitl"; import { frameBelongsToThread } from "./frameThread"; import { MAX_STREAM_RESUME_ATTEMPTS, @@ -150,6 +160,7 @@ function sealInFlightAssistantMessages(state: SessionStreamState): void { state.messages = state.messages.map((m) => m.status === "streaming" ? { ...m, status: "done" as const } : m, ); + clearAllLiveSpeakers(state); } // ── Pending prefill text ────────────────────────────────────────────────── @@ -253,6 +264,7 @@ const EMPTY_SNAPSHOT: SessionSnapshot = Object.freeze({ historyNextOffset: 0, historyHydrated: false, pendingPlanPath: null, + liveSpeakers: [] as string[], }); const sessionStates = new Map(); @@ -384,9 +396,64 @@ function buildSnapshot(state: SessionStreamState): SessionSnapshot { historyNextCursor: state.historyNextCursor, historyHydrated: state.historyHydrated, pendingPlanPath: state.pendingPlanPath ?? null, + liveSpeakers: [...state.liveSpeakers].sort(), }; } +function speakerLiveKey(speaker?: string): string { + return (speaker || "").trim(); +} + +/** Mark a speaker as still generating (survives tool gaps until ``done``). */ +function markSpeakerLive(state: SessionStreamState, speaker?: string): void { + const key = speakerLiveKey(speaker); + state.liveSpeakers.add(key); + // Unlabeled host frames also light up the room id so stamped host groups match. + if (!key) { + const host = state.roomAgentId?.trim(); + if (host) state.liveSpeakers.add(host); + } +} + +function clearSpeakerLive(state: SessionStreamState, speaker?: string): void { + const key = speakerLiveKey(speaker); + state.liveSpeakers.delete(key); + const host = state.roomAgentId?.trim(); + // Unlabeled and stamped host share one live bit — clear both together. + if (!key) { + if (host) state.liveSpeakers.delete(host); + } else if (host && key === host) { + state.liveSpeakers.delete(""); + } +} + +function clearAllLiveSpeakers(state: SessionStreamState): void { + state.liveSpeakers.clear(); +} + +/** Drop live speakers with no streaming text and no in-flight tools. */ +function pruneIdleLiveSpeakers(state: SessionStreamState): void { + if (state.liveSpeakers.size === 0) return; + const busy = new Set(); + const host = state.roomAgentId?.trim(); + for (const message of state.messages) { + if (message.role !== "assistant") continue; + const key = speakerLiveKey(message.speakerAgentId); + const inFlightTool = Boolean( + message.toolData && + message.toolData.output === undefined && + !isPausedAskTool(message), + ); + if (message.status !== "streaming" && !inFlightTool) continue; + busy.add(key); + if (!key && host) busy.add(host); + if (key && host && key === host) busy.add(""); + } + for (const key of [...state.liveSpeakers]) { + if (!busy.has(key)) state.liveSpeakers.delete(key); + } +} + function getOrCreate(sessionId: string): SessionStreamState { let state = sessionStates.get(sessionId); if (!state) { @@ -411,6 +478,7 @@ function getOrCreate(sessionId: string): SessionStreamState { _snapshot: EMPTY_SNAPSHOT, roomAgentId: undefined, isTeamRoom: false, + liveSpeakers: new Set(), }; sessionStates.set(sessionId, state); } @@ -430,12 +498,15 @@ function notify(state: SessionStreamState) { } function beginStream(state: SessionStreamState, sessionId: string): void { + clearAllLiveSpeakers(state); state.thinkingStartedAt = Date.now(); state.isStreaming = true; touchStreamActivity(sessionId); } function clearStreamingFlags(state: SessionStreamState): void { + // Do not clear liveSpeakers — team members may keep generating after the + // host turn unlocks the composer; process panels key off liveSpeakers. state.isStreaming = false; state.thinkingStartedAt = null; } @@ -710,15 +781,7 @@ export function truncateAndReplaceUserMessage( return true; } -/** - * Append a push (proactive/cron) assistant message to the focused session. - * - * FE-3: this used to fan the same message into every cached session, which - * duplicated recording/automation progress notes into unrelated conversations - * under the same message id. UI progress notes belong to the session the user - * is actually looking at; server-pushed proactive messages arrive through the - * socket and are not affected by this path. - */ +/** Append a push (proactive/cron) assistant message to all active sessions. */ export function appendPushMessage(text: string) { const msg: ChatMessage = { id: generateId(), @@ -726,11 +789,14 @@ export function appendPushMessage(text: string) { content: text, timestamp: Date.now(), }; - const focused = getFocusedChatSession(); - const state = focused ? sessionStates.get(focused) : undefined; - if (!state) return; - state.messages = [...state.messages, msg]; - notify(state); + // renameSessionKey may briefly alias two keys to one state — dedupe. + const seen = new Set(); + for (const state of sessionStates.values()) { + if (seen.has(state)) continue; + seen.add(state); + state.messages = [...state.messages, msg]; + notify(state); + } } /** Clear all messages for a session, unless a turn is still in flight. */ @@ -749,6 +815,7 @@ export function clearMessages(sessionId: string) { if (alreadyEmpty) return; state.messages = []; clearStreamingFlags(state); + clearAllLiveSpeakers(state); state.runUsage = null; usageSamplesByState.delete(state); state.streamMsg = ""; @@ -1026,8 +1093,9 @@ function applyUsageChunk(state: SessionStreamState, chunk: UsageChunk): void { function chunkSpeakerId( chunk: { agent_id?: unknown } | object, + hostAgentId?: string, ): string | undefined { - return streamSpeakerId(chunk); + return rewritePeerSpeakerId(hostAgentId, streamSpeakerId(chunk)); } function hasStreamingMessages(state: SessionStreamState): boolean { @@ -1043,7 +1111,7 @@ function isHostTurnTerminal( ): boolean { if (data.type === "error" || data.type === "hitl_required") return true; if (data.type !== "done") return false; - const speaker = chunkSpeakerId(data); + const speaker = chunkSpeakerId(data, hostAgentId); if (!speaker) return true; const host = (hostAgentId || "").trim(); // Room id not remembered yet — treat a stamped done as the 1:1 host. @@ -1103,30 +1171,39 @@ function handleHarnessChunk( chunk: HarnessChunk, sessionId?: string, ): void { - const speaker = chunkSpeakerId(chunk); + const speaker = chunkSpeakerId(chunk, sessionHostAgentId(state, sessionId)); if (sessionId && (state.isStreaming || speaker)) { touchStreamActivity(sessionId); } switch (chunk.type) { - case "token": + case "token": { + const snapshot = Boolean(chunk.team_snapshot); + // Snapshots are complete wall copies — never leave the speaker "live". + if (!snapshot) markSpeakerLive(state, speaker); appendStreamingToken( state, chunk.content, speaker, - Boolean(chunk.team_snapshot), + snapshot, Boolean(chunk.team_wrapup), ); + if (snapshot) clearSpeakerLive(state, speaker); break; + } case "reasoning": + markSpeakerLive(state, speaker); appendStreamingReasoning(state, chunk.content, speaker); break; case "usage": applyUsageChunk(state, chunk); break; case "tool_call_chunk": + markSpeakerLive(state, speaker); upsertToolCall(state, chunk, sessionId, speaker); break; case "tool_result": + // Keep the speaker live across the tool→next-token gap. + markSpeakerLive(state, speaker); closeToolCall(state, chunk.messages, sessionId, speaker); break; case "done": @@ -1139,6 +1216,10 @@ function handleHarnessChunk( } if (Boolean(chunk.team_wrapup)) { finalizeWrapupMessages(state, speaker); + clearSpeakerLive(state, speaker); + // Wrap-up means members already finished — drop stale live bits + // (e.g. snapshot re-marked a speaker after their done). + pruneIdleLiveSpeakers(state); break; } finalizeStreamingMessages( @@ -1146,6 +1227,7 @@ function handleHarnessChunk( speaker, sessionHostAgentId(state, sessionId), ); + clearSpeakerLive(state, speaker); // Host done always frees the composer. Member bubbles may still stream. if (!speaker || (!hasStreamingMessages(state) && state.isStreaming)) { clearStreamingFlags(state); @@ -1658,7 +1740,7 @@ function upsertToolCall( : m.toolData?.callId ?? callId; state.messages = [ ...state.messages.slice(0, idx), - { + promoteAskUserToolMessage({ ...m, speakerAgentId: speaker ?? m.speakerAgentId, toolData: { @@ -1668,7 +1750,7 @@ function upsertToolCall( callId: nextCallId, arguments: nextArgs, }, - }, + }), ...state.messages.slice(idx + 1), ]; registerToolCallKeys(state, existingMsgId, chunk, speaker); @@ -1680,7 +1762,7 @@ function upsertToolCall( registerToolCallKeys(state, msgId, chunk, speaker); state.messages = [ ...state.messages, - { + promoteAskUserToolMessage({ id: msgId, role: "assistant", content: "", @@ -1693,7 +1775,7 @@ function upsertToolCall( status: "streaming", timestamp: Date.now(), speakerAgentId: speaker, - }, + }), ]; emitToolEvent({ kind: "toolStart", @@ -1761,6 +1843,16 @@ function extractToolResultOutput(messages: unknown[]): string { return ""; } +/** First non-null ToolMessage ``artifact`` in a tool_result frame, if any. */ +function extractToolResultArtifact(messages: unknown[]): unknown { + for (const raw of messages) { + if (!raw || typeof raw !== "object") continue; + const artifact = (raw as Record).artifact; + if (artifact !== undefined && artifact !== null) return artifact; + } + return undefined; +} + function closeToolCall( state: SessionStreamState, messages: unknown[], @@ -1820,6 +1912,7 @@ function closeToolCall( if (toolIdx < 0) return; const target = state.messages[toolIdx]; const output = extractToolResultOutput(messages); + const artifact = extractToolResultArtifact(messages); const explicitToolError = messages.some( (raw) => raw !== null && @@ -1846,6 +1939,7 @@ function closeToolCall( ...(target.toolData ?? {}), output, errorCode, + ...(artifact != null ? { artifact } : {}), }, }, ...state.messages.slice(toolIdx + 1), @@ -1990,8 +2084,9 @@ function finalizeStreamingMessages( } function parseHitlRequest(raw: Record) { - const requests = Array.isArray(raw.action_requests) - ? raw.action_requests + const normalized = normalizeHitlRequest(raw); + const requests = Array.isArray(normalized.action_requests) + ? normalized.action_requests : []; const action_requests = requests .filter((item) => item && typeof item === "object") @@ -1999,16 +2094,13 @@ function parseHitlRequest(raw: Record) { const row = item as Record; return { name: typeof row.name === "string" ? row.name : "tool", - args: - row.args && typeof row.args === "object" - ? (row.args as Record) - : {}, + args: parseActionArgs(row.args), description: typeof row.description === "string" ? row.description : undefined, }; }); - const review_configs = Array.isArray(raw.review_configs) - ? raw.review_configs + const review_configs = Array.isArray(normalized.review_configs) + ? normalized.review_configs .filter((item) => item && typeof item === "object") .map( (item) => @@ -2018,7 +2110,18 @@ function parseHitlRequest(raw: Record) { }, ) : undefined; - return { action_requests, review_configs, status: "pending" as const }; + const pendingId = + typeof normalized.pending_id === "string" + ? normalized.pending_id + : typeof raw.pending_id === "string" + ? raw.pending_id + : undefined; + return { + action_requests, + review_configs, + status: "pending" as const, + ...(pendingId ? { pending_id: pendingId } : {}), + }; } function resolveHitlPending( @@ -2047,13 +2150,55 @@ function handleHitlRequired( ): void { finalizeStreamingMessages(state); clearStreamingFlags(state); + clearAllLiveSpeakers(state); + const hitlData = parseHitlRequest(request); + const askPause = isAskHitl(hitlData.action_requests); + const existingIdx = [...state.messages] + .map((message, index) => ({ message, index })) + .reverse() + .find(({ message }) => { + const hitl = message.hitlData; + if (hitlData.pending_id && hitl?.pending_id === hitlData.pending_id) { + return true; + } + if (askPause) { + if (isPausedAskTool(message)) return true; + return ( + Boolean(hitl) && + (hitl?.status ?? "pending") === "pending" && + isAskHitl(hitl?.action_requests) && + !hitl?.pending_id + ); + } + return ( + Boolean(hitl) && + (hitl?.status ?? "pending") === "pending" && + !isAskHitl(hitl?.action_requests) + ); + })?.index; + if (existingIdx !== undefined) { + const current = state.messages[existingIdx]; + state.messages = [ + ...state.messages.slice(0, existingIdx), + { + ...current, + hitlData: { + ...current.hitlData, + ...hitlData, + }, + status: "done", + }, + ...state.messages.slice(existingIdx + 1), + ]; + return; + } state.messages = [ ...state.messages, { id: generateId(), role: "assistant", content: "", - hitlData: parseHitlRequest(request), + hitlData, status: "done", timestamp: Date.now(), }, diff --git a/dashboard/src/pages/Chat/hooks/sseHelpers.ts b/dashboard/src/pages/Chat/hooks/sseHelpers.ts index d00eb28843..e521f3a6ad 100644 --- a/dashboard/src/pages/Chat/hooks/sseHelpers.ts +++ b/dashboard/src/pages/Chat/hooks/sseHelpers.ts @@ -18,6 +18,12 @@ export interface ToolCallData { callId?: string; arguments?: string; output?: string; + /** + * Offloaded ``octop_ui`` payload. When the backend strips a large ``data`` + * field from the tool output (envelope carries ``data_ref: "artifact"``), + * the full payload lands here — never in the model's context. + */ + artifact?: unknown; errorCode?: string; returnCode?: number; /** Owning plugin id when known (from tool index / SSE). */ @@ -82,6 +88,13 @@ export interface ChatMessage { speakerAgentId?: string; /** Host wrap-up after members — never continue the dispatch bubble. */ teamWrapup?: boolean; + /** + * Workspace paths written/edited in this turn. Stamped on the final + * (or last file-tool) assistant bubble so the edit-file card still + * shows when process tools are collapsed; full tool trail is also + * persisted for the process panel. + */ + editedFiles?: string[]; } /** Per-session state held in the chat store's module-scoped Map. */ @@ -121,6 +134,12 @@ export interface SessionStreamState { /** Team host room — listen-only sockets and ask_agent continue stay on. */ isTeamRoom?: boolean; pendingPlanPath?: string | null; + /** + * Speakers that still own an open generation (token / tool / reasoning) + * until their ``done`` frame. Empty string = unlabeled host. Keeps process + * panels open across tool gaps after the composer has unlocked. + */ + liveSpeakers: Set; } /** Read-only snapshot shape exposed via ``chatStore.getSnapshot``. */ @@ -136,4 +155,6 @@ export interface SessionSnapshot { historyNextCursor?: string | null; historyHydrated: boolean; pendingPlanPath?: string | null; + /** Sorted speaker keys still generating (see SessionStreamState.liveSpeakers). */ + liveSpeakers: string[]; } diff --git a/dashboard/src/pages/Chat/hooks/useChat.ts b/dashboard/src/pages/Chat/hooks/useChat.ts index b7779c1ca2..f1e68a50cb 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.ts @@ -11,6 +11,7 @@ import type { CallEntry, } from "../../../api/types"; import type { HitlSessionPolicy } from "../../../api/types/hitl"; +import type { ThreadArtifact } from "../../../api/modules/octopThreads"; import * as chatStore from "./chatStore"; import { shouldBlockHistoryRefresh } from "./wsResumeGate"; import { @@ -29,6 +30,8 @@ import { parseToolExecutionFeedback, } from "../../../utils/toolMediaBlocks"; import { injectPendingHitlMessage } from "../../../utils/injectPendingHitlMessage"; +import { promoteAskUserToolMessage } from "../utils/pendingHitl"; +import { rewritePeerSpeakerId } from "../../../utils/remoteExpert"; import type { ChatAttachment, ChatMessage, @@ -473,7 +476,14 @@ function convertCallEntries(entries: CallEntry[]): ChatMessage[] { entry.speaker_agent_id.trim() ? entry.speaker_agent_id.trim() : undefined, - teamWrapup: Boolean((entry as { team_wrapup?: boolean }).team_wrapup), + teamWrapup: Boolean(entry.team_wrapup), + editedFiles: Array.isArray(entry.edited_files) + ? entry.edited_files + .filter( + (p): p is string => typeof p === "string" && p.trim().length > 0, + ) + .map((p) => p.trim()) + : undefined, }; }); @@ -499,6 +509,9 @@ function convertCallEntries(entries: CallEntry[]): ChatMessage[] { output: current.toolData?.output, errorCode: current.toolData?.errorCode, returnCode: current.toolData?.returnCode, + ...(current.toolData?.artifact != null + ? { artifact: current.toolData.artifact } + : {}), }, status: current.status, errorInfo: current.errorInfo, @@ -629,7 +642,7 @@ function convertCallEntries(entries: CallEntry[]): ChatMessage[] { } flushTurn(); - return merged; + return merged.map(promoteAskUserToolMessage); } function toHistoryContentBlocks(content: unknown): unknown[] { @@ -661,6 +674,7 @@ export function convertHistoryMessages( error_code?: string; agent_id?: string; team_wrapup?: boolean; + edited_files?: string[]; }>, agentId?: string, ): ChatMessage[] { @@ -688,12 +702,25 @@ export function convertHistoryMessages( ? message.agent_id.trim() : undefined, team_wrapup: message.team_wrapup === true, + edited_files: Array.isArray(message.edited_files) + ? message.edited_files.filter( + (p): p is string => typeof p === "string" && p.trim().length > 0, + ) + : undefined, }; }); const converted = convertCallEntries(entries).filter( isDisplayableHistoryMessage, ); - return agentId ? enrichAttachmentPreviewUrls(converted, agentId) : converted; + const rewritten = agentId + ? converted.map((message) => { + const speaker = rewritePeerSpeakerId(agentId, message.speakerAgentId); + return speaker && speaker !== message.speakerAgentId + ? { ...message, speakerAgentId: speaker } + : message; + }) + : converted; + return agentId ? enrichAttachmentPreviewUrls(rewritten, agentId) : rewritten; } // ── Helpers ─────────────────────────────────────────────────────────────── @@ -708,13 +735,12 @@ async function loadThreadHistory( nextOffset: number; nextCursor: string | null; turnActive: boolean; - artifacts: string[]; + artifacts: ThreadArtifact[]; projectionLoading: boolean; retryAfterMs: number; }> { - const { octopThreadsApi, CHAT_HISTORY_PAGE_SIZE } = await import( - "../../../api/modules/octopThreads" - ); + const { octopThreadsApi, CHAT_HISTORY_PAGE_SIZE, normalizeThreadArtifacts } = + await import("../../../api/modules/octopThreads"); const { syncSessionArtifacts, syncSessionConversationMode, @@ -727,12 +753,11 @@ async function loadThreadHistory( offset, cursor: params.cursor, }); - const artifacts = Array.isArray(history.artifacts) - ? history.artifacts.filter( - (path): path is string => - typeof path === "string" && path.trim().length > 0, - ) - : []; + const artifacts = normalizeThreadArtifacts( + history.artifacts, + agentId, + history.artifact_refs, + ); if (offset === 0) { syncSessionArtifacts(threadId, artifacts); syncSessionConversationMode( @@ -825,6 +850,7 @@ export function useChat( historyLoadingMore, historyHydrated, pendingPlanPath, + liveSpeakers, } = useSyncExternalStore(subscribeStore, getStoreSnapshot); const [historyLoading, setHistoryLoading] = useState(false); @@ -1175,6 +1201,7 @@ export function useChat( historyRefreshing, historyHydrated, pendingPlanPath, + liveSpeakers, sendMessage, editAndResend, cancelStream, diff --git a/dashboard/src/pages/Chat/hooks/useChat.usage.test.ts b/dashboard/src/pages/Chat/hooks/useChat.usage.test.ts index 4b0b8c8265..8647a2cfbb 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.usage.test.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.usage.test.ts @@ -96,6 +96,45 @@ describe("history token usage", () => { expect(messages[3]?.speakerAgentId).toBe("host"); }); + it("rewrites peer team speakers onto local Bridge shadow ids", () => { + const room = "bridge:cid:host"; + const messages = convertHistoryMessages( + [ + { role: "user", content: "ask the doctor", id: "u1" }, + { + role: "assistant", + content: "please rest", + id: "m1", + agent_id: "doctor", + }, + { + role: "assistant", + content: "wrap", + id: "w1", + agent_id: "host", + }, + ], + room, + ); + expect(messages[1]?.speakerAgentId).toBe("bridge:cid:doctor"); + expect(messages[2]?.speakerAgentId).toBe("bridge:cid:host"); + }); + + it("maps stamped edited_files onto history bubbles for the edit card", () => { + const messages = convertHistoryMessages([ + { role: "user", content: "write a plan", id: "u1" }, + { + role: "assistant", + content: "done", + id: "m1", + agent_id: "doctor", + edited_files: ["notes/plan.md", ""], + }, + ]); + expect(messages[1]?.editedFiles).toEqual(["notes/plan.md"]); + expect(messages[1]?.speakerAgentId).toBe("doctor"); + }); + it("maps persisted stream errors to assistant error bubbles", () => { const messages = convertHistoryMessages([ { role: "user", content: "continue this", id: "u1" }, @@ -118,4 +157,29 @@ describe("history token usage", () => { source: "history", }); }); + + it("promotes an unanswered ask_user_question tool call into a pending card", () => { + const messages = convertHistoryMessages([ + { role: "user", content: "help me choose", id: "u1" }, + { + role: "assistant", + id: "a1", + content: [ + { + type: "tool_use", + name: "ask_user_question", + id: "call-1", + input: { + questions: [{ question: "Which database?", header: "Storage" }], + }, + }, + ], + }, + ]); + expect(messages[1]?.hitlData?.status).toBe("pending"); + expect(messages[1]?.hitlData?.action_requests?.[0]?.name).toBe( + "ask_user_question", + ); + expect(messages[1]?.status).toBe("done"); + }); }); diff --git a/dashboard/src/pages/Chat/hooks/useChatAttachments.test.tsx b/dashboard/src/pages/Chat/hooks/useChatAttachments.test.tsx new file mode 100644 index 0000000000..acaa1f5d4d --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useChatAttachments.test.tsx @@ -0,0 +1,204 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { DragEvent } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { message } from "@/utils/antdMessage"; +import { uploadFile } from "../../../api/modules/upload"; +import { DESKTOP_FILE_DROP_EVENT } from "../../../utils/desktopFileDrop"; +import { useChatAttachments } from "./useChatAttachments"; + +vi.mock("../../../hooks/useServerUploadLimit", () => ({ + useServerUploadLimit: () => ({ maxUploadBytes: 1024, maxUploadMb: 1 }), +})); + +vi.mock("../../../api/modules/upload", () => ({ + uploadFile: vi.fn(), +})); + +vi.mock("@/utils/antdMessage", () => ({ + message: { error: vi.fn(), warning: vi.fn() }, +})); + +function dragEvent( + init: Partial> & { currentTarget?: HTMLElement }, +): DragEvent { + return { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: { types: ["Files"], items: [], files: [] }, + relatedTarget: null, + ...init, + } as unknown as DragEvent; +} + +describe("useChatAttachments drop", () => { + beforeEach(() => { + vi.mocked(uploadFile).mockReset(); + vi.mocked(message.error).mockReset(); + vi.mocked(message.warning).mockReset(); + vi.mocked(uploadFile).mockImplementation(async (_agentId, file) => ({ + path: `inbound/${file.name}`, + workspace_path: `inbound/${file.name}`, + filename: file.name, + media_type: file.type || "application/octet-stream", + url: "", + access_url: `/files/${file.name}`, + })); + }); + + it("uploads every file from one drop", async () => { + const { result } = renderHook(() => useChatAttachments("agent-1")); + const first = new File(["a"], "a.txt", { type: "text/plain" }); + const second = new File(["b"], "b.txt", { type: "text/plain" }); + const dropped = dragEvent({ + dataTransfer: { + types: ["Files"], + items: [ + { kind: "file", getAsFile: () => first }, + { kind: "file", getAsFile: () => second }, + ], + files: [first, second], + } as unknown as DataTransfer, + }); + + await act(async () => { + await result.current.handleDrop(dropped); + }); + + await waitFor(() => { + expect(result.current.attachments.map((item) => item.filename)).toEqual([ + "a.txt", + "b.txt", + ]); + }); + expect(uploadFile).toHaveBeenCalledTimes(2); + expect(result.current.dragOver).toBe(false); + }); + + it("keeps the drop highlight while the pointer is still inside the composer", () => { + const { result } = renderHook(() => useChatAttachments("agent-1")); + const shell = document.createElement("div"); + const field = document.createElement("textarea"); + shell.appendChild(field); + + act(() => { + result.current.handleDragEnter( + dragEvent({ + currentTarget: shell, + dataTransfer: { types: ["Files"] } as DataTransfer, + }), + ); + }); + expect(result.current.dragOver).toBe(true); + + act(() => { + result.current.handleDragLeave( + dragEvent({ currentTarget: shell, relatedTarget: field }), + ); + }); + expect(result.current.dragOver).toBe(true); + + act(() => { + result.current.handleDragLeave( + dragEvent({ currentTarget: shell, relatedTarget: document.body }), + ); + }); + expect(result.current.dragOver).toBe(false); + }); + + it("does not highlight a text drag", () => { + const { result } = renderHook(() => useChatAttachments("agent-1")); + const event = dragEvent({ + dataTransfer: { + types: ["text/plain"], + items: [], + files: [], + } as unknown as DataTransfer, + }); + + act(() => { + result.current.handleDragEnter(event); + }); + + expect(result.current.dragOver).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("warns when a dropped folder has no files", async () => { + const { result } = renderHook(() => useChatAttachments("agent-1")); + const emptyDir = { + isFile: false, + isDirectory: true, + name: "empty", + fullPath: "/empty", + createReader: () => ({ + readEntries(success: (entries: FileSystemEntry[]) => void) { + success([]); + }, + }), + }; + const dropped = dragEvent({ + dataTransfer: { + types: ["Files"], + items: [ + { + kind: "file", + webkitGetAsEntry: () => emptyDir, + getAsFile: () => null, + }, + ], + files: [], + } as unknown as DataTransfer, + }); + + await act(async () => { + await result.current.handleDrop(dropped); + }); + + expect(uploadFile).not.toHaveBeenCalled(); + expect(message.warning).toHaveBeenCalledWith("No files to upload"); + }); + + it("uploads files delivered by the desktop shell", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("note", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }), + ), + ); + const { result } = renderHook(() => useChatAttachments("agent-1")); + + await act(async () => { + window.dispatchEvent( + new CustomEvent(DESKTOP_FILE_DROP_EVENT, { + detail: { + files: [ + { + name: "note.txt", + url: "http://127.0.0.1:9/drop/token/0", + type: "text/plain", + }, + ], + truncated: true, + max: 500, + }, + }), + ); + }); + + await waitFor(() => { + expect(result.current.attachments.map((item) => item.filename)).toEqual([ + "note.txt", + ]); + }); + expect(uploadFile).toHaveBeenCalledTimes(1); + expect(message.warning).toHaveBeenCalledWith( + "Only the first 500 files can be uploaded at once", + ); + expect(result.current.dragOver).toBe(false); + vi.unstubAllGlobals(); + }); +}); diff --git a/dashboard/src/pages/Chat/hooks/useChatAttachments.ts b/dashboard/src/pages/Chat/hooks/useChatAttachments.ts index 052c536f62..376d21aa55 100644 --- a/dashboard/src/pages/Chat/hooks/useChatAttachments.ts +++ b/dashboard/src/pages/Chat/hooks/useChatAttachments.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ChangeEvent, ClipboardEvent, DragEvent } from "react"; import { useTranslation } from "react-i18next"; import { uploadFile } from "../../../api/modules/upload"; @@ -8,6 +8,18 @@ import { message as antMessage } from "@/utils/antdMessage"; import { apiErrorMessage } from "../../../utils/apiError"; import { inferAttachmentKind } from "../utils/chatAttachments"; +import { + DESKTOP_FILE_DROP_EVENT, + filesFromDesktopDrop, + type DesktopFileDropDetail, +} from "../../../utils/desktopFileDrop"; +import { + dragHasFiles, + filesFromSnapshot, + MAX_CHAT_UPLOAD_FILES, + snapshotDroppedEntries, + type CollectedDrop, +} from "../utils/droppedFiles"; import { useServerUploadLimit } from "../../../hooks/useServerUploadLimit"; export function useChatAttachments(agentId: string | null | undefined) { @@ -17,10 +29,25 @@ export function useChatAttachments(agentId: string | null | undefined) { const [uploading, setUploading] = useState(false); const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); + const folderInputRef = useRef(null); const processFiles = useCallback( async (files: FileList | File[]) => { - const fileArr = Array.from(files).filter((f) => { + const incoming = Array.from(files); + const capped = + incoming.length > MAX_CHAT_UPLOAD_FILES + ? incoming.slice(0, MAX_CHAT_UPLOAD_FILES) + : incoming; + if (incoming.length > MAX_CHAT_UPLOAD_FILES) { + antMessage.warning( + t( + "upload.tooMany", + "Only the first {{max}} files can be uploaded at once", + { max: MAX_CHAT_UPLOAD_FILES }, + ), + ); + } + const fileArr = capped.filter((f) => { if (f.size > maxUploadBytes) { antMessage.error( t("upload.tooLarge", "File too large (max {{maxMb}}MB): {{name}}", { @@ -81,6 +108,13 @@ export function useChatAttachments(agentId: string | null | undefined) { fileInputRef.current?.click(); }, []); + const handleFolderSelect = useCallback(() => { + const input = folderInputRef.current; + if (!input) return; + input.setAttribute("webkitdirectory", ""); + input.click(); + }, []); + const handleFileChange = useCallback( (e: ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { @@ -124,41 +158,102 @@ export function useChatAttachments(agentId: string | null | undefined) { ); const handleDragEnter = useCallback((e: DragEvent) => { + if (!dragHasFiles(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); setDragOver(true); }, []); - const handleDragLeave = useCallback((e: DragEvent) => { + const handleDragLeave = useCallback((e: DragEvent) => { + const next = e.relatedTarget; + if (next instanceof Node && e.currentTarget.contains(next)) return; e.preventDefault(); e.stopPropagation(); setDragOver(false); }, []); const handleDragOver = useCallback((e: DragEvent) => { + if (!dragHasFiles(e.dataTransfer)) return; e.preventDefault(); e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + setDragOver(true); }, []); const handleDrop = useCallback( (e: DragEvent) => { + const snapshot = snapshotDroppedEntries(e.dataTransfer); + if (!snapshot.hadFileItems) return; e.preventDefault(); e.stopPropagation(); setDragOver(false); - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - void processFiles(e.dataTransfer.files); - } + return (async () => { + let collected: CollectedDrop; + try { + collected = await filesFromSnapshot(snapshot); + } catch { + antMessage.error(t("upload.failed", "Upload failed")); + return; + } + if (collected.truncated) { + antMessage.warning( + t( + "upload.tooMany", + "Only the first {{max}} files can be uploaded at once", + { max: MAX_CHAT_UPLOAD_FILES }, + ), + ); + } + if (collected.files.length === 0) { + antMessage.warning(t("upload.emptyDrop", "No files to upload")); + return; + } + await processFiles(collected.files); + })(); }, - [processFiles], + [processFiles, t], ); + useEffect(() => { + const onDesktopDrop = (event: Event) => { + const detail = (event as CustomEvent).detail; + setDragOver(false); + void (async () => { + if (detail?.truncated) { + antMessage.warning( + t( + "upload.tooMany", + "Only the first {{max}} files can be uploaded at once", + { max: detail.max ?? 500 }, + ), + ); + } + try { + const files = await filesFromDesktopDrop(detail); + if (files.length === 0) { + antMessage.warning(t("upload.emptyDrop", "No files to upload")); + return; + } + await processFiles(files); + } catch { + antMessage.error(t("upload.failed", "Upload failed")); + } + })(); + }; + window.addEventListener(DESKTOP_FILE_DROP_EVENT, onDesktopDrop); + return () => + window.removeEventListener(DESKTOP_FILE_DROP_EVENT, onDesktopDrop); + }, [processFiles, t]); + return { attachments, uploading, dragOver, fileInputRef, + folderInputRef, processFiles, handleFileSelect, + handleFolderSelect, handleFileChange, removeAttachment, clearAttachments, diff --git a/dashboard/src/pages/Chat/hooks/useChatComposerResources.test.tsx b/dashboard/src/pages/Chat/hooks/useChatComposerResources.test.tsx index b21ec5339b..0b40d22518 100644 --- a/dashboard/src/pages/Chat/hooks/useChatComposerResources.test.tsx +++ b/dashboard/src/pages/Chat/hooks/useChatComposerResources.test.tsx @@ -33,7 +33,10 @@ vi.mock("../../../api/modules/connectors", () => ({ }, })); vi.mock("../../../api/modules/provider", () => ({ - providerApi: { listResolvedModels: vi.fn().mockResolvedValue([]) }, + providerApi: { + listResolvedModels: vi.fn().mockResolvedValue([]), + getActiveModel: vi.fn().mockResolvedValue(null), + }, })); vi.mock("../../../api/modules/preferences", () => ({ preferencesApi: { diff --git a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts index c50b96dcb6..7bf421f332 100644 --- a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts +++ b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts @@ -3,10 +3,10 @@ import { connectorsApi } from "../../../api/modules/connectors"; import { providerApi } from "../../../api/modules/provider"; import { preferencesApi } from "../../../api/modules/preferences"; import { octopThreadsApi } from "../../../api/modules/octopThreads"; -import { request } from "../../../api/request"; import { knowledgeBasesApi, type KnowledgeBase, + type KnowledgeCapability, } from "../../../api/modules/knowledgeBases"; import type { ResolvedModel } from "../../../api/types"; import { CONNECTORS_CHANGED_EVENT } from "../../Agent/Connectors/customMcpUtils"; @@ -58,38 +58,21 @@ export function useChatComposerResources( const currentUserId = user?.id ?? null; const { agents } = useAgent(); const expert = agents.find((item) => item.agent_id === resolvedAgentId); + const expertDefaultMode = parseConversationMode( + expert?.config?.conversation_mode, + ); const teamHost = isTeamAgent(expert); - const expertMcpServers = teamHost ? [] : expert?.mcp_servers; + const bridgeConnectionId = + expert?.bridge && expert.bridge_connection_id + ? expert.bridge_connection_id + : null; + const remoteBridge = Boolean(bridgeConnectionId); + const expertMcpServers = teamHost || remoteBridge ? [] : expert?.mcp_servers; const expertKnowledgeBaseIds = expert?.knowledge_base_ids; const expertMcpKey = (expertMcpServers ?? []).join("\0"); const expertKbKey = (expertKnowledgeBaseIds ?? []).join("\0"); const isNewSession = !activeThreadId || isPendingThread(activeThreadId); const composerTouchedRef = useRef(false); - // FE-4: serialize thread-preference PATCHes so rapid mode switches cannot - // write out of order, and log (never swallow) failures. - const threadPatchQueueRef = useRef>(Promise.resolve()); - const persistThreadPreferences = useCallback( - (body: Record) => { - if ( - !resolvedAgentId || - !activeThreadId || - isPendingThread(activeThreadId) - ) { - return; - } - const agentId = resolvedAgentId; - const threadId = activeThreadId; - threadPatchQueueRef.current = threadPatchQueueRef.current - .then(() => octopThreadsApi.patch(agentId, threadId, body)) - .then( - () => undefined, - (err: unknown) => { - console.error("thread preference save failed", err); - }, - ); - }, - [resolvedAgentId, activeThreadId], - ); const [selectedConnectors, setSelectedConnectors] = useState([]); const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState< string[] @@ -106,6 +89,7 @@ export function useChatComposerResources( }[] >([]); const [availableModels, setAvailableModels] = useState([]); + const [modelsReady, setModelsReady] = useState(false); const [activeModelRef, setActiveModelRef] = useState(null); const [selectedModel, setSelectedModel] = useState(null); const [preferredModel, setPreferredModel] = useState(null); @@ -202,10 +186,10 @@ export function useChatComposerResources( useEffect(() => { setConversationMode( isNewSession - ? DEFAULT_CONVERSATION_MODE + ? expertDefaultMode : parseConversationMode(stickyConversationMode), ); - }, [isNewSession, stickyConversationMode, activeThreadId]); + }, [isNewSession, stickyConversationMode, activeThreadId, expertDefaultMode]); useEffect(() => { setHitlPolicy( @@ -223,43 +207,52 @@ export function useChatComposerResources( } let cancelled = false; const loadConnectors = () => { - void connectorsApi.listInstances().then((instances) => { - if (cancelled) return; - const options = (instances ?? []) - .filter((i) => i.status === "active" && i.has_credentials) - .map((i) => ({ - mcp_server_name: i.mcp_server_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 && i.owner_user_id === currentUserId, - })); - setChatConnectors(options); - const allowed = new Set(options.map((o) => o.mcp_server_name)); - const defaults = withDefaultOpenKnowledgeBases( - options.filter((o) => o.default_open).map((o) => o.mcp_server_name), - expertMcpServers ?? [], - ); - setSelectedConnectors((prev) => - resolveInitialConnectors({ - prev, - saved: resolvedAgentId ? loadSavedConnectors(resolvedAgentId) : [], - hasSaved: resolvedAgentId - ? hasSavedConnectors(resolvedAgentId) - : false, - defaults, - allowed, - ignorePrev: isNewSession && !composerTouchedRef.current, - ignoreSaved: isNewSession, - preferPrev: composerTouchedRef.current, - }), - ); - }); + void connectorsApi + .listInstances(remoteBridge ? resolvedAgentId : undefined) + .then((instances) => { + if (cancelled) return; + const options = (instances ?? []) + .filter((i) => i.status === "active" && i.has_credentials) + .map((i) => ({ + mcp_server_name: i.mcp_server_name, + label: + !remoteBridge && + 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: remoteBridge + ? i.default_open === true + : i.default_open === true && i.owner_user_id === currentUserId, + })); + setChatConnectors(options); + const allowed = new Set(options.map((o) => o.mcp_server_name)); + const defaults = withDefaultOpenKnowledgeBases( + options.filter((o) => o.default_open).map((o) => o.mcp_server_name), + expertMcpServers ?? [], + ); + setSelectedConnectors((prev) => + resolveInitialConnectors({ + prev, + saved: resolvedAgentId + ? loadSavedConnectors(resolvedAgentId) + : [], + hasSaved: resolvedAgentId + ? hasSavedConnectors(resolvedAgentId) + : false, + defaults, + allowed, + ignorePrev: isNewSession && !composerTouchedRef.current, + ignoreSaved: isNewSession, + preferPrev: composerTouchedRef.current, + }), + ); + }); }; loadConnectors(); const onFocus = () => loadConnectors(); @@ -270,7 +263,14 @@ export function useChatComposerResources( window.removeEventListener("focus", onFocus); window.removeEventListener(CONNECTORS_CHANGED_EVENT, loadConnectors); }; - }, [resolvedAgentId, currentUserId, isNewSession, expertMcpKey, teamHost]); + }, [ + resolvedAgentId, + currentUserId, + isNewSession, + expertMcpKey, + teamHost, + remoteBridge, + ]); useEffect(() => { if (teamHost) { @@ -284,15 +284,20 @@ export function useChatComposerResources( setSelectedKnowledgeBaseIds(pendingId ? [pendingId] : []); } setChatKnowledgeBases(undefined); - void knowledgeBasesApi - .getCapability() + + const loadCapability = (): Promise => + knowledgeBasesApi.getCapability(resolvedAgentId); + const loadBases = (): Promise => + knowledgeBasesApi.list(resolvedAgentId); + + void loadCapability() .then((capability) => { if (cancelled) return; if (!capability.usable) { if (pendingId) consumePendingAttachKnowledgeBaseId(); return; } - return knowledgeBasesApi.list().then((bases) => { + return loadBases().then((bases) => { if (cancelled) return; setChatKnowledgeBases(bases); const ownedDefaults = bases @@ -304,8 +309,13 @@ export function useChatComposerResources( ) .map((base) => base.id); const allowed = new Set(bases.map((base) => base.id)); + // Peer owner_user_id rarely matches local user id — for remote + // experts, treat default_open flags on the peer list as usable. + const bridgeDefaults = remoteBridge + ? bases.filter((base) => base.default_open).map((base) => base.id) + : ownedDefaults; const defaults = withDefaultOpenKnowledgeBases( - ownedDefaults, + bridgeDefaults, (expertKnowledgeBaseIds ?? []).filter((id) => allowed.has(id)), ); setSelectedKnowledgeBaseIds((previous) => { @@ -339,18 +349,32 @@ export function useChatComposerResources( return () => { cancelled = true; }; - }, [resolvedAgentId, currentUserId, isNewSession, expertKbKey, teamHost]); + }, [ + resolvedAgentId, + currentUserId, + isNewSession, + expertKbKey, + teamHost, + remoteBridge, + ]); useEffect(() => { let cancelled = false; + setModelsReady(false); const loadModels = () => { void providerApi - .listResolvedModels() + .listResolvedModels(resolvedAgentId) .then((data) => { - if (!cancelled) setAvailableModels(data); + if (!cancelled) { + setAvailableModels(data); + setModelsReady(true); + } }) .catch(() => { - if (!cancelled) setAvailableModels([]); + if (!cancelled) { + setAvailableModels([]); + setModelsReady(true); + } }); }; loadModels(); @@ -360,7 +384,7 @@ export function useChatComposerResources( cancelled = true; window.removeEventListener("focus", onFocus); }; - }, []); + }, [resolvedAgentId]); useEffect(() => { let cancelled = false; @@ -380,9 +404,8 @@ export function useChatComposerResources( useEffect(() => { let cancelled = false; const loadActiveModel = () => { - void request<{ provider_name: string; model: string }>( - "/providers/active-model", - ) + void providerApi + .getActiveModel(resolvedAgentId) .then((active) => { if (!cancelled) setActiveModelRef(activeModelToRef(active)); }) @@ -397,7 +420,7 @@ export function useChatComposerResources( cancelled = true; window.removeEventListener("focus", onFocus); }; - }, []); + }, [resolvedAgentId]); const handleConnectorsChange = useCallback( (names: string[]) => { @@ -451,20 +474,14 @@ export function useChatComposerResources( activeThreadId && !isPendingThread(activeThreadId) ) { - persistThreadPreferences({ + void octopThreadsApi.patch(resolvedAgentId, activeThreadId, { model_ref: model, reasoning_mode: nextMode, reasoning_effort: nextEffort, }); } }, - [ - activeThreadId, - availableModels, - modelReasoning, - resolvedAgentId, - persistThreadPreferences, - ], + [activeThreadId, availableModels, modelReasoning, resolvedAgentId], ); const handleReasoningChange = useCallback( @@ -486,13 +503,13 @@ export function useChatComposerResources( activeThreadId && !isPendingThread(activeThreadId) ) { - persistThreadPreferences({ + void octopThreadsApi.patch(resolvedAgentId, activeThreadId, { reasoning_mode: mode, reasoning_effort: effort, }); } }, - [activeThreadId, resolvedAgentId, selectedModel, persistThreadPreferences], + [activeThreadId, resolvedAgentId, selectedModel], ); const handleConversationModeChange = useCallback( @@ -511,12 +528,12 @@ export function useChatComposerResources( activeThreadId && !isPendingThread(activeThreadId) ) { - persistThreadPreferences({ + void octopThreadsApi.patch(resolvedAgentId, activeThreadId, { conversation_mode: mode, }); } }, - [activeThreadId, resolvedAgentId, persistThreadPreferences], + [activeThreadId, resolvedAgentId], ); const handleHitlPolicyChange = useCallback( @@ -532,12 +549,12 @@ export function useChatComposerResources( activeThreadId && !isPendingThread(activeThreadId) ) { - persistThreadPreferences({ + void octopThreadsApi.patch(resolvedAgentId, activeThreadId, { hitl_policy: next, }); } }, - [activeThreadId, resolvedAgentId, persistThreadPreferences], + [activeThreadId, resolvedAgentId], ); return { @@ -555,6 +572,7 @@ export function useChatComposerResources( chatConnectors, chatKnowledgeBases, availableModels, + modelsReady, activeModelRef, handleConnectorsChange, handleKnowledgeBaseIdsChange, diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts index 44a1257c35..f627346fa2 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts @@ -24,7 +24,7 @@ export type DockTab = | { id: "workspace"; kind: "workspace" } | { id: "browser"; kind: "browser" } | { id: "terminal"; kind: "terminal" } - | { id: string; kind: "file"; path: string } + | { id: string; kind: "file"; path: string; agentId?: string } | { id: string; kind: "knowledge"; @@ -162,26 +162,35 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { }, [openDock]); const openFileAt = useCallback( - (path?: string | null) => { + (path?: string | null, fileAgentId?: string | null) => { if (!path?.trim()) { openFileList(); return; } + const ownerId = (fileAgentId || agentId || "").trim() || null; const hostAbs = normalizeDockFilePath(path); // Keep host-absolute tool paths. Collapsing ``~/.octop/agents//…`` to a // relative key breaks virtual ``root_dir`` nests (bytes live under // ``{root}/Users/…/.octop/agents//…``, not the real agent home). const tabPath = isHostAbsolutePath(hostAbs) ? hostAbs - : canonicalizeDockFilePath(path, agentId); + : canonicalizeDockFilePath(path, ownerId); if (!tabPath) { openFileList(); return; } - const id = dockFileTabId(tabPath, agentId); + const id = dockFileTabId(tabPath, ownerId); setOpenTabs((prev) => { if (prev.some((t) => t.id === id)) return prev; - return [...prev, { id, kind: "file", path: tabPath }]; + return [ + ...prev, + { + id, + kind: "file" as const, + path: tabPath, + ...(ownerId ? { agentId: ownerId } : {}), + }, + ]; }); setActiveTabId(id); openDock(); diff --git a/dashboard/src/pages/Chat/hooks/useChatFileDetection.ts b/dashboard/src/pages/Chat/hooks/useChatFileDetection.ts index a5dd1e6932..be74c06276 100644 --- a/dashboard/src/pages/Chat/hooks/useChatFileDetection.ts +++ b/dashboard/src/pages/Chat/hooks/useChatFileDetection.ts @@ -159,27 +159,31 @@ export function collectChatFilePaths( ): string[] { const paths: string[] = []; for (const m of messages) { - addPath(paths, extractWriteToolPath(m), agentId); + const ownerId = m.speakerAgentId?.trim() || agentId; + addPath(paths, extractWriteToolPath(m), ownerId); + for (const path of m.editedFiles ?? []) { + addPath(paths, path, ownerId); + } for (const att of m.attachments ?? []) { if (att.workspacePath) { - addPath(paths, att.workspacePath, agentId); + addPath(paths, att.workspacePath, ownerId); } else if (att.url) { - addPath(paths, workspacePathFromAccessUrl(att.url) ?? null, agentId); + addPath(paths, workspacePathFromAccessUrl(att.url) ?? null, ownerId); } } const media = collectToolMediaFromToolData( m.toolData, - agentId, + ownerId, m.attachments, ); for (const file of media.files) { - addPath(paths, workspacePathFromAccessUrl(file.url) ?? null, agentId); + addPath(paths, workspacePathFromAccessUrl(file.url) ?? null, ownerId); } for (const img of media.images) { - addPath(paths, workspacePathFromAccessUrl(img.url) ?? null, agentId); + addPath(paths, workspacePathFromAccessUrl(img.url) ?? null, ownerId); } for (const video of media.videos) { - addPath(paths, workspacePathFromAccessUrl(video.url) ?? null, agentId); + addPath(paths, workspacePathFromAccessUrl(video.url) ?? null, ownerId); } } return dedupeDockFilePaths(paths, agentId); diff --git a/dashboard/src/pages/Chat/hooks/useChatSend.ts b/dashboard/src/pages/Chat/hooks/useChatSend.ts index 6e8ae7a424..65c6c19d85 100644 --- a/dashboard/src/pages/Chat/hooks/useChatSend.ts +++ b/dashboard/src/pages/Chat/hooks/useChatSend.ts @@ -307,7 +307,7 @@ export function useChatSend({ const pushMsg = { id: generateId(), role: "assistant" as const, - content: `🎬 录制已开始!请在浏览器中进行你想要自动化的操作。\n\n操作完成后,输入"结束"即可结束录制,系统将自动生成技能脚本。`, + content: t("skillRecord.recordingStartedMessage"), timestamp: Date.now(), status: "done" as const, }; @@ -328,7 +328,7 @@ export function useChatSend({ window.addEventListener("octop:pending-chat-message", handler); return () => window.removeEventListener("octop:pending-chat-message", handler); - }, [handleSend, activeThreadId, onAutoRecordingStarted]); + }, [handleSend, activeThreadId, onAutoRecordingStarted, t]); return { handleSend }; } diff --git a/dashboard/src/pages/Chat/hooks/useChatSessionActions.ts b/dashboard/src/pages/Chat/hooks/useChatSessionActions.ts index 9b25649317..f552ce6695 100644 --- a/dashboard/src/pages/Chat/hooks/useChatSessionActions.ts +++ b/dashboard/src/pages/Chat/hooks/useChatSessionActions.ts @@ -14,7 +14,7 @@ interface UseChatSessionActionsParams { setSidebarOpen: (open: boolean) => void; setSelectedModel: (model: string | null) => void; setHasBrowserTool: (value: boolean) => void; - deleteSession: (id: string) => Promise; + deleteSession: (id: string, compact: boolean) => Promise; clearMessages: () => void; resetNavForAgentSwitch: () => void; markInitialNavDone: (agentId: string) => void; @@ -133,9 +133,9 @@ export function useChatSessionActions({ ); const handleDeleteSession = useCallback( - async (id: string) => { - const deleted = await deleteSession(id); - if (!deleted) return; + async (id: string, compact: boolean) => { + const deleted = await deleteSession(id, compact); + if (!deleted) return false; const agent = resolvedAgentId; if (id === activeThreadId && agent) { const remaining = sessions.filter((s) => s.id !== id); @@ -147,6 +147,7 @@ export function useChatSessionActions({ clearMessages(); } } + return true; }, [ activeThreadId, diff --git a/dashboard/src/pages/Chat/hooks/useCollapseThinking.ts b/dashboard/src/pages/Chat/hooks/useCollapseThinking.ts index 293706bd69..bfca50cfd5 100644 --- a/dashboard/src/pages/Chat/hooks/useCollapseThinking.ts +++ b/dashboard/src/pages/Chat/hooks/useCollapseThinking.ts @@ -1,9 +1,21 @@ import { useLocalStorageState } from "ahooks"; -/** Browser-wide display preference, shared by the menu and process panels. */ -export function useCollapseThinking() { - return useLocalStorageState("octop:collapse-thinking", { - defaultValue: false, +/** Solo / 1:1 chat preference (default: expand while streaming). */ +export const COLLAPSE_THINKING_SOLO_KEY = "octop:collapse-thinking"; +/** Team room preference (default: collapse while streaming). */ +export const COLLAPSE_THINKING_TEAM_KEY = "octop:collapse-thinking:team"; + +export function collapseThinkingStorageKey(isTeam: boolean): string { + return isTeam ? COLLAPSE_THINKING_TEAM_KEY : COLLAPSE_THINKING_SOLO_KEY; +} + +/** + * Browser display preference for folding thinking/tool panels while streaming. + * Team and solo chats use separate keys so toggling one does not affect the other. + */ +export function useCollapseThinking(isTeam = false) { + return useLocalStorageState(collapseThinkingStorageKey(isTeam), { + defaultValue: isTeam, listenStorageChange: true, }); } diff --git a/dashboard/src/pages/Chat/hooks/useHistoryMigration.ts b/dashboard/src/pages/Chat/hooks/useHistoryMigration.ts index 5d34f032a8..ae36204de7 100644 --- a/dashboard/src/pages/Chat/hooks/useHistoryMigration.ts +++ b/dashboard/src/pages/Chat/hooks/useHistoryMigration.ts @@ -11,9 +11,10 @@ export function useHistoryMigration( const [status, setStatus] = useState(null); const [starting, setStarting] = useState(false); const [startFailed, setStartFailed] = useState(false); + const bridgeAgent = Boolean(agentId?.startsWith("bridge:")); useEffect(() => { - if (!agentId || !enabled) { + if (!agentId || !enabled || bridgeAgent) { setStatus(null); setStartFailed(false); return; @@ -39,7 +40,7 @@ export function useHistoryMigration( stopped = true; if (timer != null) window.clearTimeout(timer); }; - }, [agentId, enabled]); + }, [agentId, enabled, bridgeAgent]); const start = useCallback(async () => { if (!agentId || starting) return; diff --git a/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts b/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts index 1978e79bbf..b57d3d1c43 100644 --- a/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts +++ b/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts @@ -46,11 +46,13 @@ export function useMemoryMaintenance( ) { const [status, setStatus] = useState(null); const [connectionLost, setConnectionLost] = useState(false); + const bridgeAgent = Boolean(agentId?.startsWith("bridge:")); useEffect(() => { setStatus(null); setConnectionLost(false); - if (!agentId || !enabled) { + // Bridge shadows have no local harness status / memory maintenance. + if (!agentId || !enabled || bridgeAgent) { return; } let stop = false; @@ -78,7 +80,7 @@ export function useMemoryMaintenance( stop = true; if (timer != null) window.clearTimeout(timer); }; - }, [agentId, enabled]); + }, [agentId, enabled, bridgeAgent]); const phase = status?.phase ?? "idle"; return { diff --git a/dashboard/src/pages/Chat/hooks/useSessionWorkIds.test.ts b/dashboard/src/pages/Chat/hooks/useSessionWorkIds.test.ts new file mode 100644 index 0000000000..d290099cf2 --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useSessionWorkIds.test.ts @@ -0,0 +1,41 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useSessionWorkIds } from "./useSessionWorkIds"; +import type { StreamEvent } from "./chatStore"; + +let listener: ((event: StreamEvent) => void) | null = null; + +vi.mock("./chatStore", () => ({ + onStreamEvent: (fn: (event: StreamEvent) => void) => { + listener = fn; + return () => { + listener = null; + }; + }, +})); + +describe("useSessionWorkIds", () => { + afterEach(() => { + listener = null; + }); + + it("tracks stream start and end for a thread", () => { + const { result } = renderHook(() => useSessionWorkIds()); + expect(result.current.has("thr_1")).toBe(false); + + act(() => { + listener?.({ kind: "streamStart", sessionId: "thr_1" }); + }); + expect(result.current.has("thr_1")).toBe(true); + + act(() => { + listener?.({ kind: "streamResume", sessionId: "thr_1" }); + }); + expect(result.current.has("thr_1")).toBe(true); + + act(() => { + listener?.({ kind: "streamEnd", sessionId: "thr_1" }); + }); + expect(result.current.has("thr_1")).toBe(false); + }); +}); diff --git a/dashboard/src/pages/Chat/hooks/useSessionWorkIds.ts b/dashboard/src/pages/Chat/hooks/useSessionWorkIds.ts new file mode 100644 index 0000000000..bad884478c --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useSessionWorkIds.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; +import { onStreamEvent } from "./chatStore"; + +/** Thread ids whose turn is streaming in this tab (start/resume until end). */ +export function useSessionWorkIds(): ReadonlySet { + const [ids, setIds] = useState>(() => new Set()); + + useEffect(() => { + return onStreamEvent((event) => { + setIds((prev) => { + const busy = event.kind !== "streamEnd"; + if (busy === prev.has(event.sessionId)) return prev; + const next = new Set(prev); + if (busy) next.add(event.sessionId); + else next.delete(event.sessionId); + return next; + }); + }); + }, []); + + return ids; +} diff --git a/dashboard/src/pages/Chat/hooks/useSessions.test.ts b/dashboard/src/pages/Chat/hooks/useSessions.test.ts index 8a954621df..cd62caf506 100644 --- a/dashboard/src/pages/Chat/hooks/useSessions.test.ts +++ b/dashboard/src/pages/Chat/hooks/useSessions.test.ts @@ -19,6 +19,7 @@ vi.mock("../../../api/modules/octopThreads", () => ({ rename: vi.fn(), rebind: vi.fn(), }, + normalizeThreadArtifacts: () => [], })); function threadRow(threadId: string, agentExtra?: Partial<{ title: string }>) { @@ -74,6 +75,18 @@ describe("toSession / sortSessions ordering", () => { "thr_recent", ]); }); + + it("maps turn_active and awaiting_user onto the session", () => { + const session = toSession({ + thread_id: "thr_work", + title: "busy", + last_active: 1, + turn_active: true, + awaiting_user: true, + }); + expect(session.turnActive).toBe(true); + expect(session.awaitingUser).toBe(true); + }); }); describe("useSessions agent switch", () => { diff --git a/dashboard/src/pages/Chat/hooks/useSessions.ts b/dashboard/src/pages/Chat/hooks/useSessions.ts index 24fbba191e..724d5bd675 100644 --- a/dashboard/src/pages/Chat/hooks/useSessions.ts +++ b/dashboard/src/pages/Chat/hooks/useSessions.ts @@ -1,5 +1,11 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; -import { octopThreadsApi } from "../../../api/modules/octopThreads"; +import { useTranslation } from "react-i18next"; +import { deleteConversation } from "../utils/deleteConversation"; +import { + normalizeThreadArtifacts, + octopThreadsApi, + type ThreadArtifact, +} from "../../../api/modules/octopThreads"; import type { HitlSessionPolicy } from "../../../api/types/hitl"; import * as chatStore from "./chatStore"; import { onSessionEvent } from "./chatStore"; @@ -21,7 +27,9 @@ export interface Session { conversationMode?: "ask" | "plan" | "craft" | null; pendingPlanPath?: string | null; hitlPolicy?: HitlSessionPolicy | null; - artifacts?: string[]; + artifacts?: ThreadArtifact[]; + turnActive?: boolean; + awaitingUser?: boolean; } /** Result of probing whether a thread exists for the current agent. */ @@ -42,7 +50,11 @@ export function toSession(row: { conversation_mode?: "ask" | "plan" | "craft" | null; pending_plan_path?: string | null; hitl_policy?: HitlSessionPolicy | null; - artifacts?: string[] | null; + artifacts?: Array | null; + artifact_refs?: ThreadArtifact[] | null; + agent_id?: string | null; + turn_active?: boolean; + awaiting_user?: boolean; }): Session { const hasActivity = Boolean(row.has_messages) || Boolean(row.title) || row.last_active > 0; @@ -70,12 +82,13 @@ export function toSession(row: { hitlPolicy: row.hitl_policy ? parseHitlSessionPolicy(row.hitl_policy) : null, - artifacts: Array.isArray(row.artifacts) - ? row.artifacts.filter( - (path): path is string => - typeof path === "string" && path.trim().length > 0, - ) - : [], + artifacts: normalizeThreadArtifacts( + row.artifacts, + row.agent_id, + row.artifact_refs, + ), + turnActive: Boolean(row.turn_active), + awaitingUser: Boolean(row.awaiting_user), }; } @@ -173,19 +186,23 @@ export function syncSessionConversationMode( } /** Patch artifacts for one thread in the module session store. */ -export function syncSessionArtifacts(threadId: string, artifacts: string[]) { +export function syncSessionArtifacts( + threadId: string, + artifacts: ThreadArtifact[], +) { if (!threadId) return; - const normalized = artifacts.filter( - (path): path is string => - typeof path === "string" && path.trim().length > 0, - ); + const normalized = normalizeThreadArtifacts(artifacts); setModuleSessions((prev) => { const idx = prev.findIndex((s) => s.id === threadId); if (idx < 0) return prev; const current = prev[idx].artifacts ?? []; if ( current.length === normalized.length && - current.every((p, i) => p === normalized[i]) + current.every( + (p, i) => + p.path === normalized[i]?.path && + (p.agent_id || "") === (normalized[i]?.agent_id || ""), + ) ) { return prev; } @@ -199,19 +216,18 @@ export function syncSessionArtifacts(threadId: string, artifacts: string[]) { export async function fetchAndSyncSessionArtifacts( agentId: string, threadId: string, -): Promise { +): Promise { if (!agentId || !threadId) return []; try { const history = await octopThreadsApi.history(agentId, threadId, { limit: 1, offset: 0, }); - const artifacts = Array.isArray(history.artifacts) - ? history.artifacts.filter( - (path): path is string => - typeof path === "string" && path.trim().length > 0, - ) - : []; + const artifacts = normalizeThreadArtifacts( + history.artifacts, + agentId, + history.artifact_refs, + ); syncSessionArtifacts(threadId, artifacts); return artifacts; } catch { @@ -304,7 +320,9 @@ async function fetchSessionsPage( ): Promise<{ sessions: Session[]; hasMore: boolean }> { const rows = await octopThreadsApi.list(agentId, limit + 1); const hasMore = rows.length > limit; - const sessions = sortSessions(rows.slice(0, limit).map(toSession)); + const sessions = sortSessions( + rows.slice(0, limit).map((row) => toSession({ ...row, agent_id: agentId })), + ); return { sessions, hasMore }; } @@ -371,6 +389,7 @@ export function resetSessionStoreForTests() { } export function useSessions(agentId: string | null) { + const { t } = useTranslation(); syncStoreToAgent(agentId); const { sessions, loading, hasMore, loadingMore } = useSyncExternalStore( subscribeSessionStore, @@ -539,6 +558,7 @@ export function useSessions(agentId: string | null) { last_active: 0, created_at: now, channel_type: "dashboard", + agent_id: agentId, }); setModuleSessions((prev) => sortSessions([ @@ -556,19 +576,16 @@ export function useSessions(agentId: string | null) { }, [agentId]); const deleteSession = useCallback( - async (id: string) => { + async (id: string, compact: boolean) => { if (!agentId || !id) return false; - try { - await octopThreadsApi.delete(agentId, id); - setModuleSessions((prev) => prev.filter((s) => s.id !== id)); - chatStore.removeSession(id); - chatStore.emitSessionEvent({ kind: "sessionDeleted", sessionId: id }); - return true; - } catch { - return false; - } + const deleted = await deleteConversation(agentId, id, compact, t); + if (!deleted) return false; + setModuleSessions((prev) => prev.filter((s) => s.id !== id)); + chatStore.removeSession(id); + chatStore.emitSessionEvent({ kind: "sessionDeleted", sessionId: id }); + return true; }, - [agentId], + [agentId, t], ); const pinSession = useCallback( diff --git a/dashboard/src/pages/Chat/hooks/useSkillRecordingWorkflow.ts b/dashboard/src/pages/Chat/hooks/useSkillRecordingWorkflow.ts index 3b1277f3e2..cbc2dc9e7f 100644 --- a/dashboard/src/pages/Chat/hooks/useSkillRecordingWorkflow.ts +++ b/dashboard/src/pages/Chat/hooks/useSkillRecordingWorkflow.ts @@ -108,28 +108,26 @@ export function useSkillRecordingWorkflow({ const previewText = previewLines.join("\n"); const truncationNotice = data.skillContent.split("\n").length > 20 - ? `\n\n... (共 ${ - data.skillContent.split("\n").length - } 行,完整内容将在确认后保存)` + ? t("skillRecord.previewTruncation", { + lines: data.skillContent.split("\n").length, + }) : ""; chatStore.appendPushMessage( - `✅ 录制完成!已生成 ${data.steps ?? 0} 个回放步骤。\n\n` + - `📝 **技能脚本预览:**\n\n${previewText}${truncationNotice}\n\n` + - `回复"确认"即可应用此技能,之后可以一键回放相同操作流程。`, + t("skillRecord.previewMessage", { + steps: data.steps ?? 0, + preview: previewText, + truncation: truncationNotice, + }), ); } else { chatStore.appendPushMessage( - `✅ 录制完成!已生成 ${data.steps ?? 0} 个回放步骤。\n\n` + - `⚠️ 技能脚本生成失败,请稍后重试或手动生成。`, + t("skillRecord.previewNoScript", { steps: data.steps ?? 0 }), ); } antMessage.success( - t( - "skillRecord.recordingStopped", - `录制完成,已生成 ${data.steps ?? 0} 个回放步骤`, - ), + t("skillRecord.recordingStopped", { steps: data.steps ?? 0 }), ); } catch (err) { antMessage.error( @@ -181,15 +179,15 @@ export function useSkillRecordingWorkflow({ // Push confirmation message to chat chatStore.appendPushMessage( - `🎉 技能 **${result.name || pendingSkillName}** 已成功应用!\n\n` + - `之后可以通过聊天输入相关指令,一键回放相同的浏览器操作流程。`, + t("skillRecord.appliedMessage", { + name: result.name || pendingSkillName, + }), ); antMessage.success( - t( - "skillRecord.skillApplied", - `技能 "${result.name || pendingSkillName}" 已成功应用`, - ), + t("skillRecord.skillApplied", { + name: result.name || pendingSkillName, + }), ); } catch (err) { antMessage.error( diff --git a/dashboard/src/pages/Chat/index.module.less b/dashboard/src/pages/Chat/index.module.less index 776b318220..2990b0f6d4 100644 --- a/dashboard/src/pages/Chat/index.module.less +++ b/dashboard/src/pages/Chat/index.module.less @@ -76,7 +76,8 @@ padding-inline-start: var(--chat-turn-rail-gutter); } - .askQuestionDock { + .askQuestionDock, + .modelConfigDock { padding-inline-start: var(--chat-turn-rail-gutter); padding-inline-end: var(--chat-column-pad-x, 24px); } diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index a8bd43d55f..586c006052 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -17,8 +17,9 @@ import { message as antMessage } from "@/utils/antdMessage"; import { showConfirmModal } from "../../utils/confirmModal"; import PlanReadyCard from "./components/PlanReadyCard"; import { useIsMobile } from "../../hooks/useIsMobile"; +import { useHitlEnabled } from "../../hooks/useHitlEnabled"; import { useCurrentUser } from "../../hooks/useCurrentUser"; -import { userCan } from "../../utils/permissions"; +import { navAllowed, userCan } from "../../utils/permissions"; import { useChat } from "./hooks/useChat"; import { useSessions, fetchAndSyncSessionArtifacts } from "./hooks/useSessions"; import * as chatStore from "./hooks/chatStore"; @@ -37,8 +38,15 @@ import { useChatSessionActions } from "./hooks/useChatSessionActions"; import { useChatComposerResources } from "./hooks/useChatComposerResources"; import type { HitlSessionPolicy } from "./utils/hitlSessionPolicy"; -import { mergeAllowTools } from "./utils/hitlSessionPolicy"; +import { + mergeAllowTools, + parseHitlSessionPolicy, +} from "./utils/hitlSessionPolicy"; import { useChatContextWindow } from "./hooks/useChatContextWindow"; +import { + disableProviderStreamUsage, + providerIdForTurn, +} from "../../utils/disableProviderStreamUsage"; import { useBrowserToolDetection } from "./hooks/useBrowserToolDetection"; import { useSkillRecordingWorkflow } from "./hooks/useSkillRecordingWorkflow"; import { listDockFilePathsForTree } from "./utils/dockFilePath"; @@ -55,6 +63,7 @@ import MessageList from "./components/MessageList"; import ChatInput, { type ChatInputHandle } from "./components/ChatInput"; import WelcomeScreen from "./components/WelcomeScreen"; import AgentNotReadyScreen from "./components/AgentNotReadyScreen"; +import ModelConfigEmpty from "./components/ModelConfigEmpty"; import AgentProfileDrawer from "../../components/AgentProfileDrawer"; import TrajectoryDrawer from "./components/TrajectoryDrawer"; import { useExpertChatWelcome } from "./hooks/useExpertQuickCards"; @@ -87,9 +96,10 @@ import TeamChatBadge from "./components/TeamChatBadge"; import ChatComposerChrome from "./components/ChatComposerChrome"; import AskQuestionCard from "./components/AskQuestionCard"; import { - findPendingApproval, + findAutoResumableApproval, findPendingAsk, hasPendingHitl, + isResumableHitl, } from "./utils/pendingHitl"; import { isAgentChatReady } from "../../utils/agentError"; import { useMemoryMaintenance } from "./hooks/useMemoryMaintenance"; @@ -124,10 +134,12 @@ function ChatPageInner() { threadId: threadId ?? null, }); const isMobile = useIsMobile(); + const hitlEnabled = useHitlEnabled(); const user = useCurrentUser(); const { layoutMode } = useLayoutMode(); const isMinimalLayout = layoutMode === "minimal"; const canTerminal = userCan(user, "terminal"); + const canConfigureModels = navAllowed(user, "models"); const chatHistoryRail = useChatHistoryRail(); const [browserRecording, setBrowserRecording] = useState(false); const [browserRecordingId, setBrowserRecordingId] = useState( @@ -196,7 +208,7 @@ function ChatPageInner() { () => agents.find((a) => a.agent_id === resolvedAgentId) ?? null, [agents, resolvedAgentId], ); - const agentChatReady = isAgentChatReady(activeAgent?.state); + const agentChatReady = isAgentChatReady(activeAgent?.state, activeAgent); const trajectoryEnabled = activeAgent !== null && activeAgent.config?.enable_trajectory !== false; const sharedExpertViewer = isSharedExpertViewer(activeAgent ?? {}); @@ -345,6 +357,7 @@ function ChatPageInner() { historyHydrated, contextUsage, pendingPlanPath, + liveSpeakers, sendMessage, editAndResend, cancelStream, @@ -361,6 +374,7 @@ function ChatPageInner() { [messages], ); const pendingAsk = useMemo(() => findPendingAsk(messages), [messages]); + const autoResumedApprovalRef = useRef(null); const refreshBrowserRef = useRef<() => void>(() => {}); @@ -376,7 +390,11 @@ function ChatPageInner() { controlOwner: browserControlOwner, environment: browserEnvironment, refresh: refreshBrowserSession, - } = useBrowserSessionState(threadId, hasBrowserTool); + } = useBrowserSessionState( + threadId, + hasBrowserTool, + activeAgent?.bridge ? activeAgent.bridge_connection_id : null, + ); refreshBrowserRef.current = refreshBrowserSession; @@ -406,6 +424,10 @@ function ChatPageInner() { } = useChatDockPanel(isMobile, resolvedAgentId); const chromeCheckInFlightRef = useRef(false); + const bridgeConnectionId = + activeAgent?.bridge && activeAgent.bridge_connection_id + ? activeAgent.bridge_connection_id + : null; const ensureChromeThen = useCallback( async (then: () => void) => { // A live session means Chrome is already running — skip the probe. @@ -413,8 +435,21 @@ function ChatPageInner() { if (chromeCheckInFlightRef.current) return; chromeCheckInFlightRef.current = true; try { - const env = await browserApi.checkEnvStatus(); + const env = await browserApi.checkEnvStatus(resolvedAgentId); if (shouldJumpToChromeInstall(env)) { + // Peer Chromium install is not tunneled — only nudge local install UX. + if (bridgeConnectionId) { + showConfirmModal( + { + title: t("browserWorkspace.chromeMissingTitle"), + content: t("chat.remoteExpert.manageToast"), + okText: t("common.confirm"), + cancelText: t("common.cancel"), + }, + { isMobile }, + ); + return; + } showConfirmModal( { title: t("browserWorkspace.chromeMissingTitle"), @@ -437,7 +472,14 @@ function ChatPageInner() { } then(); }, - [browserSessionId, isMobile, navigate, t], + [ + browserSessionId, + bridgeConnectionId, + isMobile, + navigate, + resolvedAgentId, + t, + ], ); const handleToggleBrowserPanel = useCallback(() => { @@ -496,14 +538,28 @@ function ChatPageInner() { const panelFilePaths = useMemo(() => { const fromTabs = openTabs .filter((tab) => tab.kind === "file") - .map((tab) => tab.path); - const fromThread = composerSession?.artifacts ?? []; + .map((tab) => ({ + path: tab.path, + ...(tab.agentId ? { agentId: tab.agentId } : {}), + })); + const fromThread = (composerSession?.artifacts ?? []).map((item) => ({ + path: item.path, + ...(item.agent_id ? { agentId: item.agent_id } : {}), + })); return listDockFilePathsForTree( [...fromThread, ...fromTabs], resolvedAgentId, ); }, [openTabs, resolvedAgentId, composerSession?.artifacts]); + const dockAgentNameById = useMemo(() => { + const map: Record = {}; + for (const a of agents) { + if (a.agent_id) map[a.agent_id] = a.name || a.agent_id; + } + return map; + }, [agents]); + const { selectedModel, setSelectedModel, @@ -512,6 +568,7 @@ function ChatPageInner() { chatConnectors, chatKnowledgeBases, availableModels, + modelsReady, activeModelRef, reasoningMode, reasoningEffort, @@ -599,13 +656,22 @@ function ChatPageInner() { // Subset for the chat-side *pickers* (`@` button popover, `@` mention menu). // Only running experts — picking a stopped one would dispatch into an // unloaded harness and silently fail. - const chatAgentOptionsPickable = useMemo( - () => - selectEnabledExperts(agents, null, { pinActive: false }) - .filter((item) => !isTeamAgent(item)) - .map(projectChatAgentOption), - [agents], - ); + // Bridge sessions: only peers on the same connection (local ask_agent + // cannot reach them; peer ask_agent cannot reach local experts). + const chatAgentOptionsPickable = useMemo(() => { + let list = selectEnabledExperts(agents, null, { pinActive: false }).filter( + (item) => !isTeamAgent(item), + ); + if (activeAgent?.bridge && activeAgent.bridge_connection_id) { + const cid = activeAgent.bridge_connection_id; + list = list.filter( + (item) => item.bridge && item.bridge_connection_id === cid, + ); + } else { + list = list.filter((item) => !item.bridge); + } + return list.map(projectChatAgentOption); + }, [agents, activeAgent?.bridge, activeAgent?.bridge_connection_id]); const teamExpertOptions = useMemo(() => { if (!isTeamChat) return chatAgentOptionsPickable; const ids = new Set(activeAgent?.member_ids ?? []); @@ -834,21 +900,27 @@ function ChatPageInner() { policy.mode === "allow_tools" ? mergeAllowTools(hitlPolicy, policy.tools ?? []) : policy; + const pending = findAutoResumableApproval(next, messages); + if (pending && activeThreadId) { + autoResumedApprovalRef.current = `${activeThreadId}:${pending.messageId}`; + } handleHitlPolicyChange(next, { persist: false }); resumeHitl(decisions, activeThreadId ?? undefined, undefined, next); return; } resumeHitl(decisions, activeThreadId ?? undefined); }, - [resumeHitl, activeThreadId, handleHitlPolicyChange, hitlPolicy], + [resumeHitl, activeThreadId, handleHitlPolicyChange, hitlPolicy, messages], ); const handleComposerHitlPolicyChange = useCallback( (policy: HitlSessionPolicy) => { - const pending = - policy.mode === "allow_all" ? findPendingApproval(messages) : null; + const pending = findAutoResumableApproval(policy, messages); handleHitlPolicyChange(policy, { persist: !pending }); if (!pending) return; + if (activeThreadId) { + autoResumedApprovalRef.current = `${activeThreadId}:${pending.messageId}`; + } resumeHitl( pending.actions.map(() => ({ type: "approve" })), activeThreadId ?? undefined, @@ -859,6 +931,33 @@ function ChatPageInner() { [handleHitlPolicyChange, messages, resumeHitl, activeThreadId], ); + useEffect(() => { + if (isStreaming || !activeThreadId) return; + const sessionPolicy = parseHitlSessionPolicy(composerSession?.hitlPolicy); + const pending = findAutoResumableApproval(sessionPolicy, messages); + if (!pending) { + autoResumedApprovalRef.current = null; + return; + } + const card = messages.find((message) => message.id === pending.messageId); + if (!isResumableHitl(card?.hitlData)) return; + const resumeKey = `${activeThreadId}:${pending.messageId}`; + if (autoResumedApprovalRef.current === resumeKey) return; + autoResumedApprovalRef.current = resumeKey; + resumeHitl( + pending.actions.map(() => ({ type: "approve" })), + activeThreadId, + undefined, + sessionPolicy, + ); + }, [ + composerSession?.hitlPolicy, + messages, + activeThreadId, + resumeHitl, + isStreaming, + ]); + /** 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( @@ -916,6 +1015,41 @@ function ChatPageInner() { [messages, wrappedHandleSend, hasPendingHitlPause, t], ); + const handleDisableStreamUsage = useCallback(async () => { + let lastUserModel: string | null = null; + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (messages[i].role === "user") { + lastUserModel = messages[i].composerContext?.model ?? null; + break; + } + } + const providerId = providerIdForTurn(availableModels, [ + lastUserModel, + selectedModel, + activeAgent?.default_model, + activeModelRef, + ]); + if (providerId == null) { + antMessage.error(t("chat.disableStreamUsageFailed")); + return false; + } + try { + await disableProviderStreamUsage(providerId); + antMessage.success(t("chat.disableStreamUsageSuccess")); + return true; + } catch { + antMessage.error(t("chat.disableStreamUsageFailed")); + return false; + } + }, [ + availableModels, + selectedModel, + activeAgent?.default_model, + activeModelRef, + messages, + t, + ]); + // Edit user message: truncate history from that message onwards, replace // its content, and re-send — mirrors Claude / ChatGPT "edit message" behaviour. const handleEditUserMessage = useCallback( @@ -1293,13 +1427,15 @@ function ChatPageInner() { noAgents={noAgents} loading={agentsLoading} /> + ) : showWelcome && modelsReady && availableModels.length === 0 ? ( + ) : showWelcome ? ( 0} isTeam={isTeamChat} /> ) : ( @@ -1324,10 +1460,11 @@ function ChatPageInner() { onLoadMoreHistory={loadMoreHistory} onRefreshHistory={refreshHistory} isStreaming={isStreaming} + liveSpeakers={liveSpeakers} thinkingStartedAt={thinkingStartedAt} sessionKey={activeThreadId ?? undefined} - onCancel={cancelStream} onRegenerate={handleRegenerate} + onDisableStreamUsage={handleDisableStreamUsage} onEditUserMessage={handleEditUserMessage} onForkAssistantMessage={handleForkAssistantMessage} forkDisabled={forkDisabled} @@ -1562,6 +1699,33 @@ function ChatPageInner() {
) : null} + {!showWelcome && modelsReady && availableModels.length === 0 ? ( +
+
+ navigate("/admin/models")} + > + {t("modelConfig.configureButton")} + + ) : null + } + /> +
+
+ ) : null} [\s\S]*?<\/redacted_thinking>\s*/gi; +const THINKING_TAG_RE = + /<(?:think|thinking)>[\s\S]*?<\/(?:think|thinking)>\s*/gi; export function stripThinkingTags(text: string): string { - return text.replace(THINKING_TAG_RE, "").trim(); + let result = text.replace(THINKING_TAG_RE, ""); + const unclosed = result.search(/<(?:think|thinking)>/i); + if (unclosed >= 0) { + result = result.slice(0, unclosed); + } + return result.trim(); } export function isImageMediaType(mime?: string): boolean { diff --git a/dashboard/src/pages/Chat/utils/conversationMode.ts b/dashboard/src/pages/Chat/utils/conversationMode.ts index f39b67a374..7b41a352d0 100644 --- a/dashboard/src/pages/Chat/utils/conversationMode.ts +++ b/dashboard/src/pages/Chat/utils/conversationMode.ts @@ -2,6 +2,13 @@ export type ConversationMode = "ask" | "plan" | "craft"; export const DEFAULT_CONVERSATION_MODE: ConversationMode = "craft"; +/** Display order for mode pickers / selects; labels come from chat.conversationMode.*. */ +export const CONVERSATION_MODES: readonly ConversationMode[] = [ + "craft", + "plan", + "ask", +]; + export function parseConversationMode(value: unknown): ConversationMode { if (value === "ask" || value === "plan" || value === "craft") return value; return DEFAULT_CONVERSATION_MODE; diff --git a/dashboard/src/pages/Chat/utils/deleteConversation.tsx b/dashboard/src/pages/Chat/utils/deleteConversation.tsx new file mode 100644 index 0000000000..fd787b77bb --- /dev/null +++ b/dashboard/src/pages/Chat/utils/deleteConversation.tsx @@ -0,0 +1,107 @@ +import { Button } from "antd"; +import type { TFunction } from "i18next"; +import { + octopThreadsApi, + type ThreadDeleteResult, +} from "../../../api/modules/octopThreads"; +import { apiErrorMessage } from "../../../utils/apiError"; +import { message as antMessage } from "../../../utils/antdMessage"; +import { modal } from "../../../utils/antdModal"; + +const MOBILE_BREAKPOINT = 768; + +function deleteResultText( + t: TFunction, + compact: boolean, + result: ThreadDeleteResult | undefined, +): string { + if (compact && result?.compacted) return t("chat.deleteSessionCompacted"); + if (compact) return t("chat.deleteSessionCompactLater"); + return t("chat.deleteSessionLater"); +} + +/** Delete one conversation. ``compact`` rebuilds the database before returning. */ +export async function deleteConversation( + agentId: string, + threadId: string, + compact: boolean, + t: TFunction, +): Promise { + const closeLoading = compact + ? antMessage.loading(t("chat.deleteSessionCompacting"), 0) + : undefined; + try { + const result = await octopThreadsApi.delete(agentId, threadId, compact); + if (typeof closeLoading === "function") closeLoading(); + antMessage.success(deleteResultText(t, compact, result)); + return true; + } catch (error) { + if (typeof closeLoading === "function") closeLoading(); + antMessage.error(apiErrorMessage(error, t("sessions.deleteFailed"), t)); + return false; + } +} + +/** + * Confirm deleting a conversation. + * + * Delete-and-compact shrinks the database file now and pauses the expert. + * Delete-only leaves that to idle maintenance. + */ +export function confirmDeleteConversation( + t: TFunction, + onConfirm: (compact: boolean) => void | Promise, +): void { + const mobile = + typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT; + let chosen = false; + const handle: { destroy?: () => void } = {}; + const choose = (compact: boolean) => { + if (chosen) return; + chosen = true; + handle.destroy?.(); + void onConfirm(compact); + }; + const buttonStyle = mobile ? { width: "100%" } : undefined; + const opened = modal.confirm({ + title: t("chat.deleteSessionTitle"), + icon: null, + width: mobile ? Math.min(400, Math.max(280, window.innerWidth - 32)) : 480, + content: ( +
+

{t("chat.deleteSessionBody")}

+

+ {t("chat.deleteSessionAndCompactHint")} +

+

{t("chat.deleteSessionOnlyHint")}

+
+ ), + footer: ( +
+ + + +
+ ), + }); + handle.destroy = opened.destroy; +} diff --git a/dashboard/src/pages/Chat/utils/dockFilePath.test.ts b/dashboard/src/pages/Chat/utils/dockFilePath.test.ts index f701f024d0..51b1ac3829 100644 --- a/dashboard/src/pages/Chat/utils/dockFilePath.test.ts +++ b/dashboard/src/pages/Chat/utils/dockFilePath.test.ts @@ -128,7 +128,7 @@ describe("dockFilePath", () => { expect(dockFileTabId("file:///workspace/x.py")).toBe("file:x.py"); expect( dockFileTabId("/home/wally/.octop/agents/main/generated/a.pptx", "main"), - ).toBe("file:generated/a.pptx"); + ).toBe("file:main:generated/a.pptx"); }); it("folder tree uses absolute paths from artifacts", () => { @@ -173,7 +173,27 @@ describe("dockFilePath", () => { ["/home/wally/.octop/agents/main/generated/a.pptx", "generated/a.pptx"], "main", ), - ).toEqual(["/home/wally/.octop/agents/main/generated/a.pptx"]); + ).toEqual([ + { + path: "/home/wally/.octop/agents/main/generated/a.pptx", + agentId: "main", + }, + ]); + }); + + it("keeps same relative path from different agents", () => { + expect( + listDockFilePathsForTree( + [ + { path: "outbound/a.md", agentId: "member-a" }, + { path: "outbound/a.md", agentId: "member-b" }, + ], + "host", + ), + ).toEqual([ + { path: "outbound/a.md", agentId: "member-a" }, + { path: "outbound/a.md", agentId: "member-b" }, + ]); }); it("maps download APIs; host abs stays file:// for virtual root failback", () => { diff --git a/dashboard/src/pages/Chat/utils/dockFilePath.ts b/dashboard/src/pages/Chat/utils/dockFilePath.ts index 51a7dc716b..85b6984704 100644 --- a/dashboard/src/pages/Chat/utils/dockFilePath.ts +++ b/dashboard/src/pages/Chat/utils/dockFilePath.ts @@ -101,11 +101,18 @@ export function dockFileBasename(path: string): string { return parts[parts.length - 1] || path; } -/** Stable tab id for an open file path. */ +/** Stable tab id for an open file path (scoped by producer agent when set). */ export function dockFileTabId(path: string, agentId?: string | null): string { - return `file:${canonicalizeDockFilePath(path, agentId)}`; + const key = canonicalizeDockFilePath(path, agentId); + const aid = (agentId || "").trim(); + return aid ? `file:${aid}:${key}` : `file:${key}`; } +export type DockFileRef = { + path: string; + agentId?: string; +}; + /** Prefer a richer on-disk path for tree display after canonical dedupe. */ function preferDisplayPath(current: string, candidate: string): string { const a = current.replace(/\\/g, "/"); @@ -127,34 +134,60 @@ function preferDisplayPath(current: string, candidate: string): string { /** * Deduplicate artifact paths for the dock file list, preferring absolute paths. + * Entries are keyed by ``agentId + path`` so team rooms can list the same + * relative path from multiple members. */ export function listDockFilePathsForTree( - paths: string[], + paths: Array, agentId?: string | null, -): string[] { - return dedupeDockFilePaths(paths, agentId); +): DockFileRef[] { + return dedupeDockFileRefs(paths, agentId); } -/** Deduplicate by canonical workspace path; keep the richest display path. */ +/** @deprecated Prefer ``listDockFilePathsForTree`` (returns refs). */ export function dedupeDockFilePaths( paths: string[], agentId?: string | null, ): string[] { - const bestByKey = new Map(); + return dedupeDockFileRefs(paths, agentId).map((ref) => ref.path); +} + +/** Deduplicate by producer agent + canonical workspace path. */ +export function dedupeDockFileRefs( + paths: Array, + defaultAgentId?: string | null, +): DockFileRef[] { + const bestByKey = new Map(); const order: string[] = []; + const fallback = (defaultAgentId || "").trim(); for (const raw of paths) { - const key = canonicalizeDockFilePath(raw, agentId); - if (!key) continue; - const display = normalizeDockFilePath(raw) || key; + const pathRaw = typeof raw === "string" ? raw : raw.path; + const aid = + (typeof raw === "string" ? fallback : raw.agentId?.trim() || fallback) || + ""; + const keyPath = canonicalizeDockFilePath(pathRaw, aid || defaultAgentId); + if (!keyPath) continue; + const display = normalizeDockFilePath(pathRaw) || keyPath; + const key = `${aid}\0${keyPath}`; const prev = bestByKey.get(key); if (!prev) { - bestByKey.set(key, preferDisplayPath(key, display)); + bestByKey.set(key, { + path: preferDisplayPath(keyPath, display), + ...(aid ? { agentId: aid } : {}), + }); order.push(key); continue; } - bestByKey.set(key, preferDisplayPath(prev, display)); + bestByKey.set(key, { + path: preferDisplayPath(prev.path, display), + ...(aid + ? { agentId: aid } + : prev.agentId + ? { agentId: prev.agentId } + : {}), + }); } - return order.map((key) => bestByKey.get(key) || key); + return order.map((key) => bestByKey.get(key)!); } export type DockPathTreeNode = { @@ -163,6 +196,8 @@ export type DockPathTreeNode = { /** Full path for files; directory prefix for folders. */ path: string; isDir: boolean; + /** Producer agent for file leaves (team room multi-member artifacts). */ + agentId?: string; children: DockPathTreeNode[]; }; @@ -171,13 +206,14 @@ export type DockPathTreeNode = { * chains into ``a / b / c`` labels (PR “Files changed” style). */ export function buildDockPathTree( - paths: string[], + paths: Array, agentId?: string | null, ): DockPathTreeNode[] { type Trie = { name: string; path: string; isDir: boolean; + agentId?: string; children: Map; }; @@ -188,7 +224,9 @@ export function buildDockPathTree( children: new Map(), }; - for (const raw of listDockFilePathsForTree(paths, agentId)) { + for (const ref of listDockFilePathsForTree(paths, agentId)) { + const raw = ref.path; + const fileAgent = (ref.agentId || agentId || "").trim(); const parts = raw.replace(/\\/g, "/").split("/").filter(Boolean); if (parts.length === 0) continue; // Preserve leading slash for absolute paths in the root segment join. @@ -200,15 +238,17 @@ export function buildDockPathTree( const isLast = i === parts.length - 1; acc = acc === "" && abs ? `/${part}` : acc === "" ? part : `${acc}/${part}`; - let child = node.children.get(part); + const childKey = isLast && fileAgent ? `${part}\0${fileAgent}` : part; + let child = node.children.get(childKey); if (!child) { child = { name: part, path: acc, isDir: !isLast, + ...(isLast && fileAgent ? { agentId: fileAgent } : {}), children: new Map(), }; - node.children.set(part, child); + node.children.set(childKey, child); } else if (!isLast) { child.isDir = true; } @@ -235,6 +275,7 @@ export function buildDockPathTree( name: names.filter(Boolean).join(" / "), path: cur.path, isDir: cur.isDir || children.length > 0, + ...(cur.agentId ? { agentId: cur.agentId } : {}), children, }; } diff --git a/dashboard/src/pages/Chat/utils/droppedFiles.test.ts b/dashboard/src/pages/Chat/utils/droppedFiles.test.ts new file mode 100644 index 0000000000..a074525142 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/droppedFiles.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + dragHasFiles, + filesFromSnapshot, + MAX_CHAT_UPLOAD_FILES, + snapshotDroppedEntries, + type DropSnapshot, +} from "./droppedFiles"; + +function fileEntry(file: File, fullPath = `/${file.name}`): FileSystemEntry { + return { + isFile: true, + isDirectory: false, + name: file.name, + fullPath, + file(success: (next: File) => void) { + success(file); + }, + } as unknown as FileSystemFileEntry; +} + +function directoryEntry( + name: string, + children: FileSystemEntry[], + fullPath = `/${name}`, +): FileSystemEntry { + return { + isFile: false, + isDirectory: true, + name, + fullPath, + createReader() { + let pending = children; + return { + readEntries(success: (entries: FileSystemEntry[]) => void) { + const batch = pending; + pending = []; + success(batch); + }, + }; + }, + } as unknown as FileSystemDirectoryEntry; +} + +function dropSource( + items: DataTransferItem[], + files: File[] = [], +): DataTransfer { + return { items, files } as unknown as DataTransfer; +} + +describe("snapshotDroppedEntries", () => { + it("keeps several loose files from one drop", () => { + const first = new File(["a"], "a.txt", { type: "text/plain" }); + const second = new File(["b"], "b.md", { type: "text/markdown" }); + const snapshot = snapshotDroppedEntries( + dropSource([ + { + kind: "file", + getAsFile: () => first, + } as DataTransferItem, + { + kind: "file", + getAsFile: () => second, + } as DataTransferItem, + ]), + ); + + expect(snapshot.hadFileItems).toBe(true); + expect(snapshot.entries).toEqual([]); + expect(snapshot.files.map((file) => file.name)).toEqual(["a.txt", "b.md"]); + }); + + it("walks a dropped folder instead of the empty directory placeholder", async () => { + const readme = new File(["# hi"], "README.md", { type: "text/markdown" }); + const note = new File(["note"], "note.txt", { type: "text/plain" }); + const folder = directoryEntry("docs", [ + fileEntry(readme, "/docs/README.md"), + directoryEntry( + "nested", + [fileEntry(note, "/docs/nested/note.txt")], + "/docs/nested", + ), + ]); + const snapshot = snapshotDroppedEntries( + dropSource( + [ + { + kind: "file", + webkitGetAsEntry: () => folder, + getAsFile: () => new File([], "docs"), + } as unknown as DataTransferItem, + ], + [new File([], "docs")], + ), + ); + + expect(snapshot.files).toEqual([]); + const collected = await filesFromSnapshot(snapshot); + expect(collected.truncated).toBe(false); + expect(collected.files.map((file) => file.name)).toEqual([ + "README.md", + "note.txt", + ]); + }); + + it("reports an empty folder as a file drop with no files", async () => { + const snapshot = snapshotDroppedEntries( + dropSource([ + { + kind: "file", + webkitGetAsEntry: () => directoryEntry("empty", []), + getAsFile: () => null, + } as unknown as DataTransferItem, + ]), + ); + + expect(snapshot.hadFileItems).toBe(true); + await expect(filesFromSnapshot(snapshot)).resolves.toEqual({ + files: [], + truncated: false, + }); + }); + + it("keeps the first 500 files and reports the rest", async () => { + const entries = Array.from({ length: MAX_CHAT_UPLOAD_FILES + 1 }, (_, i) => + fileEntry(new File(["x"], `f${i}.txt`)), + ); + const collected = await filesFromSnapshot({ + entries, + files: [], + hadFileItems: true, + }); + expect(collected.files).toHaveLength(MAX_CHAT_UPLOAD_FILES); + expect(collected.truncated).toBe(true); + expect(collected.files[0]?.name).toBe("f0.txt"); + }); + + it("ignores non-file drags", () => { + const snapshot = snapshotDroppedEntries( + dropSource([ + { kind: "string", getAsFile: () => null } as DataTransferItem, + ]), + ); + expect(snapshot).toEqual({ + entries: [], + files: [], + hadFileItems: false, + }); + }); +}); + +describe("dragHasFiles", () => { + it("is true only when the drag carries files", () => { + expect(dragHasFiles({ types: ["Files"] } as unknown as DataTransfer)).toBe( + true, + ); + expect( + dragHasFiles({ types: ["text/plain"] } as unknown as DataTransfer), + ).toBe(false); + expect(dragHasFiles(null)).toBe(false); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/droppedFiles.ts b/dashboard/src/pages/Chat/utils/droppedFiles.ts new file mode 100644 index 0000000000..54a78e23ea --- /dev/null +++ b/dashboard/src/pages/Chat/utils/droppedFiles.ts @@ -0,0 +1,133 @@ +/** Matches the desktop shell cap in desktop/src/filedrop.go. */ +export const MAX_CHAT_UPLOAD_FILES = 500; + +export interface DropSnapshot { + entries: FileSystemEntry[]; + files: File[]; + /** True when the drop contained at least one file or folder item. */ + hadFileItems: boolean; +} + +export interface CollectedDrop { + files: File[]; + truncated: boolean; +} + +function readEntryFile(entry: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); +} + +function readAllDirectoryEntries( + reader: FileSystemDirectoryReader, +): Promise { + return new Promise((resolve, reject) => { + const all: FileSystemEntry[] = []; + const readBatch = () => { + reader.readEntries((batch) => { + if (batch.length === 0) { + resolve(all); + return; + } + all.push(...batch); + readBatch(); + }, reject); + }; + readBatch(); + }); +} + +/** Returns true when another entry exists past the cap. */ +async function collectEntryFiles( + entry: FileSystemEntry, + out: File[], + max: number, +): Promise { + if (out.length >= max) return true; + if (entry.isFile) { + out.push(await readEntryFile(entry as FileSystemFileEntry)); + return false; + } + if (!entry.isDirectory) return false; + const reader = (entry as FileSystemDirectoryEntry).createReader(); + const children = await readAllDirectoryEntries(reader); + for (const child of children) { + if (await collectEntryFiles(child, out, max)) return true; + } + return false; +} + +export function dragHasFiles(dataTransfer: DataTransfer | null): boolean { + if (!dataTransfer?.types) return false; + return Array.from(dataTransfer.types).includes("Files"); +} + +/** + * Capture drop payload synchronously. Directory entries stay readable after + * the drop event ends; ``File`` objects from ``getAsFile`` must be taken now. + */ +export function snapshotDroppedEntries( + dataTransfer: DataTransfer | null, +): DropSnapshot { + if (!dataTransfer) { + return { entries: [], files: [], hadFileItems: false }; + } + + const entries: FileSystemEntry[] = []; + const loose: File[] = []; + const items = dataTransfer.items; + let sawFileItem = false; + + if (items && items.length > 0) { + for (let i = 0; i < items.length; i += 1) { + const item = items[i]; + if (!item || item.kind !== "file") continue; + sawFileItem = true; + const entry = + typeof item.webkitGetAsEntry === "function" + ? item.webkitGetAsEntry() + : null; + if (entry) { + entries.push(entry); + continue; + } + const file = item.getAsFile(); + if (file) loose.push(file); + } + } + + if (sawFileItem) { + return { entries, files: loose, hadFileItems: true }; + } + + const fallback = Array.from(dataTransfer.files ?? []); + return { + entries: [], + files: fallback, + hadFileItems: fallback.length > 0, + }; +} + +export async function filesFromSnapshot( + snapshot: DropSnapshot, +): Promise { + const out: File[] = []; + let truncated = false; + for (const entry of snapshot.entries) { + if (await collectEntryFiles(entry, out, MAX_CHAT_UPLOAD_FILES)) { + truncated = true; + break; + } + } + if (!truncated) { + const room = MAX_CHAT_UPLOAD_FILES - out.length; + if (snapshot.files.length > room) { + out.push(...snapshot.files.slice(0, room)); + truncated = true; + } else { + out.push(...snapshot.files); + } + } + return { files: out, truncated }; +} diff --git a/dashboard/src/pages/Chat/utils/expertMention.ts b/dashboard/src/pages/Chat/utils/expertMention.ts index ca2d78bca6..09b6206e8c 100644 --- a/dashboard/src/pages/Chat/utils/expertMention.ts +++ b/dashboard/src/pages/Chat/utils/expertMention.ts @@ -1,7 +1,7 @@ /** * Composer helpers for expert / subagent @mentions. * - * harness-agent no longer intercepts ``target_agent_ids``; the host must put + * octop-harness no longer intercepts ``target_agent_ids``; the host must put * ``@Name`` in the user message so the model can call ``ask_agent`` (teammate) * or ``task`` (workspace subagent). */ diff --git a/dashboard/src/pages/Chat/utils/liveSpeakerLabel.test.ts b/dashboard/src/pages/Chat/utils/liveSpeakerLabel.test.ts new file mode 100644 index 0000000000..618df2e724 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/liveSpeakerLabel.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + hasInFlightTool, + hasStreamingThinking, + resolveLiveSpeakerNames, +} from "./liveSpeakerLabel"; + +describe("resolveLiveSpeakerNames", () => { + const agents = [ + { agent_id: "host", name: "主持" }, + { agent_id: "doctor", name: "临床助手" }, + { agent_id: "nurse", name: "护理助手" }, + ]; + + it("maps member ids to display names", () => { + expect( + resolveLiveSpeakerNames(["doctor", "nurse"], agents, { + hostId: "host", + hostName: "主持", + }), + ).toEqual(["临床助手", "护理助手"]); + }); + + it("treats empty and host id as the host", () => { + expect( + resolveLiveSpeakerNames(["", "host"], agents, { + hostId: "host", + hostName: "团队主持", + }), + ).toEqual(["团队主持"]); + }); + + it("falls back to the raw id when the agent is unknown", () => { + expect(resolveLiveSpeakerNames(["mystery"], agents)).toEqual(["mystery"]); + }); + + it("caps the list", () => { + expect( + resolveLiveSpeakerNames(["doctor", "nurse", "mystery"], agents, { + limit: 2, + }), + ).toEqual(["临床助手", "护理助手"]); + }); +}); + +describe("hasInFlightTool", () => { + it("detects a streaming tool without output", () => { + expect( + hasInFlightTool([ + { status: "streaming", toolData: { name: "read_file" } }, + ]), + ).toBe(true); + }); + + it("ignores completed tools", () => { + expect( + hasInFlightTool([ + { + status: "done", + toolData: { name: "read_file", output: "ok" }, + }, + ]), + ).toBe(false); + }); + + it("ignores a paused ask_user_question tool", () => { + expect( + hasInFlightTool([ + { + status: "streaming", + toolData: { name: "ask_user_question" }, + }, + ]), + ).toBe(false); + }); +}); + +describe("hasStreamingThinking", () => { + it("detects streaming reasoning bubbles", () => { + expect( + hasStreamingThinking([{ status: "streaming", type: "reasoning" }]), + ).toBe(true); + }); + + it("detects thinking content blocks", () => { + expect( + hasStreamingThinking([ + { + status: "streaming", + contentBlocks: [{ type: "thinking" }], + }, + ]), + ).toBe(true); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/liveSpeakerLabel.ts b/dashboard/src/pages/Chat/utils/liveSpeakerLabel.ts new file mode 100644 index 0000000000..aae28b0915 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/liveSpeakerLabel.ts @@ -0,0 +1,76 @@ +import { ASK_USER_TOOL_NAME } from "../../../api/types/hitl"; + +/** Resolve display names for speakers still generating in a team room. */ + +export interface NamedAgent { + agent_id: string; + name?: string | null; +} + +/** + * Pick up to ``limit`` readable names for ``liveSpeakers`` keys + * (``""`` / host id → hostName). + */ +export function resolveLiveSpeakerNames( + liveSpeakers: ReadonlyArray, + agents: ReadonlyArray, + opts?: { + hostId?: string | null; + hostName?: string | null; + limit?: number; + }, +): string[] { + const hostId = (opts?.hostId || "").trim(); + const hostName = (opts?.hostName || "").trim(); + const limit = opts?.limit ?? 2; + const names: string[] = []; + const seen = new Set(); + + for (const raw of liveSpeakers) { + const key = (raw || "").trim(); + const isHost = !key || (hostId && key === hostId); + const name = isHost + ? hostName || + agents.find((a) => a.agent_id === hostId)?.name?.trim() || + "" + : agents.find((a) => a.agent_id === key)?.name?.trim() || key; + const label = name.trim(); + if (!label || seen.has(label)) continue; + seen.add(label); + names.push(label); + if (names.length >= limit) break; + } + return names; +} + +/** True when any assistant tool bubble is still waiting on a result. */ +export function hasInFlightTool( + messages: ReadonlyArray<{ + status?: string; + toolData?: { name?: string; output?: unknown } | null; + }>, +): boolean { + return messages.some( + (m) => + Boolean(m.toolData) && + m.status === "streaming" && + m.toolData?.output === undefined && + m.toolData?.name !== ASK_USER_TOOL_NAME, + ); +} + +/** True when a thinking/reasoning bubble is still streaming. */ +export function hasStreamingThinking( + messages: ReadonlyArray<{ + status?: string; + type?: string; + toolData?: unknown; + contentBlocks?: ReadonlyArray<{ type: string }> | null; + }>, +): boolean { + return messages.some((m) => { + if (m.status !== "streaming" || m.toolData) return false; + if (m.type === "reasoning") return true; + return Boolean(m.contentBlocks?.some((block) => block.type === "thinking")); + }); +} diff --git a/dashboard/src/pages/Chat/utils/messageContent.ts b/dashboard/src/pages/Chat/utils/messageContent.ts index c01edc9de7..6e44286c4b 100644 --- a/dashboard/src/pages/Chat/utils/messageContent.ts +++ b/dashboard/src/pages/Chat/utils/messageContent.ts @@ -1,14 +1,24 @@ import type { ChatMessage } from "../hooks/useChat"; import { isBrowserToolName, isWriteToolName } from "../constants"; -const THINKING_TAG_RE = /[\s\S]*?<\/think>/g; +const THINKING_TAG_RE = /<(?:think|thinking)>[\s\S]*?<\/(?:think|thinking)>/gi; +const THINKING_OPEN_RE = /<(?:think|thinking)>/i; +const THINKING_CLOSE_RE = /<\/(?:think|thinking)>/i; -/** Strip `` blocks from visible assistant text. */ +/** Strip `` / `` blocks from visible assistant text. */ export function stripThinkTags(raw: string): string { let result = raw.replace(THINKING_TAG_RE, ""); - const unclosedIdx = result.indexOf(""); - if (unclosedIdx >= 0) { - result = result.slice(0, unclosedIdx); + // Orphan closing tag: drop the hidden prefix before it. + while (true) { + const close = result.search(THINKING_CLOSE_RE); + if (close < 0) break; + const open = result.search(THINKING_OPEN_RE); + if (open >= 0 && open < close) break; + result = result.slice(close).replace(THINKING_CLOSE_RE, ""); + } + const unclosed = result.search(THINKING_OPEN_RE); + if (unclosed >= 0) { + result = result.slice(0, unclosed); } return result.trim(); } @@ -105,6 +115,7 @@ function hasCompletedToolBetweenTexts(messages: ChatMessage[]): boolean { export function splitAssistantTurn( messages: ChatMessage[], + opts?: { joinAnswerFragments?: boolean }, ): AssistantTurnSplit { const tools: ChatMessage[] = []; const thinkings: ThinkingProcessItem[] = []; @@ -137,11 +148,17 @@ export function splitAssistantTurn( } } - // 1:1 ReAct (text → tool → conclusion) keeps the last bubble as the answer. - // Join only team fragments of the same speaker that were split across bubbles. + // 1:1 ReAct (text → tool → conclusion) keeps the last bubble as the answer + // once the turn settles. While generating (or for stamped team speakers), + // join fragments so the answer never shrinks mid-flight after a tool round. + const teamStamped = messages.some((item) => + Boolean((item.speakerAgentId || "").trim()), + ); const joinFragments = textParts.length > 1 && - (messages.some((item) => item.teamWrapup) || + (Boolean(opts?.joinAnswerFragments) || + teamStamped || + messages.some((item) => item.teamWrapup) || !hasCompletedToolBetweenTexts(messages)); const answerMessage = answerTemplate && textParts.length > 0 @@ -197,7 +214,10 @@ function messageUsesFileTool(msg: ChatMessage): boolean { /** True when this assistant turn invoked a workspace file write/edit tool. */ export function turnUsedFileTool(split: AssistantTurnSplit): boolean { - return (split?.tools ?? []).some((msg) => messageUsesFileTool(msg)); + if ((split?.tools ?? []).some((msg) => messageUsesFileTool(msg))) { + return true; + } + return (split?.answerMessage?.editedFiles?.length ?? 0) > 0; } /** Index of the most recent assistant turn that invoked a browser tool, or -1. */ diff --git a/dashboard/src/pages/Chat/utils/messageGrouping.test.ts b/dashboard/src/pages/Chat/utils/messageGrouping.test.ts index 18cf12eb2f..0be3fd143f 100644 --- a/dashboard/src/pages/Chat/utils/messageGrouping.test.ts +++ b/dashboard/src/pages/Chat/utils/messageGrouping.test.ts @@ -194,6 +194,53 @@ describe("groupConsecutiveAssistantMessages", () => { ).toBe(0); }); + it("keeps a team member answer on one bubble across completed tools", () => { + const messages = [ + msg("assistant", "member", { + content: "我先查一下", + speakerAgentId: "doctor", + status: "done", + }), + msg("assistant", "tool", { + toolData: { + name: "read_file", + arguments: "{}", + output: "labs ok", + }, + speakerAgentId: "doctor", + }), + ]; + expect( + findSpeakerTextToContinue(messages, "doctor", "host", { + teamRoom: true, + continueThroughTools: ["ask_agent"], + }), + ).toBe(0); + // Host still treats completed tools as a break. + expect( + findSpeakerTextToContinue( + [ + msg("assistant", "host", { + content: "稍等", + speakerAgentId: "host", + status: "done", + }), + msg("assistant", "tool", { + toolData: { + name: "ask_agent", + arguments: "{}", + output: "queued", + }, + speakerAgentId: "host", + }), + ], + "host", + "host", + { teamRoom: true, continueThroughTools: ["ask_agent"] }, + ), + ).toBe(-1); + }); + it("treats a completed ask_agent as a turn break", () => { const messages = [ msg("assistant", "host", { content: "I will ask" }), @@ -270,6 +317,24 @@ describe("groupConsecutiveAssistantMessages", () => { expect(split.answerMessage?.content).toBe("please rest"); }); + it("keeps member narration across tool rounds in the answer", () => { + const split = splitAssistantTurn([ + msg("assistant", "m1", { + content: "我先查一下资料", + speakerAgentId: "doctor", + }), + msg("assistant", "tool", { + speakerAgentId: "doctor", + toolData: { name: "read_file", arguments: "{}", output: "ok" }, + }), + msg("assistant", "m2", { + content: "结论如下", + speakerAgentId: "doctor", + }), + ]); + expect(split.answerMessage?.content).toBe("我先查一下资料 结论如下"); + }); + it("keeps a 1:1 ReAct conclusion as the answer", () => { const split = splitAssistantTurn([ msg("assistant", "host1", { content: "I will ask" }), diff --git a/dashboard/src/pages/Chat/utils/messageGrouping.ts b/dashboard/src/pages/Chat/utils/messageGrouping.ts index 3f6c92986e..53c95da485 100644 --- a/dashboard/src/pages/Chat/utils/messageGrouping.ts +++ b/dashboard/src/pages/Chat/utils/messageGrouping.ts @@ -71,6 +71,9 @@ export function sameGroupedSpeaker( * ``continueThroughTools`` only skips *in-flight* tools (no output yet). A * completed ``ask_agent`` is a turn break so the host wrap-up after members * reply opens a new bubble instead of appending to the dispatch text. + * + * Team **members** keep one answer bubble across their own completed tools + * (normal ReAct). Host still treats completed tools as a break. */ export function findSpeakerTextToContinue( messages: ChatMessage[], @@ -80,6 +83,10 @@ export function findSpeakerTextToContinue( ): number { const skipTools = new Set(options?.continueThroughTools ?? []); const teamRoom = Boolean(options?.teamRoom); + const memberContinuesThroughTools = + teamRoom && + Boolean((speakerId || "").trim()) && + !isRoomHostSpeaker(speakerId, hostAgentId); let lastTool = -1; let lastText = -1; for (let i = messages.length - 1; i >= 0; i--) { @@ -99,7 +106,7 @@ export function findSpeakerTextToContinue( if (message.toolData) { const name = message.toolData.name || ""; const inFlight = skipTools.has(name) && !message.toolData.output; - if (inFlight) continue; + if (inFlight || memberContinuesThroughTools) continue; if (lastTool < 0) lastTool = i; continue; } diff --git a/dashboard/src/pages/Chat/utils/pendingHitl.test.ts b/dashboard/src/pages/Chat/utils/pendingHitl.test.ts index 342d46f365..3912a13944 100644 --- a/dashboard/src/pages/Chat/utils/pendingHitl.test.ts +++ b/dashboard/src/pages/Chat/utils/pendingHitl.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import type { ChatMessage } from "../hooks/sseHelpers"; import { + findAutoResumableApproval, findPendingApproval, findPendingAsk, hasPendingHitl, + promoteAskUserToolMessage, } from "./pendingHitl"; function msg( @@ -79,6 +81,7 @@ describe("pendingHitl", () => { }, ], status: "pending", + pending_id: "ab12", }, }), ]); @@ -122,6 +125,53 @@ describe("pendingHitl", () => { expect(pending?.actions[0]?.name).toBe("execute"); }); + it("auto-resumes tool approvals under allow-all but not questions", () => { + const messages = [ + msg({ + id: "tool", + role: "assistant", + hitlData: { + action_requests: [{ name: "execute", args: { command: "ls" } }], + status: "pending", + }, + }), + ]; + expect( + findAutoResumableApproval({ mode: "allow_all" }, messages)?.messageId, + ).toBe("tool"); + expect( + findAutoResumableApproval( + { mode: "allow_tools", tools: ["execute"] }, + messages, + )?.messageId, + ).toBe("tool"); + expect( + findAutoResumableApproval( + { mode: "allow_tools", tools: ["write_file"] }, + messages, + ), + ).toBeNull(); + expect(findAutoResumableApproval({ mode: "ask" }, messages)).toBeNull(); + expect( + findAutoResumableApproval({ mode: "allow_all" }, [ + msg({ + id: "ask", + role: "assistant", + hitlData: { + action_requests: [ + { + name: "ask_user_question", + args: { questions: [{ question: "Which?" }] }, + }, + ], + status: "pending", + pending_id: "ab12", + }, + }), + ]), + ).toBeNull(); + }); + it("ignores ask pauses without parseable questions", () => { expect( findPendingAsk([ @@ -136,4 +186,22 @@ describe("pendingHitl", () => { ]), ).toBeNull(); }); + + it("ignores reconstructed asks that the server cannot resume", () => { + const reconstructed = promoteAskUserToolMessage( + msg({ + id: "tool", + role: "assistant", + toolData: { + name: "ask_user_question", + arguments: JSON.stringify({ + questions: [{ question: "Which DB?", options: [{ label: "PG" }] }], + }), + }, + }), + ); + expect(reconstructed.hitlData?.status).toBe("pending"); + expect(findPendingAsk([reconstructed])).toBeNull(); + expect(hasPendingHitl([reconstructed])).toBe(false); + }); }); diff --git a/dashboard/src/pages/Chat/utils/pendingHitl.ts b/dashboard/src/pages/Chat/utils/pendingHitl.ts index 9417f5414e..2d25042361 100644 --- a/dashboard/src/pages/Chat/utils/pendingHitl.ts +++ b/dashboard/src/pages/Chat/utils/pendingHitl.ts @@ -1,9 +1,12 @@ import { + ASK_USER_TOOL_NAME, extractAskQuestions, isAskHitl, + questionsFromUnknown, type AskQuestion, } from "../../../api/types/hitl"; import type { ChatMessage, HitlActionRequest } from "../hooks/sseHelpers"; +import type { HitlSessionPolicy } from "./hitlSessionPolicy"; export type PendingAsk = { messageId: string; @@ -11,9 +14,29 @@ export type PendingAsk = { questions: AskQuestion[]; }; -/** True when any message still awaits a HITL decision. */ +/** True when a pending HITL can actually be resumed on the server. */ +export function isResumableHitl( + hitl: ChatMessage["hitlData"] | undefined, +): boolean { + return Boolean(hitl?.pending_id?.trim()); +} + +/** Unanswered ``ask_user_question`` is a pause, not a running tool. */ +export function isPausedAskTool(message: ChatMessage): boolean { + return ( + message.toolData?.name === ASK_USER_TOOL_NAME && + message.toolData.output === undefined + ); +} + +/** True when any message still awaits a recoverable HITL decision. */ export function hasPendingHitl(messages: ChatMessage[]): boolean { - return messages.some((message) => message.hitlData?.status === "pending"); + return messages.some((message) => { + const hitl = message.hitlData; + if (!hitl || (hitl.status ?? "pending") !== "pending") return false; + if (isAskHitl(hitl.action_requests) && !isResumableHitl(hitl)) return false; + return true; + }); } export type PendingApproval = { @@ -44,6 +67,49 @@ export function findPendingApproval( return null; } +/** Pending tool approval that a session bypass can resume without asking. */ +export function findAutoResumableApproval( + policy: HitlSessionPolicy, + messages: ChatMessage[], +): PendingApproval | null { + const pending = findPendingApproval(messages); + if (!pending) return null; + if (policy.mode === "allow_all") return pending; + if (policy.mode === "allow_tools") { + const allowed = new Set(policy.tools ?? []); + if ( + pending.actions.length > 0 && + pending.actions.every((action) => allowed.has(action.name)) + ) { + return pending; + } + } + return null; +} + +function askActionsFromTool(message: ChatMessage): HitlActionRequest[] | null { + if (message.toolData?.name !== ASK_USER_TOOL_NAME) return null; + if (message.toolData.output !== undefined) return null; + const questions = questionsFromUnknown(message.toolData.arguments); + if (questions.length === 0) return null; + return [{ name: ASK_USER_TOOL_NAME, args: { questions } }]; +} + +/** Turn an unanswered ``ask_user_question`` tool bubble into a pending HITL card. */ +export function promoteAskUserToolMessage(message: ChatMessage): ChatMessage { + if (message.hitlData) return message; + const actions = askActionsFromTool(message); + if (!actions) return message; + return { + ...message, + hitlData: { + action_requests: actions, + status: "pending", + }, + status: "done", + }; +} + /** Latest pending ``ask_user_question`` card, if any. */ export function findPendingAsk(messages: ChatMessage[]): PendingAsk | null { for (let index = messages.length - 1; index >= 0; index -= 1) { @@ -52,7 +118,8 @@ export function findPendingAsk(messages: ChatMessage[]): PendingAsk | null { if ( !hitl || (hitl.status ?? "pending") !== "pending" || - !isAskHitl(hitl.action_requests) + !isAskHitl(hitl.action_requests) || + !isResumableHitl(hitl) ) { continue; } diff --git a/dashboard/src/pages/Chat/utils/sessionWorkStatus.test.ts b/dashboard/src/pages/Chat/utils/sessionWorkStatus.test.ts new file mode 100644 index 0000000000..f02bd6edfb --- /dev/null +++ b/dashboard/src/pages/Chat/utils/sessionWorkStatus.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { resolveSessionWorkStatus } from "./sessionWorkStatus"; + +describe("resolveSessionWorkStatus", () => { + it("marks a live or server-active turn as working", () => { + expect(resolveSessionWorkStatus({ id: "thr_1" }, new Set(["thr_1"]))).toBe( + "working", + ); + expect( + resolveSessionWorkStatus({ id: "thr_1", turnActive: true }, new Set()), + ).toBe("working"); + }); + + it("prefers working over waiting", () => { + expect( + resolveSessionWorkStatus( + { id: "thr_1", turnActive: true, awaitingUser: true }, + new Set(), + ), + ).toBe("working"); + }); + + it("marks a HITL pause as waiting", () => { + expect( + resolveSessionWorkStatus({ id: "thr_1", awaitingUser: true }, new Set()), + ).toBe("waiting"); + }); + + it("treats an idle finished thread as idle (no completed badge)", () => { + expect(resolveSessionWorkStatus({ id: "thr_1" }, new Set())).toBe("idle"); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/sessionWorkStatus.ts b/dashboard/src/pages/Chat/utils/sessionWorkStatus.ts new file mode 100644 index 0000000000..c9caa242cf --- /dev/null +++ b/dashboard/src/pages/Chat/utils/sessionWorkStatus.ts @@ -0,0 +1,11 @@ +/** Sidebar thread work status. Idle (done) is the default and has no badge. */ +export type SessionWorkStatus = "working" | "waiting" | "idle"; + +export function resolveSessionWorkStatus( + session: { id: string; turnActive?: boolean; awaitingUser?: boolean }, + liveWorkingIds: ReadonlySet, +): SessionWorkStatus { + if (liveWorkingIds.has(session.id) || session.turnActive) return "working"; + if (session.awaitingUser) return "waiting"; + return "idle"; +} diff --git a/dashboard/src/pages/Control/CronJobs/components/CronJobCard.tsx b/dashboard/src/pages/Control/CronJobs/components/CronJobCard.tsx index 4628613e18..6cfb10fd14 100644 --- a/dashboard/src/pages/Control/CronJobs/components/CronJobCard.tsx +++ b/dashboard/src/pages/Control/CronJobs/components/CronJobCard.tsx @@ -150,7 +150,9 @@ export function CronJobCard({ - {job.schedule?.cron || "—"} + {job.schedule?.cron?.startsWith("agently:") + ? t("cronJobs.form.agentlyNewMail") + : job.schedule?.cron || "—"} {lastRunAt && ( diff --git a/dashboard/src/pages/Control/CronJobs/components/JobDetailDrawer.tsx b/dashboard/src/pages/Control/CronJobs/components/JobDetailDrawer.tsx index 737f79f49c..28ed1576e9 100644 --- a/dashboard/src/pages/Control/CronJobs/components/JobDetailDrawer.tsx +++ b/dashboard/src/pages/Control/CronJobs/components/JobDetailDrawer.tsx @@ -138,7 +138,9 @@ export function JobDetailDrawer({ > - {job.schedule?.cron || "—"} + {job.schedule?.cron?.startsWith("agently:") + ? t("cronJobs.form.agentlyNewMail") + : job.schedule?.cron || "—"} diff --git a/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx b/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx index 3918e4dd09..fdc487912a 100644 --- a/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx +++ b/dashboard/src/pages/Control/CronJobs/components/JobDrawer.tsx @@ -8,12 +8,14 @@ import { Button, Divider, Typography, + Alert, } from "antd"; import { useTranslation } from "react-i18next"; import type { FormInstance } from "antd"; import { providerApi } from "../../../../api/modules/provider"; import { connectorsApi } from "../../../../api/modules/connectors"; import { octopThreadsApi } from "../../../../api/modules/octopThreads"; +import { isBridgeAgentId } from "../../../../utils/remoteExpert"; import { buildDefaultFormValues, SCHEDULE_PRESETS, @@ -29,6 +31,7 @@ import { MODEL_AUTO_VALUE, type ModelPickerOption, } from "../../../../utils/modelOptions"; +import styles from "../index.module.less"; const { Text } = Typography; @@ -85,6 +88,7 @@ export function JobDrawer({ onSubmit, }: JobDrawerProps) { const { t } = useTranslation(); + const remote = isBridgeAgentId(activeAgentId); const scheduleMode = Form.useWatch("_scheduleMode", form); const freshThread = Form.useWatch("fresh_thread", form); @@ -98,6 +102,9 @@ export function JobDrawer({ Array<{ label: string; value: string }> >([]); const [connectorsLoading, setConnectorsLoading] = useState(false); + const [mailOptions, setMailOptions] = useState< + Array<{ label: string; value: string }> + >([]); const presetOptions = useMemo( () => @@ -115,7 +122,12 @@ export function JobDrawer({ form.setFieldsValue({ ...editingJob, model: defaultModelToForm(editingJob.model), - _scheduleMode: matchedPreset ? "preset" : "custom", + _scheduleMode: + editingJob._scheduleMode === "agently" + ? "agently" + : matchedPreset + ? "preset" + : "custom", _preset: matchedPreset || editingJob._preset || "daily_9am", }); } else { @@ -128,11 +140,11 @@ export function JobDrawer({ if (!open) return; setModelsLoading(true); void providerApi - .listResolvedModels() + .listResolvedModels(activeAgentId) .then((data) => setModels((data || []) as ModelPickerOption[])) .catch(() => setModels([])) .finally(() => setModelsLoading(false)); - }, [open]); + }, [open, activeAgentId]); useEffect(() => { if (!open || !activeAgentId) { @@ -170,8 +182,21 @@ export function JobDrawer({ } setConnectorsLoading(true); void connectorsApi - .listInstances() + .listInstances(remote ? activeAgentId : undefined) .then((instances) => { + setMailOptions( + (instances || []) + .filter( + (i) => + i.kind === "agently-cli" && + i.status === "active" && + i.has_credentials, + ) + .map((i) => ({ + value: i.instance_id, + label: i.display_name || i.instance_id, + })), + ); setConnectorOptions( (instances || []) .filter((i) => i.status === "active" && i.has_credentials) @@ -187,9 +212,12 @@ export function JobDrawer({ })), ); }) - .catch(() => setConnectorOptions([])) + .catch(() => { + setConnectorOptions([]); + setMailOptions([]); + }) .finally(() => setConnectorsLoading(false)); - }, [open]); + }, [open, activeAgentId, remote]); const modelOptions = buildModelSelectOptions( models, @@ -205,6 +233,15 @@ export function JobDrawer({ onClose={onClose} destroyOnHidden > + {remote ? ( + + ) : null} {t("cronJobs.form.scheduleModeCustom")} + {(mailOptions.length > 0 || scheduleMode === "agently") && ( + + {t("cronJobs.form.agentlyNewMail")} + + )} - {scheduleMode === "preset" ? ( + {scheduleMode === "agently" ? ( + + {t("cronJobs.form.agentlyHint")} + + } + > + + onUpdatePathMapping(i, "backend", v)} style={{ flex: 1 }} diff --git a/dashboard/src/pages/Experts/components/AgentCard.tsx b/dashboard/src/pages/Experts/components/AgentCard.tsx index 4aa497e2cc..dba1bfe010 100644 --- a/dashboard/src/pages/Experts/components/AgentCard.tsx +++ b/dashboard/src/pages/Experts/components/AgentCard.tsx @@ -36,6 +36,8 @@ import { isAgentModelConfigError, } from "../../../utils/agentError"; import styles from "../index.module.less"; +import RemoteExpertHint from "../../Chat/components/RemoteExpertHint"; +import { expertDeleteConfirm } from "../../../utils/remoteExpert"; import { isSharedExpertViewer } from "../../../utils/sharedExpert"; import type { PublishedExpert } from "../../../api/modules/publishedExperts"; import PublishTemplateButton from "./PublishTemplateButton"; @@ -45,14 +47,12 @@ const STATE_META: Record< string, { color: string; bg: string; spin?: boolean } > = { - /* Colour-blind-safe state palette (audit A11Y-6): all ≥4.5:1 on the card - background as text, and green/amber/red distinguished by lightness. */ - running: { color: "#047857", bg: "rgba(4,120,87,0.12)" }, - stopped: { color: "#6b7280", bg: "rgba(107,114,128,0.10)" }, - created: { color: "#6b7280", bg: "rgba(107,114,128,0.10)" }, - failed: { color: "#b91c1c", bg: "rgba(185,28,28,0.10)" }, - starting: { color: "#1d4ed8", bg: "rgba(29,78,216,0.10)", spin: true }, - stopping: { color: "#1d4ed8", bg: "rgba(29,78,216,0.10)", spin: true }, + running: { color: "#52c41a", bg: "rgba(82,196,26,0.12)" }, + stopped: { color: "#8c8c8c", bg: "rgba(140,140,140,0.10)" }, + created: { color: "#8c8c8c", bg: "rgba(140,140,140,0.10)" }, + failed: { color: "#ff4d4f", bg: "rgba(255,77,79,0.10)" }, + starting: { color: "#1677ff", bg: "rgba(22,119,255,0.10)", spin: true }, + stopping: { color: "#1677ff", bg: "rgba(22,119,255,0.10)", spin: true }, }; function getStateMeta(state: string) { @@ -114,9 +114,9 @@ export const AgentCard = memo(function AgentCard({ setLocalError(agent.last_error); }, [agent.state, agent.last_error]); - // Poll during transient states + // Poll during transient states (local agents only — bridge shadows have no runtime status). useEffect(() => { - if (!TRANSIENT.has(localState)) { + if (agent.bridge || !TRANSIENT.has(localState)) { if (pollRef.current) clearInterval(pollRef.current); pollRef.current = null; return; @@ -142,10 +142,17 @@ export const AgentCard = memo(function AgentCard({ if (pollRef.current) clearInterval(pollRef.current); pollRef.current = null; }; - }, [localState, agent.agent_id, onStateChange, onPollSettled, refreshAgents]); + }, [ + localState, + agent.agent_id, + agent.bridge, + onStateChange, + onPollSettled, + refreshAgents, + ]); useEffect(() => { - if (localState !== "running") { + if (agent.bridge || localState !== "running") { setMemorySlimming(false); if (maintPollRef.current) { clearInterval(maintPollRef.current); @@ -187,7 +194,7 @@ export const AgentCard = memo(function AgentCard({ maintPollRef.current = null; } }; - }, [localState, agent.agent_id]); + }, [localState, agent.agent_id, agent.bridge]); const isTransient = TRANSIENT.has(localState); const switchChecked = localState === "running" || localState === "starting"; @@ -279,6 +286,7 @@ export const AgentCard = memo(function AgentCard({ const chatReady = isAgentChatReady(localState); const sharedViewer = isSharedExpertViewer(agent); const isOwner = agent.is_owner !== false; + const deleteConfirm = expertDeleteConfirm(agent, t); return ( <> @@ -314,6 +322,7 @@ export const AgentCard = memo(function AgentCard({ : t("experts.share.badge")} )} +
@@ -340,7 +349,6 @@ export const AgentCard = memo(function AgentCard({ {formatAgentState(localState, t)}
@@ -444,8 +452,8 @@ export const AgentCard = memo(function AgentCard({ void handleDelete()} okText={t("common.delete", "Delete")} cancelText={t("common.cancel")} diff --git a/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx b/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx index 7f686de134..6aa904d475 100644 --- a/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx +++ b/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx @@ -45,6 +45,8 @@ import { isAgentModelConfigError, } from "../../../utils/agentError"; import styles from "../index.module.less"; +import RemoteExpertHint from "../../Chat/components/RemoteExpertHint"; +import { expertDeleteConfirm } from "../../../utils/remoteExpert"; import { isSharedExpertViewer } from "../../../utils/sharedExpert"; import type { PublishedExpert } from "../../../api/modules/publishedExperts"; import PublishTemplateButton from "./PublishTemplateButton"; @@ -169,6 +171,7 @@ export default function AgentExpertsTable({ useEffect(() => { const transientIds = agents + .filter((a) => !a.bridge) .map((a) => a.agent_id) .filter((id) => TRANSIENT.has(localStates[id] ?? "")); if (transientIds.length === 0) { @@ -317,6 +320,7 @@ export default function AgentExpertsTable({ : t("experts.share.badge")} )} +
), }, @@ -431,6 +435,7 @@ export default function AgentExpertsTable({ const isTransient = TRANSIENT.has(state); const chatReady = isAgentChatReady(state); const isOwner = row.is_owner !== false; + const deleteConfirm = expertDeleteConfirm(row, t); return (
{isOwner && ( @@ -475,8 +480,8 @@ export default function AgentExpertsTable({ void handleDelete(row)} okText={t("common.delete", "Delete")} cancelText={t("common.cancel")} diff --git a/dashboard/src/pages/Experts/components/CatalogDrawer.tsx b/dashboard/src/pages/Experts/components/CatalogDrawer.tsx index bfe916083f..964644ecf8 100644 --- a/dashboard/src/pages/Experts/components/CatalogDrawer.tsx +++ b/dashboard/src/pages/Experts/components/CatalogDrawer.tsx @@ -1,5 +1,6 @@ import type { CSSProperties, ReactNode } from "react"; -import { Drawer } from "antd"; +import { Alert, Drawer } from "antd"; +import { useTranslation } from "react-i18next"; import { useIsMobile } from "../../../hooks/useIsMobile"; import styles from "../index.module.less"; @@ -12,6 +13,10 @@ interface CatalogDrawerProps { width?: string; /** Mobile body padding (Subagents uses 0 for edge-to-edge). */ mobileBodyPadding?: CSSProperties["padding"]; + /** When this is a Bridge shadow expert, show the remote-edit banner. */ + agentId?: string | null; + /** Extra i18n key under ``chat.remoteExpert`` for surfaces that cannot fully tunnel. */ + remoteHintKey?: string; } /** @@ -24,8 +29,12 @@ export default function CatalogDrawer({ children, width = "min(1080px, 92vw)", mobileBodyPadding = "12px 14px 16px", + agentId, + remoteHintKey, }: CatalogDrawerProps) { + const { t } = useTranslation(); const isMobile = useIsMobile(); + const remote = Boolean(agentId?.startsWith("bridge:")); return ( + {remote ? ( + + ) : null} {children} ); diff --git a/dashboard/src/pages/Experts/components/ChannelCatalogDrawer.tsx b/dashboard/src/pages/Experts/components/ChannelCatalogDrawer.tsx index 5e4f8cf7b6..fa59d74d14 100644 --- a/dashboard/src/pages/Experts/components/ChannelCatalogDrawer.tsx +++ b/dashboard/src/pages/Experts/components/ChannelCatalogDrawer.tsx @@ -21,6 +21,8 @@ export default function ChannelCatalogDrawer({ title={t("pageShell.channels.title")} open={open} onClose={onClose} + agentId={agentId} + remoteHintKey="editChannels" >
+ + ({ + value: mode, + label: t(`chat.conversationMode.${mode}`), + }))} + /> + - + {!skillPackagesSupported ? ( setListRenameValue(e.target.value)} onPressEnter={() => void confirmListRename()} - disabled={listRenameSaving} autoFocus /> @@ -1348,7 +1376,7 @@ export default function EditAgentDrawer({ return ( } width={520} onClose={onClose} destroyOnHidden diff --git a/dashboard/src/pages/Experts/components/EditExpertDrawerTitle.test.tsx b/dashboard/src/pages/Experts/components/EditExpertDrawerTitle.test.tsx new file mode 100644 index 0000000000..581b9d60e3 --- /dev/null +++ b/dashboard/src/pages/Experts/components/EditExpertDrawerTitle.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { name?: string }) => + opts?.name ? `${key}:${opts.name}` : key, + }), +})); + +vi.mock("./iconForName", () => ({ + iconForName: () => , +})); + +import EditExpertDrawerTitle from "./EditExpertDrawerTitle"; + +describe("EditExpertDrawerTitle", () => { + it("places the peer icon and connection name after the title", () => { + const { container } = render( + , + ); + expect(screen.getByTestId("peer-icon")).toBeInTheDocument(); + expect(screen.getByText("云端实验室")).toBeInTheDocument(); + expect(screen.getByText("experts.editExpert")).toBeInTheDocument(); + expect(container.textContent).toMatch( + /experts\.editExpert[\s\S]*云端实验室/, + ); + }); + + it("keeps the plain title for a local expert", () => { + render( + , + ); + expect(screen.getByText("experts.editExpert")).toBeInTheDocument(); + expect(screen.queryByTestId("peer-icon")).not.toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/pages/Experts/components/EditExpertDrawerTitle.tsx b/dashboard/src/pages/Experts/components/EditExpertDrawerTitle.tsx new file mode 100644 index 0000000000..8cb5c5d77e --- /dev/null +++ b/dashboard/src/pages/Experts/components/EditExpertDrawerTitle.tsx @@ -0,0 +1,30 @@ +import { useTranslation } from "react-i18next"; +import { isBridgeAgentId } from "../../../utils/remoteExpert"; +import RemoteExpertHint from "../../Chat/components/RemoteExpertHint"; +import styles from "../index.module.less"; + +interface EditExpertDrawerTitleProps { + agent?: { + agent_id?: string; + bridge?: boolean | null; + bridge_connection_name?: string | null; + bridge_connection_icon?: string | null; + } | null; +} + +/** Drawer title: “Edit Expert”, with peer icon + connection name on the right. */ +export default function EditExpertDrawerTitle({ + agent, +}: EditExpertDrawerTitleProps) { + const { t } = useTranslation(); + const remote = Boolean(agent?.bridge) || isBridgeAgentId(agent?.agent_id); + if (remote && agent) { + return ( + + {t("experts.editExpert")} + + + ); + } + return t("experts.editExpert"); +} diff --git a/dashboard/src/pages/Experts/components/ExpertComposerDefaultsFields.test.tsx b/dashboard/src/pages/Experts/components/ExpertComposerDefaultsFields.test.tsx new file mode 100644 index 0000000000..a394e373ff --- /dev/null +++ b/dashboard/src/pages/Experts/components/ExpertComposerDefaultsFields.test.tsx @@ -0,0 +1,57 @@ +import { Form } from "antd"; +import { render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { listKnowledge, listInstances } = vi.hoisted(() => ({ + listKnowledge: vi.fn(), + listInstances: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("../../../hooks/useCurrentUser", () => ({ + useCurrentUser: () => ({ id: 7 }), +})); + +vi.mock("../../../api/modules/knowledgeBases", () => ({ + knowledgeBasesApi: { list: listKnowledge }, +})); + +vi.mock("../../../api/modules/connectors", () => ({ + connectorsApi: { listInstances }, +})); + +import ExpertComposerDefaultsFields from "./ExpertComposerDefaultsFields"; + +function renderFields(agentId?: string | null) { + return render( + + + , + ); +} + +describe("ExpertComposerDefaultsFields", () => { + beforeEach(() => { + listKnowledge.mockReset(); + listInstances.mockReset(); + listKnowledge.mockResolvedValue([{ id: "kb1", name: "KB" }]); + listInstances.mockResolvedValue([]); + }); + + it("loads local knowledge bases and connectors by default", async () => { + renderFields(); + await waitFor(() => expect(listKnowledge).toHaveBeenCalled()); + expect(listKnowledge).toHaveBeenCalledWith(undefined); + expect(listInstances).toHaveBeenCalledWith(undefined); + }); + + it("passes a peer agent id so pickers tunnel to the peer", async () => { + const agentId = "bridge:c1:doctor"; + renderFields(agentId); + await waitFor(() => expect(listKnowledge).toHaveBeenCalledWith(agentId)); + expect(listInstances).toHaveBeenCalledWith(agentId); + }); +}); diff --git a/dashboard/src/pages/Experts/components/ExpertComposerDefaultsFields.tsx b/dashboard/src/pages/Experts/components/ExpertComposerDefaultsFields.tsx index 8ccd19bec6..dc3dd4f2c2 100644 --- a/dashboard/src/pages/Experts/components/ExpertComposerDefaultsFields.tsx +++ b/dashboard/src/pages/Experts/components/ExpertComposerDefaultsFields.tsx @@ -6,7 +6,14 @@ import { connectorsApi } from "../../../api/modules/connectors"; import { knowledgeBasesApi } from "../../../api/modules/knowledgeBases"; import { useCurrentUser } from "../../../hooks/useCurrentUser"; -export default function ExpertComposerDefaultsFields() { +interface ExpertComposerDefaultsFieldsProps { + /** Bridge shadow id so knowledge / connector pickers tunnel to the peer. */ + agentId?: string | null; +} + +export default function ExpertComposerDefaultsFields({ + agentId, +}: ExpertComposerDefaultsFieldsProps) { const { t } = useTranslation(); const currentUserId = useCurrentUser()?.id ?? null; const [knowledgeBases, setKnowledgeBases] = useState< @@ -22,7 +29,7 @@ export default function ExpertComposerDefaultsFields() { let cancelled = false; setKnowledgeLoading(true); knowledgeBasesApi - .list() + .list(agentId) .then((bases) => { if (!cancelled) { setKnowledgeBases( @@ -39,13 +46,13 @@ export default function ExpertComposerDefaultsFields() { return () => { cancelled = true; }; - }, []); + }, [agentId]); useEffect(() => { let cancelled = false; setConnectorsLoading(true); connectorsApi - .listInstances() + .listInstances(agentId) .then((instances) => { if (cancelled) return; setConnectors( @@ -73,7 +80,7 @@ export default function ExpertComposerDefaultsFields() { return () => { cancelled = true; }; - }, [currentUserId]); + }, [agentId, currentUserId]); return ( <> diff --git a/dashboard/src/pages/Experts/components/FileEditModal.tsx b/dashboard/src/pages/Experts/components/FileEditModal.tsx index 45a77c2ea7..09c9001183 100644 --- a/dashboard/src/pages/Experts/components/FileEditModal.tsx +++ b/dashboard/src/pages/Experts/components/FileEditModal.tsx @@ -1,10 +1,11 @@ // dashboard/src/pages/Experts/components/FileEditModal.tsx import { lazy, Suspense, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Drawer, Modal, Spin } from "antd"; +import { Button, Drawer, Spin } from "antd"; import { message } from "@/utils/antdMessage"; import { request } from "../../../api/request"; +import { useTheme } from "../../../context/ThemeContext"; import { withFromWorkspace } from "../../../utils/fromWorkspace"; const MonacoEditor = lazy(() => import("@monaco-editor/react")); @@ -36,28 +37,10 @@ export default function FileEditModal({ onLocalSave, }: FileEditModalProps) { const { t } = useTranslation(); + const { isDark } = useTheme(); const [value, setValue] = useState(""); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); - const [dirty, setDirty] = useState(false); - const [confirmClose, setConfirmClose] = useState(false); - - useEffect(() => { - if (!open) { - setDirty(false); - setConfirmClose(false); - } - }, [open]); - - const requestClose = () => { - // FE-2: silently discarding edits loses work with no confirm, no toast, - // no undo — ask before closing whenever the editor is dirty. - if (dirty && !saving) { - setConfirmClose(true); - return; - } - onClose(); - }; useEffect(() => { if (!open || !filePath) return; @@ -81,19 +64,8 @@ export default function FileEditModal({ .then((data) => { if (!cancelled) setValue(data.content ?? ""); }) - .catch((err: unknown) => { - // FE-2: an empty editor pre-filled on failure lets a save overwrite - // the real file. Show the error and leave the editor empty-but-clean - // so the user must explicitly re-load before saving. - if (!cancelled) { - setValue(""); - message.error( - (err instanceof Error ? err.message : String(err)) || - t("experts.fileLoadFailed", { - filename: filePath.replace(/^\//, ""), - }), - ); - } + .catch(() => { + if (!cancelled) setValue(""); }) .finally(() => { if (!cancelled) setLoading(false); @@ -146,7 +118,7 @@ export default function FileEditModal({ placement="right" title={title} width={fileEditDrawerWidth()} - onClose={requestClose} + onClose={onClose} destroyOnHidden styles={{ body: { @@ -165,7 +137,7 @@ export default function FileEditModal({ gap: 8, }} > -
)} - setConfirmClose(false)} - footer={[ - , - , - ]} - > -

{t("experts.unsavedChangesBody")}

-
); } diff --git a/dashboard/src/pages/Experts/components/MbtiCatalogDrawer.tsx b/dashboard/src/pages/Experts/components/MbtiCatalogDrawer.tsx index 42c9028bb9..27236e705d 100644 --- a/dashboard/src/pages/Experts/components/MbtiCatalogDrawer.tsx +++ b/dashboard/src/pages/Experts/components/MbtiCatalogDrawer.tsx @@ -26,6 +26,7 @@ export default function MbtiCatalogDrawer({ title={t("personalization.mbti.catalogTitle")} open={open} onClose={onClose} + agentId={agentId} > {agentId ? (
diff --git a/dashboard/src/pages/Experts/components/RootDirSelect.tsx b/dashboard/src/pages/Experts/components/RootDirSelect.tsx index 9112a51542..42da94b0cb 100644 --- a/dashboard/src/pages/Experts/components/RootDirSelect.tsx +++ b/dashboard/src/pages/Experts/components/RootDirSelect.tsx @@ -18,8 +18,9 @@ import { ancestorDirPaths, appendChildren, insertChild, - makeRootNode, - normalizeTreeRoot, + isTreeRoot, + makeRootNodes, + normalizeTreeRoots, renameNode, sanitizeTree, type DirTreeNode, @@ -34,8 +35,12 @@ interface DirEntry { interface RootDirSelectProps { value?: string; onChange?: (value: string) => void; - /** Tree browse root: host ``/`` (or drive root). Default value is still home. */ - treeRoot?: string; + /** + * Browse-tree roots. A bare string is accepted for single-root callers; + * Windows hosts pass every ready drive so the picker is not confined to + * whichever drive holds the server's home directory. + */ + treeRoots?: string | string[]; /** When true, shows the current path but blocks picking / mkdir / rename. */ disabled?: boolean; } @@ -74,14 +79,17 @@ function entriesToNodes(entries: DirEntry[]): DirTreeNode[] { export default function RootDirSelect({ value, onChange, - treeRoot = HOST_FS_ROOT, + treeRoots = HOST_FS_ROOT, disabled = false, }: RootDirSelectProps) { const { t } = useTranslation(); - const normalizedRoot = normalizeTreeRoot(treeRoot); - const [treeData, setTreeData] = useState(() => [ - makeRootNode(normalizedRoot), - ]); + const normalizedRoots = useMemo( + () => normalizeTreeRoots(treeRoots), + [treeRoots], + ); + const [treeData, setTreeData] = useState(() => + makeRootNodes(normalizedRoots), + ); const [expandedKeys, setExpandedKeys] = useState( undefined, ); @@ -99,9 +107,9 @@ export default function RootDirSelect({ ( updater: (prev: DirTreeNode[]) => DirTreeNode[], ): ((prev: DirTreeNode[]) => DirTreeNode[]) => { - return (prev) => sanitizeTree(updater(prev), normalizedRoot); + return (prev) => sanitizeTree(updater(prev), normalizedRoots); }, - [normalizedRoot], + [normalizedRoots], ); const markLoaded = useCallback((paths: string[]) => { @@ -118,12 +126,12 @@ export default function RootDirSelect({ }, []); const resetTree = useCallback(() => { - setTreeData([makeRootNode(normalizedRoot)]); + setTreeData(makeRootNodes(normalizedRoots)); loadedKeysRef.current.clear(); setLoadedKeys([]); setExpandedKeys(undefined); loadingPathsRef.current.clear(); - }, [normalizedRoot]); + }, [normalizedRoots]); useEffect(() => { resetTree(); @@ -169,7 +177,7 @@ export default function RootDirSelect({ // parents exist makes antd loadData miss child merges; no synthetic path chain. useEffect(() => { if (!value) return; - const ancestors = ancestorDirPaths(value, normalizedRoot); + const ancestors = ancestorDirPaths(value, normalizedRoots); if (ancestors.length === 0) return; let cancelled = false; @@ -185,7 +193,7 @@ export default function RootDirSelect({ return () => { cancelled = true; }; - }, [value, normalizedRoot]); + }, [value, normalizedRoots]); const loadData = useCallback>( async (node) => { @@ -267,7 +275,7 @@ export default function RootDirSelect({ }), ), ); - const ancestors = ancestorDirPaths(result.path, normalizedRoot); + const ancestors = ancestorDirPaths(result.path, normalizedRoots); setExpandedKeys((prev) => [ ...new Set([...(prev ?? []), ...ancestors]), ]); @@ -278,7 +286,7 @@ export default function RootDirSelect({ setBusy(false); } }, - [beginEditing, busy, normalizedRoot, t, withSanitizedTree], + [beginEditing, busy, normalizedRoots, t, withSanitizedTree], ); const renderTitle = useCallback( @@ -334,7 +342,7 @@ export default function RootDirSelect({ flexShrink: 0, }} > - {path !== normalizedRoot ? ( + {isTreeRoot(path, normalizedRoots) ? null : ( - ) : null} + )}
@@ -360,24 +372,29 @@ export const TeamCard = memo(function TeamCard({ - void handleDelete()} - okText={t("common.delete", "Delete")} - cancelText={t("common.cancel")} - okButtonProps={{ danger: true }} - > - - - - + + +
+ ) : null} { ), ).toEqual(["a", "b"]); }); + + it("does not pick peer (cloud-collab) experts for a local team", () => { + expect( + selectedRosterIds( + ["a", "bridge:c1:x", "b"], + [ + ...experts, + { + agent_id: "bridge:c1:x", + name: "Peer", + kind: "expert", + is_owner: true, + bridge: true, + }, + ], + ), + ).toEqual(["a", "b"]); + }); + + it("keeps the first-load order when toggling selection", () => { + const onChange = vi.fn(); + const { rerender } = render( + , + ); + const names = () => + screen.getAllByRole("button").map((el) => el.textContent ?? ""); + expect(names()[0]).toMatch(/Gamma/); + + fireEvent.click(screen.getByRole("button", { name: /Alpha/ })); + expect(onChange).toHaveBeenCalledWith(["c", "a"]); + rerender( + , + ); + expect(names()[0]).toMatch(/Gamma/); + }); }); diff --git a/dashboard/src/pages/Experts/components/TeamMemberPicker.tsx b/dashboard/src/pages/Experts/components/TeamMemberPicker.tsx index 958ec14a96..a5867dd0b4 100644 --- a/dashboard/src/pages/Experts/components/TeamMemberPicker.tsx +++ b/dashboard/src/pages/Experts/components/TeamMemberPicker.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Input } from "antd"; import { Check } from "lucide-react"; import { isOwnedExpert } from "../../../utils/sharedExpert"; +import { isBridgeAgentId } from "../../../utils/remoteExpert"; import { ExpertIcon } from "./iconForName"; import styles from "../index.module.less"; @@ -16,6 +17,7 @@ export interface TeamMemberOption { kind?: string; is_shared?: boolean; is_owner?: boolean; + bridge?: boolean | null; } interface TeamMemberPickerProps { @@ -26,6 +28,7 @@ interface TeamMemberPickerProps { export function isPickableExpert(item: TeamMemberOption): boolean { if (item.kind === "team") return false; + if (item.bridge || isBridgeAgentId(item.agent_id)) return false; return isOwnedExpert(item) || Boolean(item.is_shared); } @@ -59,6 +62,10 @@ export default function TeamMemberPicker({ [value, experts], ); const selectedSet = useMemo(() => new Set(selected), [selected]); + const [frozenFrontIds, setFrozenFrontIds] = useState | null>( + null, + ); + const frontIds = frozenFrontIds ?? selectedSet; const candidates = useMemo(() => { const byId = new Map(); @@ -79,14 +86,17 @@ export default function TeamMemberPicker({ }) : candidates; return [...rows].sort((left, right) => { - const leftOn = selectedSet.has(left.agent_id) ? 0 : 1; - const rightOn = selectedSet.has(right.agent_id) ? 0 : 1; + const leftOn = frontIds.has(left.agent_id) ? 0 : 1; + const rightOn = frontIds.has(right.agent_id) ? 0 : 1; if (leftOn !== rightOn) return leftOn - rightOn; return left.name.localeCompare(right.name); }); - }, [candidates, query, selectedSet]); + }, [candidates, frontIds, query]); const toggle = (id: string) => { + if (frozenFrontIds == null) { + setFrozenFrontIds(new Set(selected)); + } onChange?.( selectedSet.has(id) ? selected.filter((item) => item !== id) diff --git a/dashboard/src/pages/Experts/components/ToolCatalogDrawer.tsx b/dashboard/src/pages/Experts/components/ToolCatalogDrawer.tsx index e02d1acfd0..62ae006549 100644 --- a/dashboard/src/pages/Experts/components/ToolCatalogDrawer.tsx +++ b/dashboard/src/pages/Experts/components/ToolCatalogDrawer.tsx @@ -22,6 +22,8 @@ export default function ToolCatalogDrawer({ title={t("pageShell.tools.title")} open={open} onClose={onClose} + agentId={agentId} + remoteHintKey="editTools" > diff --git a/dashboard/src/pages/Experts/components/agentBackendForm.skillPackages.test.ts b/dashboard/src/pages/Experts/components/agentBackendForm.skillPackages.test.ts index 5b420f2bfe..ff442bd272 100644 --- a/dashboard/src/pages/Experts/components/agentBackendForm.skillPackages.test.ts +++ b/dashboard/src/pages/Experts/components/agentBackendForm.skillPackages.test.ts @@ -16,6 +16,10 @@ describe("skill package backend gates", () => { expect(normalizeRootDir("/tmp/project/")).toBe("/tmp/project"); expect(isHostRootDir("/")).toBe(true); expect(isHostRootDir("/tmp/project")).toBe(false); + expect(isHostRootDir("C:/")).toBe(true); + expect(isHostRootDir("C:\\")).toBe(true); + expect(isHostRootDir("D:")).toBe(true); + expect(isHostRootDir("C:/Users")).toBe(false); }); it("allows local backends with host root", () => { @@ -88,7 +92,7 @@ describe("skill package backend gates", () => { ).toBe(false); }); - it("blocks outbound acp_runner under directory sandbox", () => { + it("locks ACP runner UI under directory sandbox", () => { expect( blocksAcpOutboundFromConfig({ backend: { type: "local_shell", root_dir: "/", virtual_mode: true }, diff --git a/dashboard/src/pages/Experts/components/agentBackendForm.state.test.ts b/dashboard/src/pages/Experts/components/agentBackendForm.state.test.ts new file mode 100644 index 0000000000..fd295f4055 --- /dev/null +++ b/dashboard/src/pages/Experts/components/agentBackendForm.state.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { BUILTIN_BACKENDS, parseBackendSpec } from "./agentBackendForm"; + +describe("expert backend choices", () => { + it("does not offer state when creating an expert", () => { + expect(BUILTIN_BACKENDS).not.toContain("state"); + }); + + it("keeps an already saved state backend visible in the editor", () => { + expect(parseBackendSpec({ type: "state" }).backendChoice).toBe("state"); + expect( + parseBackendSpec({ + type: "composite", + default: { type: "filesystem", virtual_mode: true, root_dir: "/" }, + routes: { "/tmp": { type: "state" } }, + }).pathMappings, + ).toEqual([{ path: "/tmp", backend: "state" }]); + }); +}); diff --git a/dashboard/src/pages/Experts/components/agentBackendForm.ts b/dashboard/src/pages/Experts/components/agentBackendForm.ts index eb22ec5a10..34eedc436a 100644 --- a/dashboard/src/pages/Experts/components/agentBackendForm.ts +++ b/dashboard/src/pages/Experts/components/agentBackendForm.ts @@ -1,6 +1,8 @@ import { request } from "../../../api/request"; -export const BUILTIN_BACKENDS = ["local_shell", "filesystem", "state"] as const; +export const BUILTIN_BACKENDS = ["local_shell", "filesystem"] as const; +/** Kept so an expert already saved as ``state`` still shows in the editor. */ +export const LEGACY_STATE_BACKEND = "state"; export const DEFAULT_BACKEND: BuiltinBackend = "local_shell"; export type BuiltinBackend = (typeof BUILTIN_BACKENDS)[number]; export type BackendChoice = BuiltinBackend | "composite" | `named:${string}`; @@ -19,10 +21,20 @@ export interface BackendOption { } export interface FilesystemDefaults { - home: string; default_root_dir: string; - allow_outside_home: boolean; tree_root: string; + /** + * Roots the picker may browse. Windows hosts list every ready drive, so the + * tree is not confined to whichever drive holds the server's home directory. + * Older servers omit this — fall back to `tree_root`. + */ + browse_roots?: string[]; + /** + * True only when a non-root `root_dir` really gets OS-level confinement + * (Linux + bubblewrap). Elsewhere it only bounds the agent's tool paths, so + * the UI must not promise a sandbox. + */ + jail_enforced?: boolean; /** True when the Octop server process runs inside a container. */ in_container?: boolean; } @@ -42,8 +54,8 @@ export function backendRefToSpec( if (isNamedBackend(ref)) { return { type: "named", name: ref.slice("named:".length) }; } - if (ref === "state") { - return { type: "state" }; + if (ref === LEGACY_STATE_BACKEND) { + return { type: LEGACY_STATE_BACKEND }; } if (ref === "local_shell" || ref === "filesystem") { return { type: ref, virtual_mode: true, root_dir: rootDir ?? "/" }; @@ -79,11 +91,16 @@ export interface RootDirProbeResult { export function normalizeRootDir(rootDir?: string | null): string { const trimmed = (rootDir ?? "").trim(); if (!trimmed || trimmed === "\\" || trimmed === "/") return "/"; - return trimmed.replace(/\/+$/, "") || "/"; + // Strip trailing POSIX or Windows separators (so ``C:/`` → ``C:``). + const stripped = trimmed.replace(/[/\\]+$/, ""); + return stripped || "/"; } +/** True for POSIX ``/`` or a Windows drive root such as ``C:/`` / ``C:``. */ export function isHostRootDir(rootDir?: string | null): boolean { - return normalizeRootDir(rootDir) === "/"; + const normalized = normalizeRootDir(rootDir); + if (normalized === "/") return true; + return /^[A-Za-z]:$/.test(normalized); } /** @@ -130,12 +147,12 @@ export function supportsHostSkillPackagesFromConfig( } /** - * Whether outbound ``acp_runner`` should be blocked for this backend. + * Whether the ACP page should lock runner enable/edit for this backend. * - * Scoped ``root_dir`` enables the Linux bwrap jail; host-spawned ACP runners - * would bypass it. Host root ``/`` and the agent workspace root are allowed - * (Windows defaults to the workspace). Non-local backends are blocked. - * Inbound ``octop acp`` (IDE → this agent) is unaffected. + * Scoped ``root_dir`` enables the Linux bwrap jail. Host root ``/`` and the + * agent workspace root are not treated as a jail (Windows defaults to the + * workspace). Non-local backends count as locked for that UI. The per-agent + * ``acp_runner`` tool toggle and inbound ``octop acp`` are unaffected. */ export function blocksAcpOutbound(options: { backendChoice: string; @@ -157,16 +174,21 @@ export function blocksAcpOutbound(options: { return true; } -/** Detect outbound-ACP block from an agent ``config`` blob. */ +/** Detect ACP runner-lock (sandbox) from an agent ``config`` blob. */ export function blocksAcpOutboundFromConfig( config: Record | null | undefined, workspaceDir?: string | null, ): boolean { const parsed = parseBackendSpec(config?.backend); + const workspaceRaw = config?.workspace_dir; + const fromConfig = + typeof workspaceRaw === "string" && workspaceRaw.trim() + ? workspaceRaw.trim() + : null; return blocksAcpOutbound({ backendChoice: parsed.backendChoice, rootDir: parsed.rootDir, - workspaceDir, + workspaceDir: workspaceDir ?? fromConfig, }); } @@ -278,7 +300,10 @@ function specToBackendRef(spec: Record): string { const name = spec.name; return typeof name === "string" ? `named:${name}` : DEFAULT_BACKEND; } - if (BUILTIN_BACKENDS.includes(type as BuiltinBackend)) { + if ( + type === LEGACY_STATE_BACKEND || + BUILTIN_BACKENDS.includes(type as BuiltinBackend) + ) { return type; } return DEFAULT_BACKEND; @@ -340,10 +365,10 @@ export function parseBackendSpec(spec: unknown): { } export function builtinLabel( - mode: BuiltinBackend, + mode: BuiltinBackend | typeof LEGACY_STATE_BACKEND, t: (key: string) => string, ): string { - const keys: Record = { + const keys: Record = { local_shell: "experts.backendModes.localShell", filesystem: "experts.backendModes.filesystem", state: "experts.backendModes.state", diff --git a/dashboard/src/pages/Experts/components/iconForName.test.ts b/dashboard/src/pages/Experts/components/iconForName.test.ts new file mode 100644 index 0000000000..ce9f6a0d18 --- /dev/null +++ b/dashboard/src/pages/Experts/components/iconForName.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeAgentScopedApiUrl, + resolveExpertAvatarUrl, +} from "./iconForName"; + +describe("normalizeAgentScopedApiUrl", () => { + it("encodes bridge shadow agent ids the same way as chat WS paths", () => { + const id = "bridge:01CID:01REMOTE"; + expect(normalizeAgentScopedApiUrl(`/api/agents/${id}/avatar`)).toBe( + `/api/agents/${encodeURIComponent(id)}/avatar`, + ); + }); + + it("does not double-encode an already percent-encoded agent id", () => { + const id = "bridge:01CID:01REMOTE"; + const encoded = `/api/agents/${encodeURIComponent(id)}/avatar`; + expect(normalizeAgentScopedApiUrl(encoded)).toBe(encoded); + }); + + it("preserves query strings on avatar URLs", () => { + const id = "bridge:01CID:01REMOTE"; + expect(normalizeAgentScopedApiUrl(`/api/agents/${id}/avatar?v=3`)).toBe( + `/api/agents/${encodeURIComponent(id)}/avatar?v=3`, + ); + }); + + it("leaves non-agent paths unchanged", () => { + expect(normalizeAgentScopedApiUrl("/experts/avatars/scene-tech.svg")).toBe( + "/experts/avatars/scene-tech.svg", + ); + }); +}); + +describe("resolveExpertAvatarUrl", () => { + it("normalizes bridge avatar API paths", () => { + const id = "bridge:01CID:01REMOTE"; + expect(resolveExpertAvatarUrl(`/api/agents/${id}/avatar`)).toBe( + `/api/agents/${encodeURIComponent(id)}/avatar`, + ); + }); + + it("returns null for empty input", () => { + expect(resolveExpertAvatarUrl(null)).toBeNull(); + expect(resolveExpertAvatarUrl(" ")).toBeNull(); + }); +}); diff --git a/dashboard/src/pages/Experts/components/iconForName.tsx b/dashboard/src/pages/Experts/components/iconForName.tsx index e88eae5306..ba90401236 100644 --- a/dashboard/src/pages/Experts/components/iconForName.tsx +++ b/dashboard/src/pages/Experts/components/iconForName.tsx @@ -9,6 +9,7 @@ */ import type { ReactNode } from "react"; +import { useEffect, useState } from "react"; import { Sparkles, Globe, @@ -46,6 +47,15 @@ import { Coffee, ShoppingBag, Users, + Cloudy, + Cloud, + CloudCog, + Laptop, + Monitor, + Wifi, + RadioTower, + Satellite, + MapPin, } from "lucide-react"; import { useAuthImageSrc } from "../../../hooks/useAuthImageSrc"; import { needsAuthBlobFetch } from "../../../utils/toolMediaBlocks"; @@ -86,6 +96,15 @@ const iconMap: Record ReactNode> = { bell: (size) => , coffee: (size) => , "shopping-bag": (size) => , + cloudy: (size) => , + cloud: (size) => , + "cloud-cog": (size) => , + laptop: (size) => , + monitor: (size) => , + wifi: (size) => , + "radio-tower": (size) => , + satellite: (size) => , + "map-pin": (size) => , }; export const EXPERT_ICON_NAMES = Object.keys(iconMap); @@ -125,10 +144,44 @@ export function ExpertIcon({ return iconForName(iconName, size); } +/** + * Encode the agent-id segment in ``/api/agents/{id}/…`` URLs. + * Bridge shadow ids contain ``:`` and must match chat WS / threads encoding. + */ +export function normalizeAgentScopedApiUrl(url: string): string { + const trimmed = url.trim(); + if (!trimmed) return trimmed; + try { + const abs = trimmed.startsWith("http") + ? new URL(trimmed) + : new URL(trimmed, "http://octop.local"); + const match = abs.pathname.match(/^(\/api\/agents\/)([^/]+)(\/.*)?$/); + if (!match) return trimmed; + let agentId = match[2]; + try { + agentId = decodeURIComponent(agentId); + } catch { + /* keep raw */ + } + const path = `${match[1]}${encodeURIComponent(agentId)}${match[3] || ""}`; + if (trimmed.startsWith("http")) { + abs.pathname = path; + return abs.toString(); + } + return `${path}${abs.search}${abs.hash}`; + } catch { + return trimmed; + } +} + /** Trust the API ``icon_url``; do not invent scene portraits on the client. */ export function resolveExpertAvatarUrl(iconUrl?: string | null): string | null { const url = iconUrl?.trim() || ""; - return url || null; + if (!url) return null; + if (url.startsWith("/api/agents/")) { + return normalizeAgentScopedApiUrl(url); + } + return url; } function ExpertIconImage({ @@ -143,7 +196,11 @@ function ExpertIconImage({ className?: string; }) { const { src, loadState } = useAuthImageSrc(url); - if (!src || loadState !== "ready") { + const [imgFailed, setImgFailed] = useState(false); + useEffect(() => { + setImgFailed(false); + }, [url]); + if (!src || loadState !== "ready" || imgFailed) { return iconForName(iconName, size); } const cover = @@ -155,6 +212,7 @@ function ExpertIconImage({ src={src} alt="" className={className} + onError={() => setImgFailed(true)} style={{ width: size, height: size, diff --git a/dashboard/src/pages/Experts/components/rootDirTree.test.ts b/dashboard/src/pages/Experts/components/rootDirTree.test.ts index 707046ffc3..472b519ee6 100644 --- a/dashboard/src/pages/Experts/components/rootDirTree.test.ts +++ b/dashboard/src/pages/Experts/components/rootDirTree.test.ts @@ -4,6 +4,9 @@ import { ancestorDirPaths, insertChild, isPathUnderHome, + isTreeRoot, + makeRootNodes, + normalizeTreeRoots, pathExistsInTree, renameNode, sanitizeTree, @@ -228,3 +231,89 @@ describe("rootDirTree helpers", () => { expect(next[1].children).toBeUndefined(); }); }); + +describe("multi-root browse tree (Windows drives)", () => { + const drives = ["C:/", "D:/"]; + + it("normalizeTreeRoots dedupes case-insensitively and keeps order", () => { + expect(normalizeTreeRoots(["C:/", "c:\\", "D:/"])).toEqual(["C:/", "D:/"]); + expect(normalizeTreeRoots("C:/")).toEqual(["C:/"]); + expect(normalizeTreeRoots([])).toEqual(["/"]); + }); + + it("makeRootNodes builds one node per drive with a drive-letter title", () => { + const nodes = makeRootNodes(drives); + expect(nodes.map((n) => n.value)).toEqual(["C:/", "D:/"]); + expect(nodes.map((n) => n.title)).toEqual(["C:", "D:"]); + }); + + it("isTreeRoot recognizes each drive but not its subdirectories", () => { + expect(isTreeRoot("C:/", drives)).toBe(true); + expect(isTreeRoot("d:\\", drives)).toBe(true); + expect(isTreeRoot("D:/projects", drives)).toBe(false); + expect(isTreeRoot("E:/", drives)).toBe(false); + }); + + it("sanitizeTree keeps every root and drops cross-root orphans", () => { + const tree: DirTreeNode[] = [ + { + value: "C:/", + title: "C:", + isLeaf: false, + children: [{ value: "C:/Users", title: "Users", isLeaf: false }], + }, + { + value: "D:/", + title: "D:", + isLeaf: false, + children: [{ value: "D:/octop", title: "octop", isLeaf: false }], + }, + // Orphan: not under any configured root. + { value: "E:/secret", title: "E:", isLeaf: false }, + ]; + const next = sanitizeTree(tree, drives); + expect(next.map((n) => n.value)).toEqual(["C:/", "D:/"]); + expect(pathExistsInTree(next, "D:/octop")).toBe(true); + expect(pathExistsInTree(next, "E:/secret")).toBe(false); + }); + + it("sanitizeTree still dedupes a repeated value across the forest", () => { + const dup = { value: "D:/octop", title: "octop", isLeaf: false }; + const next = sanitizeTree( + [ + { + value: "C:/", + title: "C:", + isLeaf: false, + children: [dup], + }, + { value: "D:/", title: "D:", isLeaf: false, children: [dup] }, + ], + drives, + ); + const occurrences = JSON.stringify(next).split("D:/octop").length - 1; + expect(occurrences).toBe(1); + }); + + it("sanitizeTree falls back to the caller's nodes when no root survives", () => { + const tree: DirTreeNode[] = [{ value: "E:/", title: "E:", isLeaf: false }]; + expect(sanitizeTree(tree, drives)).toEqual(tree); + }); + + it("ancestorDirPaths picks the longest matching root, not the first", () => { + expect(ancestorDirPaths("D:/octop/data", drives)).toEqual([ + "D:/", + "D:/octop", + ]); + expect(ancestorDirPaths("C:/Users/me/docs", drives)).toEqual([ + "C:/", + "C:/Users", + "C:/Users/me", + ]); + }); + + it("ancestorDirPaths returns [] for a drive root or a path outside all roots", () => { + expect(ancestorDirPaths("D:/", drives)).toEqual([]); + expect(ancestorDirPaths("E:/octop", drives)).toEqual([]); + }); +}); diff --git a/dashboard/src/pages/Experts/components/rootDirTree.ts b/dashboard/src/pages/Experts/components/rootDirTree.ts index 303f7556c5..2885b4773d 100644 --- a/dashboard/src/pages/Experts/components/rootDirTree.ts +++ b/dashboard/src/pages/Experts/components/rootDirTree.ts @@ -42,6 +42,30 @@ export function makeRootNode(path: string): DirTreeNode { return { value, title, isLeaf: false }; } +/** Deduplicated root list; accepts a bare string for single-root callers. */ +export function normalizeTreeRoots(roots: string | string[]): string[] { + const list = Array.isArray(roots) ? roots : [roots]; + const seen = new Set(); + const out: string[] = []; + for (const raw of list) { + const value = normalizeTreeRoot(raw); + const key = compareKey(value); + if (seen.has(key)) continue; + seen.add(key); + out.push(value); + } + return out.length > 0 ? out : [HOST_FS_ROOT]; +} + +export function makeRootNodes(roots: string | string[]): DirTreeNode[] { + return normalizeTreeRoots(roots).map(makeRootNode); +} + +export function isTreeRoot(path: string, roots: string | string[]): boolean { + const key = compareKey(normalizeTreeRoot(path)); + return normalizeTreeRoots(roots).some((root) => compareKey(root) === key); +} + /** True when *path* is *home* or a subdirectory of *home*. */ export function isPathUnderHome(path: string, home: string): boolean { const target = compareKey(path); @@ -66,18 +90,17 @@ export function pathExistsInTree(nodes: DirTreeNode[], path: string): boolean { return false; } -/** Keep a single tree under *treeRoot* — orphans duplicate keys and break expand. */ +/** Keep one tree per root under *treeRoots* — orphans duplicate keys and break expand. */ export function sanitizeTree( nodes: DirTreeNode[], - treeRoot: string = HOST_FS_ROOT, + treeRoots: string | string[] = HOST_FS_ROOT, ): DirTreeNode[] { - const rootValue = normalizeTreeRoot(treeRoot); - const rootKey = compareKey(rootValue); - const root = nodes.find((node) => compareKey(node.value) === rootKey); - if (!root) return nodes; + const rootKeys = normalizeTreeRoots(treeRoots).map(compareKey); // Ant Design TreeSelect virtual scroll renders duplicate rows when the same - // value appears more than once anywhere in treeData (antd#37228). + // value appears more than once anywhere in treeData (antd#37228). The set is + // shared across roots: every drive root is a distinct path, so global + // uniqueness is still the correct rule. const seen = new Set(); const walk = (node: DirTreeNode): DirTreeNode | null => { @@ -93,23 +116,42 @@ export function sanitizeTree( }; }; - const cleaned = walk(root); - return cleaned ? [cleaned] : [root]; + const cleaned: DirTreeNode[] = []; + for (const node of nodes) { + if (!rootKeys.includes(compareKey(node.value))) continue; + const walked = walk(node); + if (walked) cleaned.push(walked); + } + // Safety valve: a rename/mkdir that moved every root away should not blank + // the picker — keep the caller's nodes rather than rendering nothing. + return cleaned.length > 0 ? cleaned : nodes; } /** - * Ancestor directories from *treeRoot* down to the parent of *path* (excludes *path*). + * Ancestor directories from the most specific containing root down to the + * parent of *path* (excludes *path*). Returns [] when *path* sits outside + * every configured root, or when it *is* a root. */ export function ancestorDirPaths( path: string, - treeRoot: string = HOST_FS_ROOT, + treeRoots: string | string[] = HOST_FS_ROOT, ): string[] { const normalized = normalizeTreeRoot(path); - const root = normalizeTreeRoot(treeRoot); - if (!normalized || compareKey(normalized) === compareKey(root)) return []; - if (root !== HOST_FS_ROOT && !isPathUnderHome(normalized, root)) { - return []; - } + if (!normalized) return []; + const roots = normalizeTreeRoots(treeRoots); + // Longest-prefix wins so Windows `D:/x/y` expands under `D:/` even when + // `C:/` is listed first. + const root = roots + .filter((candidate) => isPathUnderHome(normalized, candidate)) + .reduce( + (best, candidate) => + best === null || compareKey(candidate).length > compareKey(best).length + ? candidate + : best, + null, + ); + if (root === null) return []; + if (compareKey(normalized) === compareKey(root)) return []; if (/^[A-Za-z]:\/$/.test(root)) { // Windows drive root: build from ``C:/Users/...`` under ``C:/``. diff --git a/dashboard/src/pages/Experts/components/sharedExpert.test.ts b/dashboard/src/pages/Experts/components/sharedExpert.test.ts index 6955de01c1..a1985525a9 100644 --- a/dashboard/src/pages/Experts/components/sharedExpert.test.ts +++ b/dashboard/src/pages/Experts/components/sharedExpert.test.ts @@ -5,6 +5,7 @@ import { isSharedExpertViewer, ownedExperts, ownedSoloExperts, + ownedTeamAgents, } from "../../../utils/sharedExpert"; describe("isSharedExpertViewer", () => { @@ -68,3 +69,19 @@ describe("ownedSoloExperts", () => { expect(ownedSoloExperts(agents).map((a) => a.agent_id)).toEqual(["writer"]); }); }); + +describe("ownedTeamAgents", () => { + it("keeps owned team hosts only", () => { + const agents = [ + { agent_id: "writer", is_owner: true, kind: "expert" }, + { agent_id: "crew", is_owner: true, kind: "team" }, + { + agent_id: "shared-crew", + is_shared: true, + is_owner: false, + kind: "team", + }, + ]; + expect(ownedTeamAgents(agents).map((a) => a.agent_id)).toEqual(["crew"]); + }); +}); diff --git a/dashboard/src/pages/Experts/index.module.less b/dashboard/src/pages/Experts/index.module.less index 32f7a9b2fd..30f4636855 100644 --- a/dashboard/src/pages/Experts/index.module.less +++ b/dashboard/src/pages/Experts/index.module.less @@ -1324,6 +1324,24 @@ flex-wrap: wrap; } +.toolbarSearch { + width: 220px; + min-width: 140px; + max-width: 100%; + + @media (max-width: 640px) { + width: auto; + flex: 1 1 160px; + } +} + +.searchEmpty { + padding: 28px 12px 12px; + text-align: center; + font-size: 13px; + color: var(--fn-text-tertiary); +} + .viewModeLabel { display: inline-flex; align-items: center; @@ -1460,6 +1478,13 @@ margin-bottom: 10px; } +.editExpertTitle { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + /* Match collapse header width with sibling sections (config / skills / …). */ .drawerCollapse { width: 100%; diff --git a/dashboard/src/pages/Experts/index.tsx b/dashboard/src/pages/Experts/index.tsx index 0bf484fda2..ab79f3a577 100644 --- a/dashboard/src/pages/Experts/index.tsx +++ b/dashboard/src/pages/Experts/index.tsx @@ -19,7 +19,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Spin, Tabs, Segmented, Tooltip } from "antd"; +import { Input, Spin, Tabs, Segmented, Tooltip } from "antd"; import { message } from "@/utils/antdMessage"; import { @@ -29,6 +29,7 @@ import { List, Plus, RefreshCw, + Search, Store, Users, } from "lucide-react"; @@ -59,6 +60,7 @@ import { PublishedExpertCard } from "./components/PublishedExpertCard"; import AgentExpertsTable from "./components/AgentExpertsTable"; import ExpertMarketTab from "./components/ExpertMarketTab"; import { OctopEmptyMascot } from "../../components/EmptyState"; +import { pickLocale } from "../../utils/localizedText"; import { isOwnedExpert, ownedExperts } from "../../utils/sharedExpert"; import { apiErrorMessage } from "../../utils/apiError"; import styles from "./index.module.less"; @@ -80,6 +82,14 @@ async function fetchPublishedExperts(): Promise { return publishedExpertsApi.list(); } +function textMatchesQuery( + query: string, + ...parts: Array +): boolean { + if (!query) return true; + return parts.some((part) => (part ?? "").toLowerCase().includes(query)); +} + function installedExpertIdsFromAgents( agents: Pick[], ): Set { @@ -118,6 +128,8 @@ export default function ExpertsPage() { loadViewMode(), ); const [refreshing, setRefreshing] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const searchNeedle = searchQuery.trim().toLowerCase(); const handleRefresh = useCallback(async () => { setRefreshing(true); @@ -213,6 +225,25 @@ export default function ExpertsPage() { () => localAgents.filter((item) => item.kind === "team"), [localAgents], ); + const visibleExpertAgents = useMemo( + () => + expertAgents.filter((agent) => + textMatchesQuery( + searchNeedle, + agent.name, + agent.description, + agent.template_name, + ), + ), + [expertAgents, searchNeedle], + ); + const visibleTeamAgents = useMemo( + () => + teamAgents.filter((agent) => + textMatchesQuery(searchNeedle, agent.name, agent.description), + ), + [searchNeedle, teamAgents], + ); const pickableExperts = useMemo( () => agents.filter( @@ -221,6 +252,10 @@ export default function ExpertsPage() { ), [agents], ); + const localTeamMembers = useMemo( + () => pickableExperts.filter((item) => !item.bridge), + [pickableExperts], + ); const [newAgentId, setNewAgentId] = useState(null); useEffect(() => { @@ -350,6 +385,20 @@ export default function ExpertsPage() { [handleRefresh, refreshing, t], ); + const toolbarSearch = useMemo( + () => ( + } + allowClear + value={searchQuery} + placeholder={t("experts.searchPlaceholder")} + onChange={(event) => setSearchQuery(event.target.value)} + /> + ), + [searchQuery, t], + ); + // ── Render helpers ───────────────────────────────────────────── const myExpertsContent = useMemo(() => { @@ -403,9 +452,10 @@ export default function ExpertsPage() { <>
- {t("experts.totalAgents", { count: expertAgents.length })} + {t("experts.totalAgents", { count: visibleExpertAgents.length })}
+ {toolbarSearch}
- {showCardView ? ( + {visibleExpertAgents.length === 0 ? ( +
{t("experts.searchEmpty")}
+ ) : showCardView ? (
- {expertAgents.map((agent) => ( + {visibleExpertAgents.map((agent) => (
) : ( { void refreshPublishedExperts(); }} onEdit={(id) => - setEditAgent(expertAgents.find((a) => a.agent_id === id) ?? null) + setEditAgent( + visibleExpertAgents.find((a) => a.agent_id === id) ?? null, + ) } onDeleted={handleDeleted} onStateChange={handleStateChange} @@ -504,13 +558,16 @@ export default function ExpertsPage() { publishedByAgentId, refreshButton, refreshPublishedExperts, + searchQuery, showCardView, t, + toolbarSearch, + visibleExpertAgents, ]); const teamsContent = useMemo(() => { if (teamAgents.length === 0) { - const canCreate = pickableExperts.length >= 2; + const canCreate = localTeamMembers.length >= 2; return (
- {t("experts.teams.total", { count: teamAgents.length })} + {t("experts.teams.total", { count: visibleTeamAgents.length })}
+ {toolbarSearch} {refreshButton}
-
- {teamAgents.map((agent) => ( -
- { - const row = teamAgents.find((item) => item.agent_id === id); - if (row) setTeamDrawer({ mode: "edit", team: row }); - }} - onDeleted={handleDeleted} - onStateChange={handleStateChange} - /> -
- ))} -
+ {visibleTeamAgents.length === 0 ? ( +
{t("experts.searchEmpty")}
+ ) : ( +
+ {visibleTeamAgents.map((agent) => ( +
+ { + const row = teamAgents.find((item) => item.agent_id === id); + if (row?.bridge) { + message.info(t("chat.remoteExpert.manageToast")); + return; + } + if (row) setTeamDrawer({ mode: "edit", team: row }); + }} + onDeleted={handleDeleted} + onStateChange={handleStateChange} + /> +
+ ))} +
+ )} ); }, [ handleDeleted, handleStateChange, isMobile, + localTeamMembers, pickableExperts, refreshButton, t, teamAgents, + toolbarSearch, + visibleTeamAgents, ]); + const visiblePublishedExperts = useMemo( + () => + publishedExperts.filter((expert) => + textMatchesQuery( + searchNeedle, + expert.name, + expert.description, + expert.creator_username, + ), + ), + [publishedExperts, searchNeedle], + ); + const visibleLibraryExperts = useMemo( + () => + experts.filter((expert) => + textMatchesQuery( + searchNeedle, + expert.id, + pickLocale(expert.label, lang), + expert.label.zh, + expert.label.en, + pickLocale(expert.description, lang), + expert.description?.zh, + expert.description?.en, + ), + ), + [experts, lang, searchNeedle], + ); + const libraryContent = useMemo(() => { if (expertLoading || publishedExpertLoading) { return ( @@ -621,9 +719,10 @@ export default function ExpertsPage() {
{t("experts.published.listTitle", { - count: publishedExperts.length, + count: visiblePublishedExperts.length, })} +
{toolbarSearch}

{t("experts.published.listHint")}

-
- {publishedExperts.map((expert) => ( - - setCreateSource({ kind: "published", expert: item }) - } - onChanged={refreshPublishedExperts} - /> - ))} -
+ {visiblePublishedExperts.length === 0 ? ( +
+ {t("experts.searchEmpty")} +
+ ) : ( +
+ {visiblePublishedExperts.map((expert) => ( + + setCreateSource({ kind: "published", expert: item }) + } + onChanged={refreshPublishedExperts} + /> + ))} +
+ )} )}
- {t("experts.totalLibrary", { count: experts.length })} + {t("experts.totalLibrary", { count: visibleLibraryExperts.length })} -
{refreshButton}
+
+ {publishedExperts.length === 0 ? toolbarSearch : null} + {refreshButton} +
{publishedExperts.length === 0 && (

)} -

- {experts.map((expert) => ( - - setCreateSource({ kind: "builtin", expert: item }) - } - /> - ))} -
+ {visibleLibraryExperts.length === 0 ? ( +
{t("experts.searchEmpty")}
+ ) : ( +
+ {visibleLibraryExperts.map((expert) => ( + + setCreateSource({ kind: "builtin", expert: item }) + } + /> + ))} +
+ )} ); }, [ agentExpertIds, + canManagePublished, expertLoading, experts, lang, publishedExpertLoading, publishedExperts, refreshButton, + refreshPublishedExperts, t, + toolbarSearch, + visibleLibraryExperts, + visiblePublishedExperts, ]); const marketContent = useMemo( @@ -769,7 +886,7 @@ export default function ExpertsPage() { open={!!teamDrawer} mode={teamDrawer?.mode ?? "create"} team={teamDrawer?.mode === "edit" ? teamDrawer.team : null} - experts={pickableExperts} + experts={localTeamMembers} onClose={() => setTeamDrawer(null)} onSaved={(saved: TeamRecord) => { setTeamDrawer(null); diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx index 32ee2292fd..b32014b92f 100644 --- a/dashboard/src/pages/KnowledgeBases/index.tsx +++ b/dashboard/src/pages/KnowledgeBases/index.tsx @@ -88,6 +88,7 @@ import { useListPanelCollapsed } from "../../hooks/useListPanelCollapsed"; import { useServerTimezone } from "../../hooks/useServerTimezone"; import PageShell from "../../layouts/PageShell"; import { apiErrorMessage, isNotFoundApiError } from "../../utils/apiError"; +import { saveBlobAsFile } from "../../utils/saveBlobAsFile"; import { createDetailRequestGate } from "../../utils/detailRequestGate"; import { getDocKind, type DocKind } from "../../utils/docKind"; import { formatBytes, formatSizeGb } from "../../utils/embeddingDownload"; @@ -1550,12 +1551,7 @@ export default function KnowledgeBasesPage() { documentId, "attachment", ); - const url = URL.createObjectURL(blob); - const a = window.document.createElement("a"); - a.href = url; - a.download = filename || "download"; - a.click(); - URL.revokeObjectURL(url); + await saveBlobAsFile(blob, filename || "download"); } catch (error) { message.error( apiErrorMessage(error, t("knowledgeBases.downloadOriginalFailed"), t), @@ -1563,23 +1559,28 @@ export default function KnowledgeBasesPage() { } }; + // Depend on selected.id (not the whole selected object). Silent indexing + // polls call setSelected(base) every 2.5s with a new object reference; that + // used to recreate fetchBlob and re-run DocumentPreviewCore's load effect, + // aborting slow Office parsers (docx/pptx/xlsx) while fast PDF survived. + const selectedId = selected?.id; const fetchPreviewBlob = useCallback( async ( onProgress?: (loaded: number, total: number) => void, signal?: AbortSignal, ) => { - if (!selected || !previewDocId) { + if (!selectedId || !previewDocId) { throw new Error("missing knowledge document preview target"); } return knowledgeBasesApi.fetchDocumentFile( - selected.id, + selectedId, previewDocId, "inline", onProgress, signal, ); }, - [selected, previewDocId], + [selectedId, previewDocId], ); useEffect(() => { diff --git a/dashboard/src/pages/Login/Login.remember.test.tsx b/dashboard/src/pages/Login/Login.remember.test.tsx new file mode 100644 index 0000000000..43c04a8b7a --- /dev/null +++ b/dashboard/src/pages/Login/Login.remember.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../context/ThemeContext", () => ({ + useTheme: () => ({ isDark: false }), +})); + +vi.mock("../../api/modules/auth", () => ({ + authApi: { + getAuthStatus: () => Promise.resolve({ setup_required: false }), + getOauthStatus: () => Promise.resolve({ providers: [] }), + getCaptcha: () => Promise.resolve({ provider: "none" }), + }, +})); + +vi.mock("../../utils/locale", () => ({ + applyGuestLocale: () => Promise.resolve(), + applyUserLocale: () => Promise.resolve(), +})); + +vi.mock("./CaptchaField", () => ({ + default: () => null, +})); + +import LoginPage from "./index"; + +describe("Login remember-me checkbox", () => { + it("renders checked by default", () => { + render( + + + , + ); + + const checkbox = screen.getByRole("checkbox", { + name: /login\.remember|记住登录状态|Remember me/, + }); + expect(checkbox).toBeChecked(); + expect( + screen.getByPlaceholderText( + /login\.username|用户名或邮箱|username or email/i, + ), + ).toBeInTheDocument(); + expect( + screen.queryByText( + /login\.usernameHint|可用安装时设置|set during setup/i, + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/pages/Login/OidcComplete.tsx b/dashboard/src/pages/Login/OidcComplete.tsx index a134a99128..df94fdfcd4 100644 --- a/dashboard/src/pages/Login/OidcComplete.tsx +++ b/dashboard/src/pages/Login/OidcComplete.tsx @@ -3,7 +3,11 @@ import { Button, Result, Spin } from "antd"; import { message } from "@/utils/antdMessage"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { setAuthToken } from "../../api"; +import { + getRememberLoginPreference, + isSessionOnlyAuth, + setAuthToken, +} from "../../api"; import { authApi } from "../../api/modules/auth"; import { refreshServerLabels } from "../../i18n"; import { apiErrorMessage } from "../../utils/apiError"; @@ -76,11 +80,34 @@ export default function OidcComplete() { void authApi .exchangeOidcCode(code) .then(async (res) => { - setAuthToken(res.access_token); + const dest = safeRedirect(redirect); + if (bind) { + // Account-link popups: opener already holds the session — do not + // overwrite its storage from the popup's empty sessionStorage. + if (notifySsoOpener({ ok: true, redirect: dest, bind: true })) { + return; + } + setAuthToken(res.access_token, !isSessionOnlyAuth()); + await applyUserLocale(res.user.locale); + void refreshServerLabels(res.user.locale); + navigate(dest, { replace: true }); + return; + } + + const remember = getRememberLoginPreference(); + if ( + notifySsoOpener({ + ok: true, + redirect: dest, + access_token: res.access_token, + remember, + }) + ) { + return; + } + setAuthToken(res.access_token, remember); await applyUserLocale(res.user.locale); void refreshServerLabels(res.user.locale); - const dest = safeRedirect(redirect); - if (notifySsoOpener({ ok: true, redirect: dest, bind })) return; navigate(dest, { replace: true }); }) .catch((err) => { diff --git a/dashboard/src/pages/Login/index.tsx b/dashboard/src/pages/Login/index.tsx index baa5e63e97..52d9b6bf07 100644 --- a/dashboard/src/pages/Login/index.tsx +++ b/dashboard/src/pages/Login/index.tsx @@ -1,12 +1,20 @@ import { useState, useEffect, useRef, type ReactNode } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { Input, Button } from "antd"; +import { Input, Button, Checkbox } from "antd"; import { message } from "@/utils/antdMessage"; import { KeyRound, Lock, User } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { clearAuthToken, setAuthToken } from "../../api"; -import { authApi, type OauthProviderStatus } from "../../api/modules/auth"; +import { + clearAuthToken, + setAuthToken, + setRememberLoginPreference, +} from "../../api"; +import { + authApi, + type LdapStatus, + type OauthProviderStatus, +} from "../../api/modules/auth"; import { apiErrorMessage } from "../../utils/apiError"; import { refreshServerLabels } from "../../i18n"; import { applyUserLocale, applyGuestLocale } from "../../utils/locale"; @@ -71,8 +79,10 @@ export default function LoginPage() { const [searchParams] = useSearchParams(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(true); const [loading, setLoading] = useState(false); const [providers, setProviders] = useState([]); + const [ldap, setLdap] = useState(null); const [ssoLoadingKind, setSsoLoadingKind] = useState(null); const [captchaReady, setCaptchaReady] = useState(false); const [captchaResetKey, setCaptchaResetKey] = useState(0); @@ -106,6 +116,12 @@ export default function LoginPage() { } }) .catch(() => {}); + authApi + .getLdapStatus() + .then((next) => { + if (!cancelled) setLdap(next); + }) + .catch(() => {}); authApi .getCaptcha() .then((next) => { @@ -154,6 +170,9 @@ export default function LoginPage() { } return; } + if (event.data.access_token) { + setAuthToken(event.data.access_token, event.data.remember ?? true); + } window.location.replace(event.data.redirect || "/chat"); }; window.addEventListener("message", onMessage); @@ -165,18 +184,9 @@ export default function LoginPage() { setCaptchaResetKey((k) => k + 1); }; - // FE-15: the 400ms SSO popup poller must not outlive the login page. - const ssoPollRef = useRef(null); - const ssoPopupRef = useRef(null); - useEffect(() => { - return () => { - if (ssoPollRef.current !== null) window.clearInterval(ssoPollRef.current); - ssoPopupRef.current?.close(); - }; - }, []); - const onSso = async (kind: string) => { setSsoLoadingKind(kind); + setRememberLoginPreference(remember); let popup: Window | null = null; if (kind !== "oidc") { popup = openSsoPopup(); @@ -189,12 +199,9 @@ export default function LoginPage() { } if (popup && !popup.closed) { popup.location.href = authorization_url; - ssoPopupRef.current = popup; - ssoPollRef.current = window.setInterval(() => { + const timer = window.setInterval(() => { if (!popup || popup.closed) { - if (ssoPollRef.current !== null) - window.clearInterval(ssoPollRef.current); - ssoPollRef.current = null; + window.clearInterval(timer); setSsoLoadingKind((current) => (current === kind ? null : current)); } }, 400); @@ -216,7 +223,7 @@ export default function LoginPage() { try { const token = await captchaRef.current?.getToken(); const res = await authApi.login(username, password, token); - setAuthToken(res.access_token); + setAuthToken(res.access_token, remember); await applyUserLocale(res.user.locale); void refreshServerLabels(res.user.locale); navigate("/chat", { replace: true }); @@ -293,8 +300,6 @@ export default function LoginPage() { } placeholder={t("login.username")} - aria-label={t("login.username")} - autoComplete="username" size="large" value={username} onChange={(e) => setUsername(e.target.value)} @@ -307,8 +312,6 @@ export default function LoginPage() { } placeholder={t("login.password")} - aria-label={t("login.password")} - autoComplete="current-password" size="large" value={password} onChange={(e) => setPassword(e.target.value)} @@ -316,6 +319,24 @@ export default function LoginPage() { style={{ borderRadius: 10 }} /> + {ldap?.enabled && ( +

+ {t("login.ldapHint", { + name: ldap.display_name.trim() || t("adminSso.ldap.kind"), + })} +

+ )} + - + setRemember(e.target.checked)} + style={{ + margin: 0, + fontSize: 13, + color: "var(--fn-text-tertiary)", + }} + > + {t("login.remember")} + + +
setShowForgotHelp(false)} diff --git a/dashboard/src/pages/PwaDebug/index.tsx b/dashboard/src/pages/PwaDebug/index.tsx index 44b69455a2..dad44125fb 100644 --- a/dashboard/src/pages/PwaDebug/index.tsx +++ b/dashboard/src/pages/PwaDebug/index.tsx @@ -495,9 +495,15 @@ export default function PwaDebugPage() { return (
diff --git a/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less new file mode 100644 index 0000000000..51a219fe0f --- /dev/null +++ b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.module.less @@ -0,0 +1,534 @@ +.loading { + display: flex; + justify-content: center; + padding: 48px 0; +} + +.pageToolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + min-height: 32px; + min-width: 0; + max-width: 100%; +} + +.pageToolbarActions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-left: auto; + min-width: 0; + max-width: 100%; +} + +/* ── Toolbar (storage-style) ───────────────────────────────────── */ + +.gridToolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0 0; +} + +.gridCount { + font-size: 13px; + color: var(--fn-text-tertiary); +} + +.viewModeLabel { + display: inline-flex; + align-items: center; + gap: 4px; +} + +/* ── Card grid (mirrors Admin Storage) ─────────────────────────── */ + +.cardGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + row-gap: 16px; + column-gap: 12px; + padding: 16px 0 24px; + width: 100%; + min-width: 0; + + /* `1fr` is minmax(auto, 1fr): a long peer URL or the action row keeps + the track wider than the viewport, and PageShell clips overflow-x + on mobile so the card's right edge (status + buttons) disappears. */ + @media (max-width: 767px) { + grid-template-columns: minmax(0, 1fr); + gap: 12px; + } +} + +.backendCard { + background: var(--fn-bg-elevated, var(--fn-bg-secondary, #fff)); + border: 1px solid var(--fn-border-primary, #e5e7eb); + border-radius: var(--fn-radius-lg, 12px); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; + color: var(--fn-text-primary, #111827); + transition: border-color 0.2s; + + &:hover { + border-color: var(--catalog-accent, var(--fn-color-brand)); + } +} + +.backendCardHeader { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.backendCardIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--fn-text-secondary); + background: none; +} + +.backendCardTitle { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +} + +.backendCardName { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + font-size: 18px; + font-weight: 700; + line-height: 1.35; + color: var(--fn-text-primary); +} + +.backendCardNameText { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.backendCardInfo { + font-size: 12px; + color: var(--fn-text-secondary); + line-height: 22px; + display: flex; + flex-direction: column; + gap: 0; + flex: 1; + min-width: 0; + min-height: 0; +} + +.infoRow { + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; + max-width: 100%; +} + +.infoLabel { + color: var(--fn-text-tertiary); + min-width: 68px; + flex-shrink: 0; + font-size: 11px; +} + +.infoValue { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.backendCardNote { + font-size: 11px; + color: var(--fn-text-tertiary); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.backendCardActions { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + border-top: 1px solid var(--fn-border-secondary, #e5e7eb); + padding-top: 10px; + margin-top: auto; + gap: 8px; + min-width: 0; + max-width: 100%; +} + +.backendActionsLeft { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.backendActionsRight { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 2px; + margin-left: auto; + max-width: 100%; +} + +.autoReconnectInline { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--fn-text-secondary); + cursor: default; +} + +.inboundLocked { + display: inline-flex; + align-items: center; + cursor: not-allowed; + + > * { + pointer-events: none; + } +} + +.statusBadgeOk, +.statusBadgeWarn { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 500; + flex-shrink: 0; + white-space: nowrap; +} + +.statusBadgeOk { + color: #52c41a; +} + +.statusBadgeWarn { + color: #8c8c8c; + padding: 2px 7px; + border-radius: 10px; + background: var(--fn-bg-secondary, #f7f8fa); + border: 1px solid var(--fn-border-secondary, #e5e7eb); +} + +.meta { + margin: 8px 0 0; + color: var(--fn-text-secondary, #6b7280); + font-size: 13px; + word-break: break-word; +} + +.metaError { + margin: 6px 0 0; + color: var(--fn-color-danger, #ef4444); + font-size: 12px; + line-height: 1.45; + word-break: break-word; +} + +/* ── Table view ────────────────────────────────────────────────── */ + +.tableWrap { + margin-top: 12px; + overflow-x: auto; +} + +.tableNameCell { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.tableNameIcon { + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--fn-color-brand); + background: color-mix(in srgb, var(--fn-color-brand) 10%, transparent); +} + +.tableNameText { + display: flex; + flex-direction: column; + min-width: 0; + gap: 2px; + + strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.tableNameNote { + font-size: 12px; + color: var(--fn-text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tableActions { + display: flex; + flex-wrap: wrap; + gap: 0; + align-items: center; +} + +/* ── Probe / agent lists ───────────────────────────────────────── */ + +.probeList { + list-style: none; + margin: 4px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.probeItem, +.connectedAgentBtn { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + text-align: left; + border: 1px solid var(--fn-border-primary, #e5e7eb); + background: var(--fn-bg-secondary, #f7f8fa); + border-radius: 10px; + padding: 10px 12px; + color: var(--fn-text-primary, #111827); +} + +.probeAvatar { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + line-height: 0; + background: transparent; +} + +.probeAvatarImg { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + display: block; +} + +.probeMoreBtn { + display: block; + width: 100%; + margin-top: 4px; + padding-inline: 0; + font-size: 13px; +} + +.connectedAgentBtn { + cursor: pointer; + font: inherit; +} + +.connectedAgentBtn:hover { + border-color: var(--catalog-accent, var(--fn-color-brand, #e85d75)); + background: color-mix( + in srgb, + var(--catalog-accent, var(--fn-color-brand, #e85d75)) 6%, + var(--fn-bg-secondary, #f7f8fa) + ); +} + +.probeBody { + min-width: 0; + flex: 1; +} + +.probeNameRow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.probeName { + font-weight: 600; + font-size: 14px; + line-height: 1.3; + color: var(--fn-text-primary, #111827); +} + +.probeDesc { + margin-top: 4px; + color: var(--fn-text-secondary, #6b7280); + font-size: 12px; + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.probeEmpty { + margin: 8px 0 0; + color: var(--fn-text-secondary, #6b7280); + font-size: 13px; +} + +.probePanel { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--fn-border-primary, #e5e7eb); +} + +.probePanelHead { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary, #111827); +} + +.drawerHint { + margin: 0 0 16px; + color: var(--fn-text-secondary, #6b7280); + font-size: 13px; + line-height: 1.55; +} + +.createDrawer { + :global(.ant-drawer-header) { + border-bottom: 1px solid var(--fn-border-primary); + } + + :global(.ant-drawer-footer) { + border-top: 1px solid var(--fn-border-primary); + padding: 12px 16px; + } +} + +.createForm { + :global(.ant-form-item) { + margin-bottom: 16px; + } + + :global(.ant-form-item-label > label) { + color: var(--fn-text-secondary); + font-weight: 500; + } + + :global(.ant-form-item-extra) { + margin-top: 6px; + font-size: 12px; + line-height: 1.45; + color: var(--fn-text-tertiary); + } + + :global(.ant-input-prefix) { + margin-inline-end: 8px; + } +} + +.createSection { + margin-bottom: 8px; + + & + & { + margin-top: 8px; + padding-top: 16px; + border-top: 1px solid var(--fn-border-primary); + } +} + +.createSectionTitle { + margin-bottom: 12px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--fn-text-tertiary); + text-transform: uppercase; +} + +.iconPicker { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.iconOption { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid var(--fn-border-primary); + background: var(--fn-bg-elevated, var(--fn-bg-primary)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--fn-text-secondary); + transition: + border-color 0.15s, + background 0.15s, + color 0.15s; +} + +.iconOption:hover { + border-color: var(--fn-color-brand); + color: var(--fn-color-brand); +} + +.iconOptionActive { + border-color: var(--fn-color-brand); + background: color-mix(in srgb, var(--fn-color-brand) 12%, transparent); + color: var(--fn-color-brand); +} + +.drawerFooter { + display: flex; + justify-content: flex-end; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} diff --git a/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx new file mode 100644 index 0000000000..0cc2af037e --- /dev/null +++ b/dashboard/src/pages/Settings/AdvancedSettings/BridgeSettings.tsx @@ -0,0 +1,1495 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type CSSProperties, + type ReactNode, +} from "react"; +import { + Button, + Drawer, + Form, + Input, + Popconfirm, + Segmented, + Spin, + Switch, + Table, + Tag, + Tooltip, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { message } from "@/utils/antdMessage"; +import { + Activity, + CheckCircle, + Globe, + LayoutGrid, + List, + Lock, + Cloudy, + Plus, + Pencil, + RefreshCw, + Share2, + Tag as TagIcon, + Trash2, + Unplug, + User, + AlertCircle, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import { EmptyState } from "../../../components/EmptyState"; +import { useAgent } from "../../../context/AgentContext"; +import { useCardTableView } from "../../../hooks/useCardTableView"; +import { apiErrorMessage } from "../../../utils/apiError"; +import { ExpertIcon, iconForName } from "../../Experts/components/iconForName"; +import { + bridgeApi, + type BridgeConnection, + type BridgeProbeAgent, + type BridgeProbeResult, + type BridgeRemoteAgent, +} from "../../../api/modules/bridge"; +import { TabPanelHeader } from "./TabPanelHeader"; +import styles from "./BridgeSettings.module.less"; + +const BRIDGE_ACCENT = "var(--fn-color-brand)"; +const DEFAULT_BRIDGE_ICON = "cloudy"; +const MAX_BRIDGE_CONNECTIONS = 10; +const BRIDGE_ICON_OPTIONS = [ + "cloudy", + "cloud", + "cloud-cog", + "globe", + "home", + "laptop", + "monitor", + "server", + "radio-tower", + "wifi", + "satellite", + "map-pin", +] as const; + +const FIELD_ICON = { + size: 16 as const, + style: { color: "var(--fn-text-tertiary)" }, +}; + +function BridgeIconPicker({ + value, + onChange, +}: { + value?: string; + onChange?: (value?: string) => void; +}) { + const { t } = useTranslation(); + const current = (value || DEFAULT_BRIDGE_ICON).trim() || DEFAULT_BRIDGE_ICON; + return ( +
+ {BRIDGE_ICON_OPTIONS.map((name) => { + const selected = current === name; + return ( + + ); + })} +
+ ); +} + +function statusColor(status: string): string { + if (status === "connected") return "success"; + if (status === "connecting" || status === "error") return "warning"; + return "default"; +} + +function isInboundConnection(row: BridgeConnection): boolean { + return Boolean(row.inbound) || !row.has_password; +} + +function connectionStatusText( + row: BridgeConnection, + t: (key: string) => string, +): string { + if (isInboundConnection(row) && row.status !== "connected") { + return t("advancedSettings.bridge.status.offline"); + } + return t(`advancedSettings.bridge.status.${row.status}`); +} + +function InboundLockedControl({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + return ( + + {children} + + ); +} + +const CARD_AGENT_PREVIEW_LIMIT = 4; + +function AgentAvatar({ + name, + iconUrl, + iconName, + color, +}: { + name: string; + iconUrl?: string | null; + iconName?: string | null; + color?: string | null; +}) { + return ( + + + + ); +} + +function KindTag({ kind }: { kind?: string | null }) { + const { t } = useTranslation(); + const isTeam = kind === "team"; + return ( + + {isTeam + ? t("advancedSettings.bridge.kindTeam") + : t("advancedSettings.bridge.kindExpert")} + + ); +} + +function AgentListItem({ + agentId, + name, + description, + iconUrl, + iconName, + color, + kind, + onClick, +}: { + agentId: string; + name: string; + description?: string | null; + iconUrl?: string | null; + iconName?: string | null; + color?: string | null; + kind?: string | null; + onClick?: () => void; +}) { + const body = ( + <> + +
+
+
{name}
+ +
+ {description?.trim() ? ( +
{description}
+ ) : null} +
+ + ); + if (onClick) { + return ( +
  • + +
  • + ); + } + return
  • {body}
  • ; +} + +function AgentListToggle({ + total, + expanded, + onToggle, +}: { + total: number; + expanded: boolean; + onToggle: () => void; +}) { + const { t } = useTranslation(); + const hidden = total - CARD_AGENT_PREVIEW_LIMIT; + if (hidden <= 0) return null; + return ( + + ); +} + +function ProbeAgentList({ agents }: { agents: BridgeProbeAgent[] }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + if (agents.length === 0) { + return ( +

    + {t("advancedSettings.bridge.noAgents")} +

    + ); + } + const visible = + expanded || agents.length <= CARD_AGENT_PREVIEW_LIMIT + ? agents + : agents.slice(0, CARD_AGENT_PREVIEW_LIMIT); + return ( + <> +
      + {visible.map((agent) => ( + + ))} +
    + setExpanded((open) => !open)} + /> + + ); +} + +function RemoteAgentsBlock({ + row, + agents, + onOpen, +}: { + row: BridgeConnection; + agents: BridgeRemoteAgent[]; + onOpen: (agentId: string) => void; +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + if (agents.length > 0) { + const visible = + expanded || agents.length <= CARD_AGENT_PREVIEW_LIMIT + ? agents + : agents.slice(0, CARD_AGENT_PREVIEW_LIMIT); + return ( + <> +
      + {visible.map((agent) => ( + onOpen(agent.id)} + /> + ))} +
    + setExpanded((open) => !open)} + /> + + ); + } + if (row.status === "connected") { + return ( +

    {t("advancedSettings.bridge.noAgents")}

    + ); + } + return null; +} + +interface ConnectionActions { + onConnect: (row: BridgeConnection) => Promise; + onDisconnect: (row: BridgeConnection) => Promise; + onDelete: (row: BridgeConnection) => Promise; + onEdit: (row: BridgeConnection) => void; + onToggleAutoReconnect: ( + row: BridgeConnection, + enabled: boolean, + ) => Promise; + onOpenAgent: (agentId: string) => void; +} + +function BridgeDisconnectButton({ + row, + connected, + link, + onDisconnect, + onConnect, +}: { + row: BridgeConnection; + connected: boolean; + link?: boolean; + onDisconnect: (row: BridgeConnection) => Promise; + onConnect: (row: BridgeConnection) => Promise; +}) { + const { t } = useTranslation(); + const inbound = isInboundConnection(row); + const buttonType = link ? "link" : "text"; + if (inbound) { + return ( + + + + ); + } + if (!connected) { + return ( + + ); + } + return ( + void onDisconnect(row)} + > + + + ); +} + +function BridgeDeleteButton({ + row, + connected, + link, + onDelete, +}: { + row: BridgeConnection; + connected: boolean; + link?: boolean; + onDelete: (row: BridgeConnection) => Promise; +}) { + const { t } = useTranslation(); + const inbound = isInboundConnection(row); + const locked = inbound && connected; + const button = ( + + ); + if (locked) { + return {button}; + } + return ( + void onDelete(row)} + > + {button} + + ); +} + +function AutoReconnectControl({ + row, + labeled, + onToggle, +}: { + row: BridgeConnection; + labeled?: boolean; + onToggle: (row: BridgeConnection, enabled: boolean) => Promise; +}) { + const { t } = useTranslation(); + const inbound = isInboundConnection(row); + const control = ( + + void onToggle(row, checked)} + /> + {labeled ? ( + {t("advancedSettings.bridge.autoReconnect")} + ) : null} + + ); + if (inbound) { + return {control}; + } + return ( + + {control} + + ); +} + +function BridgeConnectionCard({ + row, + agents, + actions, +}: { + row: BridgeConnection; + agents: BridgeRemoteAgent[]; + actions: ConnectionActions; +}) { + const { t } = useTranslation(); + const connected = row.status === "connected"; + return ( +
    +
    +
    +
    + {iconForName(row.icon_name || DEFAULT_BRIDGE_ICON, 18)} +
    +
    + + {row.display_name} + + {isInboundConnection(row) ? ( + + {t("advancedSettings.bridge.inboundTag")} + + ) : null} +
    +
    +
    + {connected ? : } + {connectionStatusText(row, t)} +
    +
    + +
    + {isInboundConnection(row) ? ( +

    + {connected + ? t("advancedSettings.bridge.inboundHint") + : t("advancedSettings.bridge.inboundOfflineHint")} +

    + ) : ( + <> +
    + + {t("advancedSettings.bridge.peerUrl")}: + + + {row.peer_base_url} + +
    +
    + + {t("advancedSettings.bridge.username")}: + + {row.peer_username} +
    + + )} + {row.notes ? ( +
    {row.notes}
    + ) : null} + {row.last_error ? ( +

    {row.last_error}

    + ) : null} +
    + +
    +
    + {isInboundConnection(row) ? null : ( + + )} +
    +
    + + + +
    +
    + + +
    + ); +} + +export default function BridgeSettingsPanel({ + asPage = false, +}: { + asPage?: boolean; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { refresh: refreshAgents } = useAgent(); + const { viewMode, setViewMode, showCardView } = useCardTableView("card"); + const [loading, setLoading] = useState(true); + const [rows, setRows] = useState([]); + const [agentsByConn, setAgentsByConn] = useState< + Record + >({}); + const [createOpen, setCreateOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [probing, setProbing] = useState(false); + const [probeResult, setProbeResult] = useState( + null, + ); + const [form] = Form.useForm(); + const [editOpen, setEditOpen] = useState(false); + const [editing, setEditing] = useState(false); + const [editProbing, setEditProbing] = useState(false); + const [editProbeResult, setEditProbeResult] = + useState(null); + const [editTarget, setEditTarget] = useState(null); + const [editForm] = Form.useForm(); + + const closeCreateDrawer = () => { + setCreateOpen(false); + setProbeResult(null); + form.resetFields(); + }; + + const openEditDrawer = useCallback( + (row: BridgeConnection) => { + setEditTarget(row); + setEditProbeResult(null); + editForm.setFieldsValue({ + display_name: row.display_name, + notes: row.notes ?? "", + icon_name: row.icon_name || DEFAULT_BRIDGE_ICON, + peer_base_url: row.peer_base_url, + peer_username: row.peer_username, + password: "", + }); + setEditOpen(true); + }, + [editForm], + ); + + const closeEditDrawer = () => { + setEditOpen(false); + setEditTarget(null); + setEditProbeResult(null); + editForm.resetFields(); + }; + + const reload = useCallback( + async (opts?: { silent?: boolean }) => { + const silent = Boolean(opts?.silent); + if (!silent) setLoading(true); + try { + const list = await bridgeApi.list(); + setRows(list); + const next: Record = {}; + await Promise.all( + list + .filter((c) => c.status === "connected") + .map(async (c) => { + try { + next[c.connection_id] = await bridgeApi.listAgents( + c.connection_id, + ); + } catch { + next[c.connection_id] = []; + } + }), + ); + setAgentsByConn(next); + } catch (err) { + if (!silent) { + message.error( + apiErrorMessage(err, t("advancedSettings.bridge.loadFailed"), t), + ); + } + } finally { + if (!silent) setLoading(false); + } + }, + [t], + ); + + useEffect(() => { + void reload(); + const timer = window.setInterval(() => { + void reload({ silent: true }); + }, 5000); + return () => window.clearInterval(timer); + }, [reload]); + + const onSaveEdit = async () => { + if (!editTarget) return; + try { + const inbound = isInboundConnection(editTarget); + const values = inbound + ? await editForm.validateFields(["display_name", "notes", "icon_name"]) + : await editForm.validateFields(); + setEditing(true); + const password = String(values.password || "").trim(); + await bridgeApi.patch( + editTarget.connection_id, + inbound + ? { + display_name: values.display_name, + notes: values.notes ?? "", + icon_name: values.icon_name || DEFAULT_BRIDGE_ICON, + } + : { + display_name: values.display_name, + notes: values.notes ?? "", + icon_name: values.icon_name || DEFAULT_BRIDGE_ICON, + peer_base_url: values.peer_base_url, + peer_username: values.peer_username, + ...(password ? { password } : {}), + }, + ); + message.success(t("advancedSettings.bridge.editSaved")); + closeEditDrawer(); + await reload(); + await refreshAgents({ silent: true, force: true }); + } catch (err) { + if (err && typeof err === "object" && "errorFields" in err) return; + message.error( + apiErrorMessage(err, t("advancedSettings.bridge.editFailed"), t), + ); + } finally { + setEditing(false); + } + }; + + const onEditProbe = async () => { + if (!editTarget) return; + try { + const values = await editForm.validateFields([ + "peer_base_url", + "peer_username", + ]); + const password = String(editForm.getFieldValue("password") || "").trim(); + if (!password && !editTarget.has_password) { + message.warning(t("advancedSettings.bridge.passwordRequired")); + return; + } + setEditProbing(true); + const result = await bridgeApi.probeConnection(editTarget.connection_id, { + peer_base_url: values.peer_base_url, + peer_username: values.peer_username, + ...(password ? { password } : {}), + }); + setEditProbeResult(result); + message.success( + t("advancedSettings.bridge.probeOk", { count: result.agent_count }), + ); + } catch (err) { + if (err && typeof err === "object" && "errorFields" in err) return; + setEditProbeResult(null); + message.error( + apiErrorMessage(err, t("advancedSettings.bridge.probeFailed"), t), + ); + } finally { + setEditProbing(false); + } + }; + + const onCreate = async () => { + try { + const values = await form.validateFields(); + setCreating(true); + await bridgeApi.create({ + peer_base_url: values.peer_base_url, + peer_username: values.peer_username, + password: values.password, + display_name: values.display_name, + notes: values.notes || undefined, + icon_name: values.icon_name || DEFAULT_BRIDGE_ICON, + connect: true, + }); + message.success(t("advancedSettings.bridge.created")); + closeCreateDrawer(); + await reload(); + await refreshAgents({ silent: true, force: true }); + } catch (err) { + if (err && typeof err === "object" && "errorFields" in err) return; + message.error( + apiErrorMessage(err, t("advancedSettings.bridge.createFailed"), t), + ); + } finally { + setCreating(false); + } + }; + + const onProbe = async () => { + try { + const values = await form.validateFields([ + "peer_base_url", + "peer_username", + "password", + ]); + setProbing(true); + const result = await bridgeApi.probe({ + peer_base_url: values.peer_base_url, + peer_username: values.peer_username, + password: values.password, + }); + setProbeResult(result); + message.success( + t("advancedSettings.bridge.probeOk", { count: result.agent_count }), + ); + } catch (err) { + if (err && typeof err === "object" && "errorFields" in err) return; + setProbeResult(null); + message.error( + apiErrorMessage(err, t("advancedSettings.bridge.probeFailed"), t), + ); + } finally { + setProbing(false); + } + }; + + const actions = useMemo( + () => ({ + onConnect: async (row) => { + try { + await bridgeApi.connect(row.connection_id); + await reload(); + await refreshAgents({ silent: true, force: true }); + } catch (err) { + message.error( + apiErrorMessage(err, t("advancedSettings.bridge.createFailed"), t), + ); + } + }, + onDisconnect: async (row) => { + try { + await bridgeApi.disconnect(row.connection_id); + await reload(); + await refreshAgents({ silent: true, force: true }); + } catch (err) { + message.error( + apiErrorMessage(err, t("advancedSettings.bridge.loadFailed"), t), + ); + } + }, + onDelete: async (row) => { + try { + await bridgeApi.remove(row.connection_id); + message.success(t("advancedSettings.bridge.deleted")); + await reload(); + await refreshAgents({ silent: true, force: true }); + } catch (err) { + message.error( + apiErrorMessage(err, t("advancedSettings.bridge.deleteFailed"), t), + ); + } + }, + onEdit: (row) => { + openEditDrawer(row); + }, + onToggleAutoReconnect: async (row, enabled) => { + try { + await bridgeApi.patch(row.connection_id, { + auto_reconnect: enabled, + }); + await reload(); + await refreshAgents({ silent: true, force: true }); + } catch (err) { + message.error( + apiErrorMessage(err, t("advancedSettings.bridge.loadFailed"), t), + ); + } + }, + onOpenAgent: (agentId) => { + navigate(`/chat/${encodeURIComponent(agentId)}`); + }, + }), + [navigate, openEditDrawer, refreshAgents, reload, t], + ); + + const columns = useMemo>( + () => [ + { + title: t("advancedSettings.bridge.displayName"), + dataIndex: "display_name", + key: "display_name", + ellipsis: true, + render: (name: string, row) => ( +
    + + {iconForName(row.icon_name || DEFAULT_BRIDGE_ICON, 14)} + +
    + {name} + {isInboundConnection(row) ? ( + + {t("advancedSettings.bridge.inboundTag")} + + ) : null} + {row.notes ? ( + {row.notes} + ) : null} +
    +
    + ), + }, + { + title: t("advancedSettings.bridge.peerUrl"), + dataIndex: "peer_base_url", + key: "peer_base_url", + ellipsis: true, + render: (url: string, row) => (isInboundConnection(row) ? "—" : url), + }, + { + title: t("advancedSettings.bridge.username"), + dataIndex: "peer_username", + key: "peer_username", + width: 120, + ellipsis: true, + }, + { + title: t("advancedSettings.bridge.colStatus"), + dataIndex: "status", + key: "status", + width: 110, + render: (_status: string, row) => ( + + {connectionStatusText(row, t)} + + ), + }, + { + title: t("advancedSettings.bridge.autoReconnect"), + dataIndex: "auto_reconnect", + key: "auto_reconnect", + width: 110, + render: (_value: boolean | undefined, row) => + isInboundConnection(row) ? ( + "—" + ) : ( + + ), + }, + { + title: t("advancedSettings.bridge.colAgents"), + key: "agents", + width: 90, + render: (_: unknown, row) => { + const n = (agentsByConn[row.connection_id] || []).length; + return row.status === "connected" ? n : "—"; + }, + }, + { + title: t("common.actions"), + key: "actions", + width: 240, + render: (_: unknown, row) => ( +
    + + + +
    + ), + }, + ], + [actions, agentsByConn, t], + ); + + const viewToggle = ( + setViewMode(v as "table" | "card")} + options={[ + { + value: "card", + label: ( + + + {t("advancedSettings.bridge.viewCard")} + + ), + }, + { + value: "table", + label: ( + + + {t("advancedSettings.bridge.viewTable")} + + ), + }, + ]} + /> + ); + + const atConnectionLimit = rows.length >= MAX_BRIDGE_CONNECTIONS; + const headerActions = ( + <> + {rows.length > 0 ? viewToggle : null} + + + + + + ); + + return ( + <> + {asPage ? ( +
    + + {!loading && rows.length > 0 + ? t("advancedSettings.bridge.totalConnections", { + count: rows.length, + }) + : null} + +
    {headerActions}
    +
    + ) : ( + } + title={t("advancedSettings.bridge.title")} + description={t("advancedSettings.bridge.description")} + actions={headerActions} + /> + )} + + {loading ? ( +
    + +
    + ) : rows.length === 0 ? ( + + ) : ( + <> + {!asPage ? ( +
    + + {t("advancedSettings.bridge.totalConnections", { + count: rows.length, + })} + +
    + ) : null} + {showCardView ? ( +
    + {rows.map((row) => ( + + ))} +
    + ) : ( +
    + + rowKey="connection_id" + size="middle" + pagination={false} + columns={columns} + dataSource={rows} + scroll={{ x: 900 }} + expandable={{ + expandedRowRender: (row) => ( + + ), + rowExpandable: (row) => + row.status === "connected" || + (agentsByConn[row.connection_id] || []).length > 0, + }} + /> +
    + )} + + )} + + + + + +
    + } + > +

    + {t("advancedSettings.bridge.drawerHint")} +

    +

    + {t("advancedSettings.bridge.directionHint")} +

    +

    + {t("advancedSettings.bridge.limitHint", { + limit: MAX_BRIDGE_CONNECTIONS, + })} +

    +
    +
    +
    + {t("advancedSettings.bridge.sectionIdentity")} +
    + + } + placeholder={t( + "advancedSettings.bridge.displayNamePlaceholder", + )} + maxLength={64} + autoFocus + /> + + + + + + + +
    + +
    +
    + {t("advancedSettings.bridge.sectionRemote")} +
    + + } + placeholder={t("advancedSettings.bridge.peerUrlPlaceholder")} + autoComplete="url" + /> + + + } + placeholder={t("advancedSettings.bridge.username")} + autoComplete="username" + /> + + + } + placeholder={t("advancedSettings.bridge.password")} + autoComplete="current-password" + /> + +
    +
    + + {probeResult ? ( +
    +
    + + {t("advancedSettings.bridge.probeResultTitle", { + name: probeResult.peer_display_name, + count: probeResult.agent_count, + })} +
    + +
    + ) : null} + + + + + {editTarget && !isInboundConnection(editTarget) ? ( + + ) : null} + +
    + } + > +

    + {t( + editTarget && isInboundConnection(editTarget) + ? "advancedSettings.bridge.editHintInbound" + : "advancedSettings.bridge.editHint", + )} +

    +
    +
    +
    + {t("advancedSettings.bridge.sectionIdentity")} +
    + + } + placeholder={t( + "advancedSettings.bridge.displayNamePlaceholder", + )} + maxLength={64} + autoFocus + /> + + + + + + + +
    + + {editTarget && !isInboundConnection(editTarget) ? ( +
    +
    + {t("advancedSettings.bridge.sectionRemote")} +
    + + } + placeholder={t("advancedSettings.bridge.peerUrlPlaceholder")} + autoComplete="url" + /> + + + } + placeholder={t("advancedSettings.bridge.username")} + autoComplete="username" + /> + + + } + placeholder={t( + "advancedSettings.bridge.passwordKeepPlaceholder", + )} + autoComplete="new-password" + /> + +
    + ) : null} +
    + + {editProbeResult ? ( +
    +
    + + {t("advancedSettings.bridge.probeResultTitle", { + name: editProbeResult.peer_display_name, + count: editProbeResult.agent_count, + })} +
    + +
    + ) : null} + + + ); +} diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less index d894aa84ad..4668edde92 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less +++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less @@ -348,6 +348,31 @@ code { opacity: 0.75; } +.mirrorErrors { + margin-top: 8px; +} + +.mirrorErrorsTitle { + margin: 0 0 4px; + font-size: 12px; + font-weight: 600; + opacity: 0.9; +} + +.mirrorErrorsList { + margin: 0; + padding-left: 18px; + font-size: 12px; + line-height: 1.45; + opacity: 0.85; + word-break: break-word; + + code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + } +} + .restartSubHint { margin-top: 4px; font-size: 12px; diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx index bd49741106..a03eccebab 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx @@ -546,6 +546,21 @@ export default function UpdateConfig() { {t("advancedSettings.update.upgradeFailed")}:{" "} {progress.error}

    + {progress.mirror_errors && + progress.mirror_errors.length > 0 && ( +
    +

    + {t("advancedSettings.update.mirrorErrorsTitle")} +

    +
      + {progress.mirror_errors.map((err) => ( +
    • + {err} +
    • + ))} +
    +
    + )}

    {t("advancedSettings.update.manualUpgradeHint")}

    diff --git a/dashboard/src/pages/Settings/AdvancedSettings/index.tsx b/dashboard/src/pages/Settings/AdvancedSettings/index.tsx index 77186f7c09..6cf906a14d 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/index.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/index.tsx @@ -65,6 +65,9 @@ export default function AdvancedSettingsPage() { if (moved === "voice" || moved === "search") { return ; } + if (moved === "bridge") { + return ; + } if (forbidden) return ; @@ -93,7 +96,7 @@ export default function AdvancedSettingsPage() { } > -
    {renderTab()}
    +
    {renderTab()}
    ); } diff --git a/dashboard/src/pages/Settings/AdvancedSettings/tabContent.module.less b/dashboard/src/pages/Settings/AdvancedSettings/tabContent.module.less index 7979febf1c..da0d561d1d 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/tabContent.module.less +++ b/dashboard/src/pages/Settings/AdvancedSettings/tabContent.module.less @@ -45,7 +45,10 @@ } .panelHeaderActions { + display: flex; flex-shrink: 0; + align-items: center; + gap: 10px; } .panelHeader { diff --git a/dashboard/src/pages/Settings/BackupRestore/index.tsx b/dashboard/src/pages/Settings/BackupRestore/index.tsx index d9d5eb7cff..526c18359f 100644 --- a/dashboard/src/pages/Settings/BackupRestore/index.tsx +++ b/dashboard/src/pages/Settings/BackupRestore/index.tsx @@ -40,6 +40,7 @@ import { useServiceRestartContext } from "../../../context/ServiceRestartContext import { useIsMobile } from "../../../hooks/useIsMobile"; import { useServerTimezone } from "../../../hooks/useServerTimezone"; import { apiErrorMessage } from "../../../utils/apiError"; +import { saveBlobAsFile } from "../../../utils/saveBlobAsFile"; import { formatServerIsoDateTime } from "../../../utils/formatMessageTime"; import { TabPanelHeader } from "../AdvancedSettings/TabPanelHeader"; import styles from "./index.module.less"; @@ -55,11 +56,7 @@ const PRESET_SCHEDULES = new Set([ ]); function triggerDownload(blob: Blob, filename: string) { - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = filename; - a.click(); - URL.revokeObjectURL(a.href); + return saveBlobAsFile(blob, filename); } function BackupContentsTags({ row }: { row: BackupFileItem }) { @@ -341,7 +338,7 @@ export default function BackupRestorePanel() { setDownloading(row.name); try { const blob = await backupApi.downloadBackup(row.name); - triggerDownload(blob, row.name); + await triggerDownload(blob, row.name); } catch (err: unknown) { const detail = err instanceof Error ? err.message : String(err); message.error(detail || t("backup.exportFailed")); diff --git a/dashboard/src/pages/Settings/Bridge/index.tsx b/dashboard/src/pages/Settings/Bridge/index.tsx new file mode 100644 index 0000000000..506bd3f5bb --- /dev/null +++ b/dashboard/src/pages/Settings/Bridge/index.tsx @@ -0,0 +1,16 @@ +import { useTranslation } from "react-i18next"; +import PageShell from "../../../layouts/PageShell"; +import BridgeSettingsPanel from "../AdvancedSettings/BridgeSettings"; + +/** User-scoped remote bridge settings (Settings → under Knowledge Bases). */ +export default function BridgePage() { + const { t } = useTranslation(); + return ( + + + + ); +} diff --git a/dashboard/src/pages/Settings/Models/components/StreamUsageAdvanced.test.tsx b/dashboard/src/pages/Settings/Models/components/StreamUsageAdvanced.test.tsx new file mode 100644 index 0000000000..a93a049a12 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/components/StreamUsageAdvanced.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Form } from "antd"; +import { describe, expect, it } from "vitest"; +import { StreamUsageAdvanced } from "./StreamUsageAdvanced"; + +function renderFold() { + return render( +
    + + , + ); +} + +describe("StreamUsageAdvanced", () => { + it("stays collapsed by default and keeps the form field mounted", async () => { + const user = userEvent.setup(); + renderFold(); + + expect( + screen.getByRole("button", { name: "models.compatibility" }), + ).toBeInTheDocument(); + expect(screen.queryByText("models.streamUsage")).not.toBeVisible(); + + await user.click( + screen.getByRole("button", { name: "models.compatibility" }), + ); + expect(screen.getByText("models.streamUsage")).toBeVisible(); + expect( + screen.getByRole("button", { name: "models.hideCompatibility" }), + ).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/pages/Settings/Models/components/StreamUsageAdvanced.tsx b/dashboard/src/pages/Settings/Models/components/StreamUsageAdvanced.tsx new file mode 100644 index 0000000000..876d895fec --- /dev/null +++ b/dashboard/src/pages/Settings/Models/components/StreamUsageAdvanced.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { Button, Form, Switch } from "antd"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +/** Provider compatibility: live token counts via stream_options.include_usage. */ +export function StreamUsageAdvanced() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( +
    +
    + +
    +
    + + + +
    +
    + ); +} diff --git a/dashboard/src/pages/Settings/Models/components/TestStreamHint.tsx b/dashboard/src/pages/Settings/Models/components/TestStreamHint.tsx new file mode 100644 index 0000000000..d89bd7467e --- /dev/null +++ b/dashboard/src/pages/Settings/Models/components/TestStreamHint.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next"; + +/** Reminder that provider "test" is non-streaming. */ +export function TestStreamHint() { + const { t } = useTranslation(); + return ( +

    + {t("models.testStreamHint")} +

    + ); +} diff --git a/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx index 732fe8d160..4690813c8a 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx @@ -12,6 +12,8 @@ import type { ProviderModel, ProviderRow } from "../../useProviders"; import { isEmbeddingModel } from "../../useProviders"; import { fetchProviderModels, testProviderDraft } from "../../providerApi"; import { ModelListEditor } from "./ModelListEditor"; +import { StreamUsageAdvanced } from "../StreamUsageAdvanced"; +import { TestStreamHint } from "../TestStreamHint"; import styles from "../../index.module.less"; interface CustomProviderModalProps { @@ -44,6 +46,7 @@ export function CustomProviderModal({ base_url?: string; api_key?: string; note?: string; + stream_usage?: boolean; }>(); const name = Form.useWatch("name", form) as string | undefined; const kind = Form.useWatch("kind", form) as string | undefined; @@ -69,7 +72,7 @@ export function CustomProviderModal({ useEffect(() => { if (open) { form.resetFields(); - form.setFieldsValue({ kind: "openai" }); + form.setFieldsValue({ kind: "openai", stream_usage: false }); setModels([]); } }, [open, form]); @@ -184,6 +187,9 @@ export function CustomProviderModal({ api_key: (values.api_key as string | undefined)?.trim() || null, models: modelEntries.length > 0 ? modelEntries : [], note: (values.note as string | undefined)?.trim() || null, + ...((values.kind as string) === "openai" + ? { stream_usage: values.stream_usage === true } + : {}), }), }); message.success( @@ -283,6 +289,8 @@ export function CustomProviderModal({ + + {kind === "openai" ? : null}
    @@ -338,6 +346,7 @@ export function CustomProviderModal({ {t("models.fetchModels")} )} + {kind === "openai" ? : null}
    diff --git a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.test.tsx b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.test.tsx new file mode 100644 index 0000000000..0d7d0ec460 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { App } from "antd"; +import { describe, expect, it, vi } from "vitest"; +import { ModelListEditor } from "./ModelListEditor"; +import type { ProviderModel, ProviderRow } from "../../useProviders"; + +function renderEditor(models: ProviderModel[]) { + const provider: ProviderRow = { + id: 1, + name: "mindie", + kind: "openai", + base_url: "http://10.0.0.8/v1", + api_key: "sk-test", + models, + note: null, + enabled: true, + }; + return render( + + + , + ); +} + +describe("ModelListEditor", () => { + it("scrolls to the edit form after clicking edit", async () => { + const user = userEvent.setup(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + renderEditor([ + { id: "qwen", name: "Qwen", enabled: true, input: ["text"] }, + ]); + + await user.click(screen.getByRole("button", { name: "models.editModel" })); + expect(await screen.findByDisplayValue("qwen")).toBeInTheDocument(); + expect(scrollIntoView).toHaveBeenCalled(); + expect(screen.getByText("models.purposeChat")).toBeInTheDocument(); + expect(screen.queryByText("models.reasoning")).not.toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "models.showAdvanced" }), + ); + expect(screen.getByText("models.reasoning")).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx index fb75210ed7..c5b55518c0 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx @@ -4,17 +4,8 @@ * Mutations stay local until the parent modal saves (PATCH full models array). * Connectivity tests still hit the live provider endpoint. */ -import { useMemo, useState } from "react"; -import { - App, - Button, - Form, - Input, - InputNumber, - Select, - Switch, - Tooltip, -} from "antd"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { App, Button, Form, Input, Radio, Select, Switch, Tooltip } from "antd"; import { Check, @@ -34,8 +25,21 @@ import type { ProviderRow, ProviderModel } from "../../useProviders"; import { isEmbeddingModel } from "../../useProviders"; import { isOnnxProviderRow } from "../../presetUtils"; import { ModelMetaTags } from "../../modelMeta"; +import { TokenCountInput } from "@/components/TokenCountInput"; import styles from "../../index.module.less"; +/** Common context-window sizes (absolute tokens). */ +const CONTEXT_WINDOW_PRESETS = [ + 8_000, 32_000, 64_000, 128_000, 200_000, 1_000_000, +] as const; + +/** Common max-output sizes (absolute tokens). */ +const MAX_TOKENS_PRESETS = [ + 4_000, 8_000, 16_000, 32_000, 64_000, 128_000, +] as const; + +const DEFAULT_CONTEXT_WINDOW = 128_000; + export interface LocalModelDownloadControl { /** Model ids already present on disk / in the local runtime. */ downloadedIds: ReadonlySet | readonly string[]; @@ -92,15 +96,17 @@ export function ModelListEditor({ const [searchQuery, setSearchQuery] = useState(""); const [editingModelId, setEditingModelId] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); + const [showReasoningProtocol, setShowReasoningProtocol] = useState(false); const [testingIds, setTestingIds] = useState>(new Set()); const [testResults, setTestResults] = useState< Map >(new Map()); const [testingForm, setTestingForm] = useState(false); const [form] = Form.useForm(); + const formAnchorRef = useRef(null); const isOnnx = isOnnxProviderRow(provider); - const embeddingValue = Form.useWatch("embedding", form); - const embeddingOn = isOnnx || embeddingValue === true; + const purposeValue = Form.useWatch("purpose", form); + const embeddingOn = isOnnx || purposeValue === "embedding"; const handleToggleEnabled = ( modelId: string, @@ -236,7 +242,7 @@ export function ModelListEditor({ const id = (values.id as string).trim(); const name = (values.name as string | undefined)?.trim() || id; const isOnnx = isOnnxProviderRow(provider); - const embedding = isOnnx || values.embedding === true; + const embedding = isOnnx || values.purpose === "embedding"; const entry: ProviderModel = { id, name, @@ -339,13 +345,21 @@ export function ModelListEditor({ reasoning_effort_type: model.reasoning_config?.effort_type ?? "enum", reasoning_adapter: model.reasoning_config?.adapter ?? "thinking", input: model.input ?? ["text"], - embedding: Boolean(model.embedding || model.task === "embedding"), + purpose: Boolean(model.embedding || model.task === "embedding") + ? "embedding" + : "chat", }); - const hasAdvanced = - (model as Record).context_window != null || - (model as Record).max_tokens != null || - (model as Record).reasoning != null; + const hasAdvanced = (model as Record).reasoning != null; setShowAdvanced(hasAdvanced); + const cfg = model.reasoning_config; + setShowReasoningProtocol( + Boolean( + (cfg?.efforts?.length ?? 0) > 0 || + cfg?.default_effort || + (cfg?.adapter && cfg.adapter !== "thinking") || + (cfg?.effort_type && cfg.effort_type !== "enum"), + ), + ); }; const startAdding = () => { @@ -353,12 +367,19 @@ export function ModelListEditor({ form.resetFields(); setAdding(true); setShowAdvanced(false); + setShowReasoningProtocol(false); + form.setFieldsValue({ + purpose: "chat", + context_window: DEFAULT_CONTEXT_WINDOW, + input: ["text"], + }); }; const resetForm = () => { setAdding(false); setEditingModelId(null); setShowAdvanced(false); + setShowReasoningProtocol(false); form.resetFields(); }; @@ -366,6 +387,16 @@ export function ModelListEditor({ const isFormVisible = adding || isEditing; const hasApiKey = canTest ?? !!provider.api_key; + useEffect(() => { + if (!isFormVisible) return; + const el = formAnchorRef.current; + if (!el) return; + const frame = window.requestAnimationFrame(() => { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [isFormVisible, editingModelId, adding]); + const filteredModels = useMemo(() => { const query = searchQuery.trim().toLowerCase(); if (!query) return models; @@ -502,6 +533,8 @@ export function ModelListEditor({ type="text" size="small" icon={} + title={t("models.editModel")} + aria-label={t("models.editModel")} onClick={() => startEditing(m as ProviderModel & Record) } @@ -522,7 +555,7 @@ export function ModelListEditor({
    {isFormVisible ? ( -
    +
    - + + {t("models.purposeChat")} + + {t("models.purposeEmbedding")} + + )} @@ -575,6 +612,31 @@ export function ModelListEditor({ /> +
    + + + + + + +
    +
    -
    - - - - - - -
    - - - - - - + {showReasoningProtocol + ? t("models.hideCustomReasoning") + : t("models.customReasoning")} + + {showReasoningProtocol ? ( + <> + + + + + + + + ) : null} ) : null } diff --git a/dashboard/src/pages/Settings/Models/components/modals/PresetProviderModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/PresetProviderModal.tsx index 413a349bbf..b8bd0c2757 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/PresetProviderModal.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/PresetProviderModal.tsx @@ -19,6 +19,8 @@ import { isEmbeddingModel } from "../../useProviders"; import { CodexOAuthConnect } from "../CodexOAuthConnect"; import { fetchProviderModels, testProviderDraft } from "../../providerApi"; import { ModelListEditor } from "./ModelListEditor"; +import { StreamUsageAdvanced } from "../StreamUsageAdvanced"; +import { TestStreamHint } from "../TestStreamHint"; import styles from "../../index.module.less"; interface PresetProviderModalProps { @@ -33,6 +35,7 @@ interface PresetForm { base_url: string; api_key?: string; kind: string; + stream_usage?: boolean; } export function PresetProviderModal({ @@ -91,6 +94,7 @@ export function PresetProviderModal({ name: preset.name, base_url: preset.base_url, kind: preset.protocol, + stream_usage: preset.protocol === "openai", }); } }, [open, preset, form, t]); @@ -223,6 +227,9 @@ export function PresetProviderModal({ base_url: values.base_url?.trim() || null, api_key: values.api_key?.trim() || null, models: draftModels, + ...(preset.protocol === "openai" + ? { stream_usage: values.stream_usage !== false } + : {}), }), }); message.success( @@ -321,6 +328,8 @@ export function PresetProviderModal({ visibilityToggle /> + + {preset.protocol === "openai" ? : null}
    @@ -339,6 +348,7 @@ export function PresetProviderModal({ > {t("models.fetchModels")} + {preset.protocol === "openai" ? : null}
    diff --git a/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx index d66427799b..1ad6ce8847 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx @@ -33,8 +33,11 @@ import { setOnnxDownloadProgressHandler, watchOnnxDownload, } from "../../../../../api/modules/onnxDownloadWatcher"; -import { isOllamaProviderRow, isOnnxProviderRow } from "../../presetUtils"; +import { looksLikeOllamaProvider, isOnnxProviderRow } from "../../presetUtils"; +import { expandOllamaDownloadedIds } from "../../ollamaNames"; import { ModelListEditor } from "./ModelListEditor"; +import { StreamUsageAdvanced } from "../StreamUsageAdvanced"; +import { TestStreamHint } from "../TestStreamHint"; import styles from "../../index.module.less"; const POLL_INTERVAL_MS = 3000; @@ -60,6 +63,7 @@ interface ProviderConfigForm { model?: string; note?: string; kind: string; + stream_usage?: boolean; } interface ProviderConfigModalProps { @@ -95,7 +99,7 @@ export function ProviderConfigModal({ const [draftModels, setDraftModels] = useState([]); const hasApiKey = !!provider.api_key && provider.api_key.length > 0; - const isOllama = isOllamaProviderRow(provider); + const isOllama = looksLikeOllamaProvider(provider); const isOnnx = isOnnxProviderRow(provider); const [downloadedIds, setDownloadedIds] = useState([]); const [downloadingIds, setDownloadingIds] = useState([]); @@ -113,6 +117,9 @@ export function ProviderConfigModal({ const [ollamaTasks, setOllamaTasks] = useState( [], ); + const [ollamaModelsDir, setOllamaModelsDir] = useState(""); + const [ollamaServiceLoaded, setOllamaServiceLoaded] = useState(false); + const [savingModelsDir, setSavingModelsDir] = useState(false); const ollamaPollRef = useRef | null>(null); const ollamaNotifiedRef = useRef>(new Set()); @@ -185,7 +192,8 @@ export function ProviderConfigModal({ const data = await request("/ollama-models"); const list = Array.isArray(data) ? data : []; setOllamaModels(list); - setDownloadedIds(list.map((m) => m.name)); + setDownloadedIds(expandOllamaDownloadedIds(list.map((m) => m.name))); + setOllamaUnavailable(false); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("503") || msg.includes("connect")) { @@ -262,6 +270,17 @@ export function ProviderConfigModal({ void fetchOllamaModels(); downloadForm.resetFields(); ollamaNotifiedRef.current.clear(); + setOllamaServiceLoaded(false); + + void ollamaModelApi + .getService() + .then((st) => { + setOllamaModelsDir(st.models_dir || ""); + }) + .catch(() => {}) + .finally(() => { + setOllamaServiceLoaded(true); + }); void request("/ollama-models/download-status") .then((tasks) => { @@ -283,6 +302,25 @@ export function ProviderConfigModal({ stopOllamaPolling, ]); + const handleSaveOllamaModelsDir = async () => { + if (!ollamaServiceLoaded) return; + setSavingModelsDir(true); + try { + const st = await ollamaModelApi.setModelsDir(ollamaModelsDir.trim()); + setOllamaModelsDir(st.models_dir || ""); + message.success(t("models.ollamaModelsDirSaved")); + await fetchOllamaModels(); + } catch (err) { + message.error( + err instanceof Error + ? err.message + : t("models.ollamaModelsDirSaveFailed"), + ); + } finally { + setSavingModelsDir(false); + } + }; + const handleOllamaDownload = async () => { try { const values = await downloadForm.validateFields(); @@ -363,7 +401,7 @@ export function ProviderConfigModal({ const data = await request("/ollama-models"); const list = Array.isArray(data) ? data : []; setOllamaModels(list); - setDownloadedIds(list.map((m) => m.name)); + setDownloadedIds(expandOllamaDownloadedIds(list.map((m) => m.name))); setOllamaUnavailable(false); } else if (isOnnx) { const st = await onnxModelApi.getStatus(); @@ -628,6 +666,7 @@ export function ProviderConfigModal({ api_key: undefined, model: currentDefaultModel, note: provider.note ?? "", + stream_usage: provider.stream_usage !== false, }); setDraftModels( (provider.models ?? []).map((m) => ({ @@ -656,6 +695,13 @@ export function ProviderConfigModal({ payload.api_key = values.api_key.trim(); if ((values.note ?? "") !== (provider.note ?? "")) payload.note = values.note?.trim() || null; + if ( + !isOnnx && + provider.kind === "openai" && + Boolean(values.stream_usage) !== (provider.stream_usage !== false) + ) { + payload.stream_usage = Boolean(values.stream_usage); + } // default model const existingDefault = provider.models?.length ? provider.models[0].id @@ -769,39 +815,37 @@ export function ProviderConfigModal({ const draftApiKey = (values.api_key as string | undefined)?.trim(); const draftBaseUrl = (values.base_url as string | undefined)?.trim(); + const useDraft = + !!draftApiKey || + (!!draftBaseUrl && draftBaseUrl !== (provider.base_url ?? "")); - const embedding = isEmbeddingModel( - draftModels.find((m) => m.id === modelId), - ); - // SEC-4 decision (a): the stored key is masked in API responses, so a - // client-side draft fallback would send the masked value and fail auth. - // With no draft key: unsaved providers can't test (prompt for a key); - // saved providers always use the by-id test endpoint, which probes with - // the stored key server-side. - let result: { ok: boolean; latency_ms?: number; error?: string }; - if (draftApiKey) { - result = await testProviderDraft({ - name: provider.name, - kind: provider.kind, - api_key: draftApiKey, - base_url: draftBaseUrl || provider.base_url, - model_id: modelId, - embedding, - }); - } else if (hasApiKey) { - result = await request<{ - ok: boolean; - latency_ms?: number; - error?: string; - }>(`${apiPrefix}/${provider.id}/test`, { - method: "POST", - body: JSON.stringify({ model_id: modelId, embedding }), - }); - } else { + if (useDraft && !draftApiKey && !hasApiKey) { message.warning(t("models.pleaseEnterApiKey")); return; } + const embedding = isEmbeddingModel( + draftModels.find((m) => m.id === modelId), + ); + const result = + useDraft || !hasApiKey + ? await testProviderDraft({ + name: provider.name, + kind: provider.kind, + api_key: draftApiKey || provider.api_key || undefined, + base_url: draftBaseUrl || provider.base_url, + model_id: modelId, + embedding, + }) + : await request<{ + ok: boolean; + latency_ms?: number; + error?: string; + }>(`${apiPrefix}/${provider.id}/test`, { + method: "POST", + body: JSON.stringify({ model_id: modelId, embedding }), + }); + if (result.ok) { const latency = result.latency_ms != null @@ -870,11 +914,7 @@ export function ProviderConfigModal({ } const draftApiKey = (values.api_key as string | undefined)?.trim(); const draftBaseUrl = (values.base_url as string | undefined)?.trim(); - // SEC-4 decision (a): the stored key is masked, so it cannot be reused - // here. Fetch-models requires a draft key when the provider is being - // edited; for an unchanged saved provider the user re-enters the key - // once (the masked form field is the visual cue). - const apiKey = draftApiKey ?? ""; + const apiKey = draftApiKey || provider.api_key || ""; if (!apiKey) { message.warning(t("models.pleaseEnterApiKey")); return; @@ -896,6 +936,11 @@ export function ProviderConfigModal({ return; } const fetched = result.models ?? []; + if (isOllama && fetched.length > 0) { + setDownloadedIds((prev) => + expandOllamaDownloadedIds([...prev, ...fetched.map((m) => m.id)]), + ); + } if (fetched.length === 0) { message.info(t("models.fetchModelsNoNew")); return; @@ -1036,6 +1081,8 @@ export function ProviderConfigModal({ + + {!isOnnx && provider.kind === "openai" ? : null}
    @@ -1056,6 +1103,7 @@ export function ProviderConfigModal({ > {t("models.fetchModels")} + {!isOnnx && provider.kind === "openai" ? : null}
    {/* ===== Models section ===== */} @@ -1115,6 +1163,35 @@ export function ProviderConfigModal({ {t("models.ollamaLocalModels")}
    +
    +
    + {t("models.ollamaModelsDir")} +
    +
    + setOllamaModelsDir(e.target.value)} + placeholder={t("models.ollamaModelsDirPlaceholder")} + /> + +
    +
    + {t("models.ollamaModelsDirHint")} +
    +
    + {ollamaUnavailable ? (
    { + it("aliases :latest with the untagged name", () => { + expect(expandOllamaDownloadedIds(["llama3:latest"]).sort()).toEqual( + ["llama3", "llama3:latest"].sort(), + ); + expect(expandOllamaDownloadedIds(["llama3"]).sort()).toEqual( + ["llama3", "llama3:latest"].sort(), + ); + }); + + it("does not alias a specific tag to the bare name", () => { + expect(expandOllamaDownloadedIds(["qwen2.5:7b"])).toEqual(["qwen2.5:7b"]); + }); +}); diff --git a/dashboard/src/pages/Settings/Models/ollamaNames.ts b/dashboard/src/pages/Settings/Models/ollamaNames.ts new file mode 100644 index 0000000000..740ced6788 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/ollamaNames.ts @@ -0,0 +1,17 @@ +/** Ollama treats a missing tag as ``:latest``. Do not alias other tags (7b vs 72b). */ + +export function expandOllamaDownloadedIds(names: readonly string[]): string[] { + const out = new Set(); + for (const raw of names) { + const name = raw.trim(); + if (!name) continue; + out.add(name); + if (name.endsWith(":latest")) { + const bare = name.slice(0, -":latest".length); + if (bare) out.add(bare); + } else if (!name.includes(":")) { + out.add(`${name}:latest`); + } + } + return [...out]; +} diff --git a/dashboard/src/pages/Settings/Models/presetUtils.test.ts b/dashboard/src/pages/Settings/Models/presetUtils.test.ts new file mode 100644 index 0000000000..716c09aa01 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/presetUtils.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + isLocalProviderRow, + isOllamaProviderRow, + isOllamaRuntimeUrl, + isOnnxProviderRow, + looksLikeOllamaProvider, +} from "./presetUtils"; + +describe("isOnnxProviderRow", () => { + it("matches the onboard ONNX placeholder key or exact name", () => { + expect(isOnnxProviderRow({ name: "anything", api_key: "onnx" })).toBe(true); + expect(isOnnxProviderRow({ name: "ONNX (Local)" })).toBe(true); + expect(isOnnxProviderRow({ name: " ONNX (Local) " })).toBe(true); + expect( + isOnnxProviderRow({ name: "My ONNX Cloud Proxy", api_key: "sk-x" }), + ).toBe(false); + }); +}); + +describe("isOllamaProviderRow", () => { + it("matches onboard identity only — not a coincidental Ollama URL", () => { + expect(isOllamaProviderRow({ name: "anything", api_key: "ollama" })).toBe( + true, + ); + expect(isOllamaProviderRow({ name: "Ollama (Local)" })).toBe(true); + expect(isOllamaProviderRow({ name: " ollama (local) " })).toBe(true); + expect( + isOllamaProviderRow({ + name: "home-ollama", + api_key: "sk-x", + base_url: "http://127.0.0.1:11434", + }), + ).toBe(false); + expect( + isOllamaProviderRow({ + name: "docker-ollama", + api_key: "sk-x", + base_url: "http://ollama:11434", + }), + ).toBe(false); + }); + + it("does not treat custom local OpenAI-compat providers as onboard Ollama", () => { + expect( + isOllamaProviderRow({ + name: "my-local-llm", + api_key: "sk-local", + base_url: "http://127.0.0.1:8000/v1", + }), + ).toBe(false); + expect( + isLocalProviderRow({ + name: "lm-studio", + api_key: "lm-studio", + base_url: "http://localhost:1234/v1", + }), + ).toBe(false); + expect( + isLocalProviderRow({ + name: "company-ollama", + api_key: "sk-x", + base_url: "http://192.168.1.10:11434", + }), + ).toBe(false); + }); +}); + +describe("looksLikeOllamaProvider", () => { + it("keeps URL hints for the Ollama management UI", () => { + expect(isOllamaRuntimeUrl("http://127.0.0.1:11434")).toBe(true); + expect(isOllamaRuntimeUrl("http://ollama")).toBe(true); + expect(isOllamaRuntimeUrl("http://host/qwen11434")).toBe(false); + expect(isOllamaRuntimeUrl("https://ollama.com/v1")).toBe(false); + expect( + looksLikeOllamaProvider({ + name: "company-ollama", + api_key: "sk-x", + base_url: "http://192.168.1.10:11434", + }), + ).toBe(true); + expect( + looksLikeOllamaProvider({ + name: "my-local-llm", + api_key: "sk-local", + base_url: "http://127.0.0.1:8000/v1", + }), + ).toBe(false); + }); +}); diff --git a/dashboard/src/pages/Settings/Models/presetUtils.ts b/dashboard/src/pages/Settings/Models/presetUtils.ts index fa8089ab4c..6f9cf60bc8 100644 --- a/dashboard/src/pages/Settings/Models/presetUtils.ts +++ b/dashboard/src/pages/Settings/Models/presetUtils.ts @@ -192,15 +192,33 @@ function localPresetApiKey(provider: { api_key?: string | null }): string { return (provider.api_key ?? "").trim().toLowerCase(); } +function providerNameKey(name: string | null | undefined): string { + return (name ?? "").trim().toLowerCase(); +} + export function isOnnxProviderRow(provider: { name: string; api_key?: string | null; }): boolean { if (localPresetApiKey(provider) === "onnx") return true; - const n = provider.name.toLowerCase(); + const n = providerNameKey(provider.name); return n === "onnx" || n === "onnx (local)"; } +/** URL hint for Ollama management UI — not used for delete protection. */ +export function isOllamaRuntimeUrl(baseUrl?: string | null): boolean { + const raw = (baseUrl ?? "").trim().toLowerCase(); + if (!raw) return false; + try { + const href = raw.includes("://") ? raw : `http://${raw}`; + const parsed = new URL(href); + if (parsed.hostname === "ollama") return true; + return parsed.port === "11434"; + } catch { + return raw === "ollama"; + } +} + export function isOllamaProviderRow(provider: { name: string; base_url?: string | null; @@ -208,13 +226,16 @@ export function isOllamaProviderRow(provider: { }): boolean { if (isOnnxProviderRow(provider)) return false; if (localPresetApiKey(provider) === "ollama") return true; - const n = provider.name.toLowerCase(); - return ( - n === "ollama" || - n === "ollama (local)" || - (provider.base_url?.includes("11434") ?? false) || - (provider.base_url?.includes("ollama") ?? false) - ); + const n = providerNameKey(provider.name); + return n === "ollama" || n === "ollama (local)"; +} + +export function looksLikeOllamaProvider(provider: { + name: string; + base_url?: string | null; + api_key?: string | null; +}): boolean { + return isOllamaProviderRow(provider) || isOllamaRuntimeUrl(provider.base_url); } export function isLocalProviderRow(provider: { diff --git a/dashboard/src/pages/Settings/Models/useProviders.ts b/dashboard/src/pages/Settings/Models/useProviders.ts index 5c18817847..c5bd38458d 100644 --- a/dashboard/src/pages/Settings/Models/useProviders.ts +++ b/dashboard/src/pages/Settings/Models/useProviders.ts @@ -71,6 +71,8 @@ export interface ProviderRow { models: ProviderModel[]; note: string | null; enabled: boolean; + /** Resolved: custom defaults off, bundled presets default on. */ + stream_usage?: boolean; } export interface UseProvidersResult { diff --git a/dashboard/src/pages/Settings/SearchConfig/index.tsx b/dashboard/src/pages/Settings/SearchConfig/index.tsx index 65225621aa..3b1972d476 100644 --- a/dashboard/src/pages/Settings/SearchConfig/index.tsx +++ b/dashboard/src/pages/Settings/SearchConfig/index.tsx @@ -256,7 +256,7 @@ function ConfigureDrawer({ > ))} diff --git a/dashboard/src/pages/Settings/Voice/index.tsx b/dashboard/src/pages/Settings/Voice/index.tsx index 4ac097e1d4..354bee4e33 100644 --- a/dashboard/src/pages/Settings/Voice/index.tsx +++ b/dashboard/src/pages/Settings/Voice/index.tsx @@ -1,5 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Button, Divider, Drawer, Form, Input, Select, Typography } from "antd"; +import { + Button, + Divider, + Drawer, + Form, + Input, + Select, + Switch, + Typography, +} from "antd"; import { message } from "@/utils/antdMessage"; import { Activity, Mic2, Check, Settings2 } from "lucide-react"; @@ -37,6 +46,8 @@ export function VoiceSettingsPanel() { const [apiKey, setApiKey] = useState(""); const [secretId, setSecretId] = useState(""); const [secretKey, setSecretKey] = useState(""); + const [realtimeStt, setRealtimeStt] = useState(false); + const [appId, setAppId] = useState(""); const [mimoEndpoint, setMimoEndpoint] = useState<"payg" | "tokenplan">( "payg", ); @@ -97,13 +108,12 @@ export function VoiceSettingsPanel() { const openConfigure = (preset: VoicePreset) => { const existing = findConfigured(preset); setConfigure({ preset, existing }); - // SEC-4 decision (a): the stored key is masked in API responses, so it - // must not be prefilled (the user would silently round-trip the mask). - // Leave the field empty; the PATCH path treats null as "keep existing". - setApiKey(""); + setApiKey(existing?.api_key ?? ""); const extra = existing?.extra ?? {}; setSecretId(String(extra.secret_id ?? "")); setSecretKey(String(extra.secret_key ?? "")); + setRealtimeStt(extra.realtime_stt === true); + setAppId(String(extra.app_id ?? "")); setMimoEndpoint(extra.endpoint_type === "tokenplan" ? "tokenplan" : "payg"); setMimoVoiceId(String(extra.voice_id ?? "冰糖")); }; @@ -118,6 +128,8 @@ export function VoiceSettingsPanel() { secret_id: secretId, secret_key: secretKey, region: "ap-guangzhou", + realtime_stt: realtimeStt, + app_id: appId.trim() || undefined, }; } else if (preset.kind === "edge") { extra = { voice_id: "zh-CN-XiaoxiaoNeural" }; @@ -143,10 +155,7 @@ export function VoiceSettingsPanel() { ? secretId && secretKey ? `${secretId}:${secretKey}` : null - : // SEC-4 decision (a): empty field means "keep existing" — the - // server's partial update skips null api_key, so the stored key - // survives a re-save untouched. - apiKey || null, + : apiKey || null, extra_json: JSON.stringify(extra), }; }; @@ -158,6 +167,10 @@ export function VoiceSettingsPanel() { ? Boolean(secretId.trim() && secretKey.trim()) : Boolean(apiKey.trim()); if (!complete) message.warning(t("voice.credentialsRequired")); + if (complete && realtimeStt && !appId.trim()) { + message.warning(t("voice.realtimeAppIdRequired")); + return false; + } return complete; }; @@ -198,6 +211,9 @@ export function VoiceSettingsPanel() { } else { await voiceApi.createProvider(payload); } + // The active payload carries `stt_realtime`, so drop the cache even when + // the active provider itself does not change (e.g. the switch was toggled). + invalidateVoiceConfigCache(); if ( preset.kind !== "browser" && (preset.capability === "stt" || preset.capability === "both") && @@ -205,7 +221,6 @@ export function VoiceSettingsPanel() { ) { const next = await voiceApi.setActive({ stt: preset.id }); setActive(next); - invalidateVoiceConfigCache(); } if ( preset.kind !== "browser" && @@ -214,7 +229,6 @@ export function VoiceSettingsPanel() { ) { const next = await voiceApi.setActive({ tts: preset.id }); setActive(next); - invalidateVoiceConfigCache(); } message.success(t("voice.saved")); setConfigure(null); @@ -425,6 +439,26 @@ export function VoiceSettingsPanel() { onChange={(e) => setSecretKey(e.target.value)} /> + + + + + setAppId(e.target.value)} + /> + + {realtimeStt && ( +
    + {t("voice.realtimeSttHint")} +
    + )} )} {configure?.preset.kind === "openai" && ( diff --git a/dashboard/src/pages/Setup/steps/PasswordStep.tsx b/dashboard/src/pages/Setup/steps/PasswordStep.tsx index b4966bffeb..e7a508abe5 100644 --- a/dashboard/src/pages/Setup/steps/PasswordStep.tsx +++ b/dashboard/src/pages/Setup/steps/PasswordStep.tsx @@ -18,6 +18,7 @@ export default function PasswordStep({ onVerified }: Props) { const [form] = Form.useForm<{ password: string }>(); const [submitting, setSubmitting] = useState(false); const [fileExists, setFileExists] = useState(null); + const [passwordPath, setPasswordPath] = useState(null); const [error, setError] = useState(null); useEffect(() => { @@ -25,6 +26,7 @@ export default function PasswordStep({ onVerified }: Props) { .getAuthStatus() .then((s) => { setFileExists(s.wizard_password_exists); + setPasswordPath(s.wizard_password_path ?? null); }) .catch(() => setFileExists(null)); }, []); @@ -69,7 +71,9 @@ export default function PasswordStep({ onVerified }: Props) { message={t("wizard.password.whereTitle")} description={ - {t("wizard.password.whereDefault")} + {passwordPath + ? t("wizard.password.wherePath", { path: passwordPath }) + : t("wizard.password.whereDefault")} } /> @@ -99,7 +103,7 @@ export default function PasswordStep({ onVerified }: Props) { } autoFocus - autoComplete="new-password" + autoComplete="off" /> diff --git a/dashboard/src/pages/SkillPackages/PackageSkillCard.tsx b/dashboard/src/pages/SkillPackages/PackageSkillCard.tsx index a8d1647845..1f8e7834c2 100644 --- a/dashboard/src/pages/SkillPackages/PackageSkillCard.tsx +++ b/dashboard/src/pages/SkillPackages/PackageSkillCard.tsx @@ -32,12 +32,7 @@ export function PackageSkillCard({ onClick={onClick} role="button" tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick(); - } - }} + onKeyDown={(e) => e.key === "Enter" && onClick()} >
    diff --git a/dashboard/src/pages/SkillPackages/index.tsx b/dashboard/src/pages/SkillPackages/index.tsx index 3da1dc2a68..048a695403 100644 --- a/dashboard/src/pages/SkillPackages/index.tsx +++ b/dashboard/src/pages/SkillPackages/index.tsx @@ -6,6 +6,7 @@ import { type CSSProperties, } from "react"; import { + Radio, Button, Drawer, Dropdown, @@ -87,6 +88,7 @@ type PackageFormValues = { description?: string; icon_name?: string; icon_url?: string; + copy_policy?: "snapshot" | "lock" | "deny"; }; const EMPTY_SKILL = "---\nname: \ndescription: \n---\n\n"; @@ -312,6 +314,7 @@ export default function SkillPackagesPage() { description: "", icon_name: undefined, icon_url: "", + copy_policy: "snapshot", }); setPackageDrawerOpen(true); }; @@ -323,6 +326,7 @@ export default function SkillPackagesPage() { description: item.description, icon_name: item.icon_name, icon_url: item.icon_url, + copy_policy: item.copy_policy ?? "snapshot", }); setPackageDrawerOpen(true); }; @@ -963,6 +967,24 @@ export default function SkillPackagesPage() { className={styles.packageForm} requiredMark={false} > + {editingPackageId ? ( + + + + ) : null} k !== "title") : []; + const running = status === "running"; return (
    {title}
    {toolName || "tool"} - {status === "running" ? " · running" : ""} + {running ? ` · ${t("common.running", "Running")}` : ""}
    {entries.length > 0 ? (
    diff --git a/dashboard/src/plugins/toolRenderers/builtin/DefaultToolRenderer.tsx b/dashboard/src/plugins/toolRenderers/builtin/DefaultToolRenderer.tsx index 3a0411a4ca..5a4eed032a 100644 --- a/dashboard/src/plugins/toolRenderers/builtin/DefaultToolRenderer.tsx +++ b/dashboard/src/plugins/toolRenderers/builtin/DefaultToolRenderer.tsx @@ -49,6 +49,7 @@ export function DefaultToolRenderer({ displayName: displayNameProp, args, output, + status, isStreaming, hideMediaPreview = false, onAcpPermissionSelect, @@ -111,7 +112,12 @@ export function DefaultToolRenderer({ const hasMediaPreview = structuredOutput.images.length > 0 || structuredOutput.videos.length > 0; const hasResult = toolData.output !== undefined; - const completed = hasResult || (!isStreaming && hasMediaPreview); + const completed = + status === "done" || + status === "error" || + hasResult || + (!isStreaming && hasMediaPreview); + const running = !completed && (status === "running" || isStreaming); const mediaOnly = completed && hasMediaPreview && @@ -126,7 +132,7 @@ export function DefaultToolRenderer({ ); const statusLabel = completed ? t("common.done", "Done") - : isStreaming + : running ? t("common.running", "Running") : t("common.pending", "Pending"); const ToolIcon = builtinToolIcon(toolName); @@ -135,9 +141,12 @@ export function DefaultToolRenderer({