diff --git a/.cursor/skills/publish/SKILL.md b/.cursor/skills/publish/SKILL.md index 684d7967..9b0039bc 100644 --- a/.cursor/skills/publish/SKILL.md +++ b/.cursor/skills/publish/SKILL.md @@ -3,7 +3,7 @@ name: publish description: >- Publish the Octop Python package: cut a release branch from develop, bump version, update CHANGELOG, open a PR to main; after merge, Actions tag on - main (PyPI / Docker Hub) and sync main into develop. Use when the user asks + main (PyPI / Docker Hub + GHCR) and sync main into develop. Use when the user asks to publish, release, bump version, cut a release, or run /publish. disable-model-invocation: true --- diff --git a/.github/workflows/auto-tag-on-release.yml b/.github/workflows/auto-tag-on-release.yml index 3052b34f..280d23b0 100644 --- a/.github/workflows/auto-tag-on-release.yml +++ b/.github/workflows/auto-tag-on-release.yml @@ -65,8 +65,9 @@ jobs: # GITHUB_TOKEN tag pushes do not cascade to other workflows. Explicitly # dispatch Release / Docker Publish / Desktop Package (workflow_dispatch - # is exempt). Desktop builds in parallel; its release job upserts zips - # onto the same v* GitHub Release. + # is exempt). FnOS FPK is cascaded from Release after the GitHub Release + # exists (reuses Release wheel + waits for GHCR image from Docker Publish). + # Desktop builds in parallel; its release job upserts zips onto the same v*. - name: Trigger Release, Docker Publish, and Desktop Package if: steps.check_tag.outputs.should_publish == 'true' env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7fc21cd..9671aa9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,4 +115,10 @@ jobs: CONNECTOR_TENCENT_LEXIANG_COMPANY_FROM: ${{ secrets.LIVE_CONNECTOR_TENCENT_LEXIANG_COMPANY_FROM }} CONNECTOR_MEITUAN_TRAVEL_TOKEN: ${{ secrets.LIVE_CONNECTOR_MEITUAN_TRAVEL_TOKEN }} CONNECTOR_YUANDIAN_TOKEN: ${{ secrets.LIVE_CONNECTOR_YUANDIAN_TOKEN }} + # Channel probes (WeChat iLink + Feishu) + WEIXIN_BOT_UIN: ${{ secrets.LIVE_WEIXIN_BOT_UIN }} + WEIXIN_TOKEN: ${{ secrets.LIVE_WEIXIN_TOKEN }} + WEIXIN_BASE_URL: ${{ secrets.LIVE_WEIXIN_BASE_URL }} + FEISHU_APP_ID: ${{ secrets.LIVE_FEISHU_APP_ID }} + FEISHU_APP_SECRET: ${{ secrets.LIVE_FEISHU_APP_SECRET }} run: make test-live diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5e37cad7..dd787522 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,26 +1,59 @@ name: Docker Publish -# 仅在推送版本标签(v*)时构建并发布镜像到 Docker Hub。 -# 与 .github/workflows/release.yml(发 PyPI)相互独立、可并行。 -# 普通 push / PR 不会触发,CI 开销保持在最低。 +# 仅在版本标签(v*)上构建并同步发布镜像到 Docker Hub 与 GHCR。 +# 文档与对外引用统一使用:ghcr.io/tencentcloud/octop +# (Hub 仍用 DOCKERHUB_* secrets 同步推送。) +# +# 触发路径: +# 1. 人工推送 v* tag → on.push.tags +# 2. Auto Tag 用 GITHUB_TOKEN 推 tag 后显式 workflow_dispatch(token push 不会触发 push 工作流) +# +# 注意:workflow_dispatch 下 metadata-action 的 type=semver 不可靠, +# 必须从 GITHUB_REF_NAME 显式解析版本号。 +# +# FnOS FPK 由 Release 成功后自动 dispatch,并等待本工作流推送的 :{version} 就绪。 +# +# 拉取示例: +# docker pull ghcr.io/tencentcloud/octop:latest +# docker pull ghcr.io/tencentcloud/octop:0.9.28 on: push: tags: - "v*" - # Allow Auto Tag On Release Merge to cascade after a GITHUB_TOKEN tag push - # (token-authored pushes do not retrigger push workflows). workflow_dispatch: +permissions: + contents: read + packages: write + jobs: docker: name: Build and push image runs-on: ubuntu-latest + # 拒绝在非 v* ref 上误跑(例如 UI 里对 main 点 Run workflow) + if: startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Resolve version from tag ref + id: ver + run: | + set -euo pipefail + ref="${GITHUB_REF_NAME}" + 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" + exit 1 + ;; + esac + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -30,16 +63,29 @@ jobs: 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: 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: - # 镜像名 = /octop(个人或组织命名空间均可)。 - images: ${{ secrets.DOCKERHUB_USERNAME }}/octop + images: | + ${{ steps.image.outputs.hub }} + ${{ steps.image.outputs.ghcr }} + # 显式 raw tag:兼容 push.tags 与 Auto Tag 的 workflow_dispatch tags: | - # v1.2.3 -> 1.2.3 - type=semver,pattern={{version}} - # 每个 tag 同时打 latest + type=raw,value=${{ steps.ver.outputs.version }} type=raw,value=latest - name: Build and push @@ -48,11 +94,8 @@ jobs: context: . file: ./docker/Dockerfile push: true - # 默认仅 amd64,构建最快最稳。如需 ARM64(Apple Silicon / Graviton), - # 改为 "linux/amd64,linux/arm64" 并加上 docker/setup-qemu-action@v3 步骤。 platforms: linux/amd64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - # 复用 GitHub Actions 缓存加速后续构建 cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/fnos-build-fpk.yml b/.github/workflows/fnos-build-fpk.yml index 05c6a338..9ce4060a 100644 --- a/.github/workflows/fnos-build-fpk.yml +++ b/.github/workflows/fnos-build-fpk.yml @@ -1,35 +1,30 @@ -name: Build Octop FPK (template) +name: Build Octop FPK -# TEMPLATE — FnOS (飞牛 NAS) app packaging pipeline for fork maintainers. -# # This workflow builds the Octop Docker image, the Docker .fpk and the native .fpk, -# # then publishes GitHub releases (fixed version + rolling latest). -# # -# # To enable on your fork: -# # 1. Replace jubaoliang/octop with your own image namespace (or fork a copy). -# # 2. Optionally add a push trigger below (e.g. branches: [main]) so a rebuild happens -# # automatically on every upstream sync. It is intentionally workflow_dispatch-only -# # here so it never runs on the upstream repository. -# # -# image job:从仓库源码构建 Octop Docker 镜像,推送到 Docker Hub(jubaoliang/octop:vanilla) -# fpk job:构建「Docker 版」飞牛安装包 Fnos-octop-vanilla--.fpk(依赖 image,确保镜像已就绪) -# native job:构建「本地版(非 Docker)」飞牛安装包 Fnos-octop-vanilla-native--.fpk。 -# 不内置 Python 运行时(依赖飞牛应用商店 Python 3.12), -# 仅内置 Octop 核心 whl + 核心依赖 site-packages,不含浏览器二进制与预装技能。 -# release job: -# - 创建固定版本 release(如 fnos-0.9.16-01),保留历史、带更新日志; -# - 同步更新滚动 release fnos-vanilla-latest,方便用户始终从同一 URL 下载最新版。 +# FnOS (飞牛 NAS) 安装包流水线。 # -# 镜像包随公开仓库默认即为 public,无需额外设置可见性。 +# 触发: +# - Release 工作流在 GitHub Release 创建成功后自动 dispatch(推荐) +# - 亦可手动 workflow_dispatch(需对应版本镜像已在 GHCR) +# +# 制品复用: +# - Docker 镜像:不再重建,等待 docker-publish 推送的 +# ghcr.io/tencentcloud/octop:{version|latest} +# - Native wheel:优先从同版本 GitHub Release(v*)下载; +# 缺失时再回退到本仓源码构建 +# +# Jobs: +# version → ensure-image → fpk(Docker 版 .fpk,仅打包 compose) +# ↘ native(本地版 .fpk)→ release(固定版 + 滚动 latest) on: workflow_dispatch: permissions: contents: write - packages: write + packages: read concurrency: - group: build-octop-vanilla + group: build-octop-fnos cancel-in-progress: false jobs: @@ -40,17 +35,20 @@ jobs: iter: ${{ steps.iter.outputs.iter }} tag: ${{ steps.iter.outputs.tag }} pkg_ver: ${{ steps.iter.outputs.pkg_ver }} + image: ${{ steps.ver.outputs.image }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 fetch-tags: true - - name: Read version + - name: Read version and image name id: ver run: | VER=$(grep -m1 '^version' pyproject.toml | sed -E 's/.*"([0-9][0-9.]*[0-9])".*/\1/') + IMAGE="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" echo "version=$VER" >> "$GITHUB_OUTPUT" + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" - name: Compute iteration id: iter @@ -58,10 +56,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VER=${{ steps.ver.outputs.version }} - # 查询已存在的 fnos--NN releases,取最大序号 +1 作为本次迭代号。 - # 之前用 GitHub API / git ls-remote / git tag -l 都在 CI 中因认证、 - # 限流或本地 tag 未拉取而回退成 0,导致每次都覆盖 fnos--01。 - # GitHub CLI 在 Actions runner 中已预装且自动使用 GITHUB_TOKEN,最稳。 + # 查询已存在的 fnos-vanilla--NN releases,取最大序号 +1。 echo "[version] existing releases matching fnos-vanilla-${VER}-NN:" gh release list --repo "${{ github.repository }}" --limit 100 --json tagName | python3 -c 'import sys,json; [print(" "+r.get("tagName","")) for r in json.load(sys.stdin)]' || true EXISTING=$(gh release list --repo "${{ github.repository }}" --limit 100 --json tagName | python3 -c 'import sys,json,re; d=json.load(sys.stdin); pat=re.compile(r"fnos-vanilla-[0-9]+\.[0-9]+\.[0-9]+-([0-9]+)$"); nums=[int(pat.match(r.get("tagName","")).group(1)) for r in d if pat.match(r.get("tagName",""))]; print(max(nums) if nums else 0)') @@ -71,42 +66,45 @@ jobs: echo "iter=$ITER" >> "$GITHUB_OUTPUT" echo "tag=fnos-vanilla-${VER}-${ITER}" >> "$GITHUB_OUTPUT" echo "pkg_ver=${VER}-${ITER}" >> "$GITHUB_OUTPUT" - echo "[version] VER=$VER, EXISTING_MAX=$MAX, ITER=$ITER, TAG=fnos-${VER}-${ITER}" + echo "[version] VER=$VER, EXISTING_MAX=$MAX, ITER=$ITER, TAG=fnos-vanilla-${VER}-${ITER}" - image: + # 复用 docker-publish 已推送的镜像,不在此重新 build/push。 + ensure-image: needs: version runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to Docker Hub + - name: Log in to GHCR uses: docker/login-action@v3 with: - # Docker Hub(与 docker-publish.yml 同一凭据,DOCKERHUB_USERNAME 为 jubaoliang 时推送 jubaoliang/octop) - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push image - uses: docker/build-push-action@v6 - with: - context: . - file: fnos/docker/Dockerfile - push: true - tags: | - jubaoliang/octop:latest - jubaoliang/octop:vanilla - jubaoliang/octop:vanilla-v${{ needs.version.outputs.version }} - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Wait for GHCR image from docker-publish + env: + IMAGE: ${{ needs.version.outputs.image }} + VER: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + TAG="${IMAGE}:${VER}" + echo "Waiting for ${TAG} (published by docker-publish)..." + for i in $(seq 1 90); do + if docker buildx imagetools inspect "$TAG" >/dev/null 2>&1; then + echo "Image ready: $TAG" + docker buildx imagetools inspect "$TAG" | head -20 + exit 0 + fi + echo "[$i/90] not ready yet..." + sleep 20 + done + echo "::error::Timed out waiting for ${TAG}. Ensure Docker Publish finished for v${VER}." + exit 1 fpk: - needs: [image, version] + needs: [ensure-image, version] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -128,8 +126,7 @@ jobs: if-no-files-found: error native: - # 即使原生版构建失败也不阻塞整体发布:Docker 版(fpk)始终会发布; - # 原生版成功则一并附带。continue-on-error 让 needs 视为通过。 + # 即使原生版构建失败也不阻塞整体发布:Docker 版(fpk)始终会发布。 continue-on-error: true needs: version runs-on: ubuntu-latest @@ -141,22 +138,40 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Set up Python 3.12 (build-only) + - 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 - # native 包必须包含当前 fork 的源码修改(如 /admin/providers/fetch-models)。 - # 上游 TencentCloud/Octop 的 release wheel 不包含这些修改,因此改为从当前 - # 仓库源码构建 wheel:先构建前端产物到 src/octop/dashboard,再打包 wheel。 - name: Build Octop wheel from current source + if: steps.wheel.outputs.source == 'build' run: | set -e echo "=== build frontend ===" @@ -184,25 +199,11 @@ jobs: mkdir -p "${{ github.workspace }}/fnos/native/app/site-packages" SP="${{ github.workspace }}/fnos/native/app/site-packages" { - # 用 uv 安装核心 whl 及其依赖到包内 site-packages。 - # pip 对 orcakit-harness-agent[all] 的复杂依赖树会报 resolution-too-deep, - # uv 的回溯能力强得多(旧方案已验证)。 python3 -m pip install --quiet uv - # 第一步:让 uv 自由解析并安装核心 whl(含其完整依赖树)。 - # 自由解析会拉到最新的 mcp==2.0.0 + langchain-mcp-adapters==0.3.1,这两者在结构上 - # 不兼容(见第二步),但本步只负责把依赖装齐,不要求版本正确。 - # 注意:不能把钉版与 octop.whl 放在同一条 uv 命令里——会和 whl 的传递依赖约束 - # 发生 resolution 冲突,导致整个 native 构建失败(fdca4c6 的教训)。 python3 -m uv pip install --python python3.12 \ --target "$SP" --no-cache "$(ls dist/octop-*.whl | head -1)" - # 第二步:强制覆盖为仓库 uv.lock 锁定的兼容组合 mcp==1.28.1 + langchain-mcp-adapters==0.3.0。 - # mcp 2.0 移除了 mcp.server.fastmcp / mcp.shared.context.RequestContext 等符号, - # 而 langchain-mcp-adapters 0.3.x 仍硬导入它们,导致 octop 启动即 ImportError。 - # 先清掉旧版目录,防止多版本共存导致 Python 仍加载坏的 mcp 2.0。 rm -rf "$SP/mcp" "$SP"/mcp-*.dist-info \ "$SP/langchain_mcp_adapters" "$SP"/langchain_mcp_adapters-*.dist-info - # 用 pip download 拿到钉版 whl,再 unzip 进 site-packages。 - # 这比 pip install --target --force-reinstall 更可控,也不会触发 resolution。 mkdir -p /tmp/octop-pinned python3.12 -m pip download --no-deps --no-cache-dir -d /tmp/octop-pinned \ "mcp==1.28.1" "langchain-mcp-adapters==0.3.0" @@ -210,13 +211,9 @@ jobs: echo "Extracting pinned wheel: $whl" python3.12 -m zipfile -e "$whl" "$SP" done - # 把核心 whl 也内置进去,供飞牛主机离线重装/回滚使用。 cp dist/octop-*.whl "$SP/octop.whl" - # 校验:必须能走通真正的启动导入链(octop.launch → octop.infra.server → - # harness_agent.mcp → langchain_mcp_adapters)。仅 import octop 不会触发该链路, - # 故这里显式导入启动入口,确保 mcp/langchain-mcp-adapters 组合确实可用。 PYTHONPATH="$SP" python3.12 \ - -c "from octop.launch import run_foreground_blocking; import mcp, langchain_mcp_adapters; 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; print('octop', octop.__version__, '| mcp', mcp.__version__, '| lcma', langchain_mcp_adapters.__version__)" } 2>&1 | tee -a "$LOG" - name: Build native FPK @@ -246,8 +243,7 @@ jobs: if-no-files-found: ignore release: - # 即使原生版失败(continue-on-error)也要发布 Docker 版; - # 原生版成功时 artifacts/native-fpk 存在,会被一并打包进发布。 + # 即使原生版失败(continue-on-error)也要发布 Docker 版。 if: always() needs: [fpk, native, version] runs-on: ubuntu-latest @@ -257,8 +253,6 @@ jobs: fetch-depth: 0 fetch-tags: true - # 分别按名称下载,单个大产物(native-fpk ~560MB)单独下载比「下载全部」更稳定, - # 避免 GitHub artifact 服务对“全部”模式偶发 5 次重试失败。 - name: Download docker fpk artifact (required) uses: actions/download-artifact@v4 with: @@ -267,7 +261,6 @@ jobs: if-no-files-found: error - name: Download native fpk artifact (best-effort) - # 原生版即使下载/解压偶发失败也不阻塞发布:Docker 版始终会发布。 continue-on-error: true uses: actions/download-artifact@v4 with: @@ -287,10 +280,7 @@ jobs: run: | VER="${{ needs.version.outputs.version }}" ITER="${{ needs.version.outputs.iter }}" - TAG="fnos-vanilla-${VER}-${ITER}" - # 找本分支历史中可达的最近固定版本 tag(排除滚动 tag)。 - # 不能直接 git describe:若历史 tag 误指向其它分支(如 main)的 commit, - # describe 会失败并回退到仓库根,导致更新说明把整个历史都列出来。 + IMAGE="${{ needs.version.outputs.image }}" PREV_TAG="" for t in $(git tag --list "fnos-vanilla-*" --sort=-creatordate); do [ "$t" = "fnos-vanilla-latest" ] && continue @@ -301,11 +291,10 @@ jobs: done echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT" { - # 安装说明置顶,用户一眼看到下载文件 echo "## 安装说明" echo "" echo "1. 飞牛应用中心 → 设置 → 手动安装应用,选择对应 .fpk。" - echo "2. Docker 版 **Fnos-octop-vanilla-${VER}-${ITER}.fpk**(约 80KB):飞牛自动从 Docker Hub 拉取镜像运行,镜像为 \\"jubaoliang/octop:vanilla\\"。" + echo "2. Docker 版 **Fnos-octop-vanilla-${VER}-${ITER}.fpk**(约 80KB):飞牛自动从 GHCR 拉取 \`${IMAGE}:latest\`。" echo "3. 本地版 **Fnos-octop-vanilla-native-${VER}-${ITER}.fpk**(约 200MB):非 Docker,原生运行在飞牛主机;安装时自动关联系统 Python 3.12 开发工具;浏览器、远程桌面等附加组件在 Octop 应用内按需安装。" echo "" echo "---" @@ -313,7 +302,6 @@ jobs: echo "" echo "## 本次更新(v${VER}-${ITER})" echo "" - # 只列相对上一版的非 merge commit,最多 30 条,避免说明冗长 if [ -n "$PREV_TAG" ]; then git log --oneline --no-merges "$PREV_TAG"..HEAD | head -30 else @@ -322,15 +310,12 @@ jobs: } > /tmp/release-body.md cat /tmp/release-body.md - # --- 固定版本 release:保留历史 --- - name: Publish fixed release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.version.outputs.tag }} - # 显式指向构建所用 commit,避免 GitHub 默认指向 main 分支 HEAD - # (此前 fnos-vanilla-* tag 全部错误指向 main 的 commit) target_commitish: ${{ github.sha }} - name: Octop (FnOS Vanilla) v${{ needs.version.outputs.pkg_ver }} + name: Octop (FnOS) v${{ needs.version.outputs.pkg_ver }} body_path: /tmp/release-body.md files: dist/*.fpk draft: false @@ -338,7 +323,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # --- 滚动 release fnos-vanilla-latest:始终指向最新版,只保留最新 assets --- - name: Delete rolling release if exists env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -359,7 +343,7 @@ jobs: with: tag_name: fnos-vanilla-latest target_commitish: ${{ github.sha }} - name: Octop (FnOS Vanilla) latest + name: Octop (FnOS) latest body_path: /tmp/release-body.md files: dist/*.fpk draft: false diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml index 4a453911..df079568 100644 --- a/.github/workflows/octop-desktop.yml +++ b/.github/workflows/octop-desktop.yml @@ -4,11 +4,16 @@ name: Octop Desktop Package # 打的 tag 不会触发 push workflow)。产物由本 workflow 的 release job upsert 到同一 # GitHub Release。workflow_dispatch 保留给手工补包 / 按平台重跑。 # 不要对任意分支 push 跑六平台矩阵。 +# pull_request(desktop/**)打 darwin-* + windows-*,不跑 linux。 on: push: tags: - "v*" + pull_request: + paths: + - "desktop/**" + - ".github/workflows/octop-desktop.yml" workflow_dispatch: inputs: platforms: @@ -69,9 +74,57 @@ jobs: if-no-files-found: error retention-days: 7 + # Job-level `if` cannot read `matrix.*` (GitHub 422). Filter here so only + # selected runners start — PRs → darwin-* + windows-*; dispatch honors `platforms`. + select-platforms: + name: Select platforms + if: github.event_name != 'workflow_dispatch' || github.event.inputs.attach_from_run == '' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set.outputs.matrix }} + steps: + - id: set + env: + EVENT_NAME: ${{ github.event_name }} + PLATFORMS: ${{ github.event.inputs.platforms || 'all' }} + run: | + set -euo pipefail + python3 <<'PY' + import json + import os + + all_plats = [ + {"plat": "linux-amd64", "arch": "amd64", "os": "ubuntu-latest"}, + {"plat": "linux-arm64", "arch": "arm64", "os": "ubuntu-24.04-arm"}, + {"plat": "darwin-arm64", "arch": "arm64", "os": "macos-14"}, + {"plat": "darwin-amd64", "arch": "amd64", "os": "macos-15-intel"}, + {"plat": "windows-amd64", "arch": "amd64", "os": "windows-latest"}, + {"plat": "windows-arm64", "arch": "arm64", "os": "windows-11-arm"}, + ] + if os.environ["EVENT_NAME"] == "pull_request": + sel = "darwin-arm64,darwin-amd64,windows-amd64,windows-arm64" + else: + sel = os.environ.get("PLATFORMS", "all").lower().replace(" ", "") + known = {row["plat"] for row in all_plats} + if sel == "all": + include = all_plats + else: + wanted = {part for part in sel.split(",") if part} + unknown = wanted - known + if unknown: + raise SystemExit(f"unknown platform(s): {', '.join(sorted(unknown))}") + include = [row for row in all_plats if row["plat"] in wanted] + if not include: + raise SystemExit(f"no platforms selected: {sel}") + matrix = json.dumps({"include": include}, separators=(",", ":")) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: + fh.write(f"matrix={matrix}\n") + print(matrix) + PY + package: name: "${{ matrix.plat }}" - needs: frontend + needs: [frontend, select-platforms] runs-on: ${{ matrix.os }} # Matrix runners are native for each plat; pin host detection so x64 Git Bash # on windows-11-arm does not mis-classify the job as windows-amd64 cross-build. @@ -79,61 +132,25 @@ jobs: GREEN_HOST_PLAT: ${{ matrix.plat }} strategy: fail-fast: false - matrix: - include: - - plat: linux-amd64 - arch: amd64 - os: ubuntu-latest - - plat: linux-arm64 - arch: arm64 - os: ubuntu-24.04-arm - - plat: darwin-arm64 - arch: arm64 - os: macos-14 - - plat: darwin-amd64 - arch: amd64 - os: macos-15-intel - - plat: windows-amd64 - arch: amd64 - os: windows-latest - - plat: windows-arm64 - arch: arm64 - os: windows-11-arm + matrix: ${{ fromJSON(needs.select-platforms.outputs.matrix) }} defaults: run: shell: bash steps: - uses: actions/checkout@v5 - - name: Decide whether to build this platform - id: want - run: | - set -euo pipefail - sel="${{ github.event.inputs.platforms || 'all' }}" - sel="$(echo "$sel" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" - plat="${{ matrix.plat }}" - if [[ "$sel" == "all" || ",$sel," == *",$plat,"* ]]; then - echo "build=true" >> "$GITHUB_OUTPUT" - else - echo "build=false" >> "$GITHUB_OUTPUT" - echo "Skipping ${plat} (selection=${sel})" - fi - - uses: actions/download-artifact@v5 - if: steps.want.outputs.build == 'true' with: name: dashboard-dist path: src/octop/dashboard - uses: astral-sh/setup-uv@v6 - if: steps.want.outputs.build == 'true' with: enable-cache: true python-version: "3.12" # GitHub Cache outages must not fail the build — PBS download is cheap enough. - name: Cache python-build-standalone downloads - if: steps.want.outputs.build == 'true' continue-on-error: true uses: actions/cache@v5 with: @@ -141,13 +158,11 @@ jobs: key: pbs-${{ env.PBS_TAG }}-${{ env.PBS_PY }}-${{ matrix.plat }} - name: Bootstrap portable CPython - if: steps.want.outputs.build == 'true' env: GREEN_HOST_PLAT: ${{ matrix.plat }} run: bash desktop/portable/bootstrap-runtime.sh "${{ matrix.plat }}" - name: Assemble green zip - if: steps.want.outputs.build == 'true' env: GREEN_HOST_PLAT: ${{ matrix.plat }} run: | @@ -156,7 +171,6 @@ jobs: bash desktop/portable/package.sh "${{ matrix.plat }}" - name: Smoke import (native host only) - if: steps.want.outputs.build == 'true' run: | set -euo pipefail staging="desktop/portable/release/Octop-${{ matrix.plat }}" @@ -192,23 +206,20 @@ jobs: fi - uses: actions/setup-go@v6 - if: steps.want.outputs.build == 'true' with: go-version: "1.25.x" cache-dependency-path: desktop/src/go.sum - name: Install Linux desktop build dependencies - if: steps.want.outputs.build == 'true' && runner.os == 'Linux' + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev - name: Install Wails v3 CLI - if: steps.want.outputs.build == 'true' run: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13 - name: Package desktop app with bundled portable runtime - if: steps.want.outputs.build == 'true' working-directory: desktop/src run: >- wails3 task package @@ -220,7 +231,6 @@ jobs: # (Octop-.zip containing another Octop-.zip). Affects every plat. # With archive:false, artifact name is the filename (name: is ignored). - uses: actions/upload-artifact@v7 - if: steps.want.outputs.build == 'true' with: path: desktop/portable/release/Octop-${{ matrix.plat }}.zip archive: false @@ -228,7 +238,6 @@ jobs: retention-days: 14 - name: Upload bundled desktop package - if: steps.want.outputs.build == 'true' uses: actions/upload-artifact@v7 with: path: desktop/src/bin/Octop-Desktop-${{ matrix.plat }}.* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de83ed74..48fbe6b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,12 @@ name: Release +# 版本标签 v* → 测包、发 PyPI、建 GitHub Release,再级联: +# - Sync Main Into Develop +# - Build Octop FPK(复用本 Release 的 wheel;等待 docker-publish 的 GHCR 镜像) +# +# 与 docker-publish.yml / octop-desktop.yml 并行(由 Auto Tag 同时 dispatch, +# 或人工 push v* tag 同时触发)。桌面产物由 Desktop Package 挂到同一 GitHub Release。 + on: push: tags: @@ -20,6 +27,7 @@ jobs: build: name: Build distributions runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v4 with: @@ -141,3 +149,20 @@ jobs: gh workflow run sync-main-to-develop.yml \ --repo "${{ github.repository }}" \ --ref main + + # FnOS FPK:复用本 Release 的 wheel + docker-publish 的 GHCR 镜像。 + # 与 sync-develop 并行;失败不阻断发版。 + trigger-fnos: + name: Trigger FnOS FPK build + needs: github-release + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Dispatch Build Octop FPK + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # --ref 用版本 tag,保证 FPK 打在与 PyPI / 镜像相同的 commit 上 + gh workflow run fnos-build-fpk.yml \ + --repo "${{ github.repository }}" \ + --ref "${{ github.ref_name }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index f75bddd5..02e393fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ ## [Unreleased] +## [0.9.30] - 2026-08-31 + +### 新增 + +- 腾讯云 Token Plan 企业版与 Hy 套餐 +- WeKnora、Dify 连接器,以及自定义 MCP 的 OAuth +- 备份/恢复、聊天工具栏、SSO 预设与更友好的供应商错误提示 +- 技能展示本地化;知识库可配置文档数量上限;钉钉扫码注册 +- 对话接入 ask-user-question 人机确认流程 + +### 修复 + +- 专家根目录、连接器排序、飞牛图标及主题确认对话框等界面问题 +- 聊天中文语音识别跟随界面语言 +- MCP OAuth 刷新失败需重新授权;渠道异常 thinking 输出过滤 +- 数据库 v10 迁移遗漏 thread projection 表 +- 飞牛 FPK 无效在线升级与原生版启动加载;长会话相关问题 +- 通道弹框文案统一为「通道」,新建默认实时过程 +- `octop acp` 启动即崩溃(CLI 注册表属性应对齐 `acp_cmd`) + +## [0.9.29] - 2026-08-27 + +### 修复 + +- 长会话卡死:聊天历史改为独立投影分页加载,并支持后台迁移旧会话(不再同步扫 checkpoint) + +### 变更 + +- 依赖:`orcakit-harness-agent[all]>=0.9.27`、`harness-memory>=0.9.7`、`harness-browser>=0.7.6`(自动 full VACUUM 关闭,空闲维护只走 lifecycle GC + incremental `nudge_vacuum`) + ## [0.9.28] - 2026-08-26 ### 修复 diff --git a/README.md b/README.md index 78a3a558..5890e029 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Python 3.12+ License: MIT - Version + Version PyPI Code Style: Ruff GitHub stars diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 8d06a092..8e841ae4 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -14872,24 +14872,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://mirrors.tencent.com/npm/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://mirrors.tencent.com/npm/yargs/-/yargs-17.7.2.tgz", diff --git a/dashboard/src/api/modules/backup.ts b/dashboard/src/api/modules/backup.ts index 712c8a01..2496ba83 100644 --- a/dashboard/src/api/modules/backup.ts +++ b/dashboard/src/api/modules/backup.ts @@ -19,9 +19,18 @@ export interface AutoBackupSettings { scheduled?: boolean; } +export type BackupOperationKind = "create" | "restore" | "auto" | "export"; + +export interface BackupStatusResponse { + busy: boolean; + operation: BackupOperationKind | null; +} + export const backupApi = { listBackups: () => request("/admin/backup/list"), + getStatus: () => request("/admin/backup/status"), + getAutoSettings: () => request("/admin/backup/auto"), updateAutoSettings: (body: { diff --git a/dashboard/src/api/modules/channel.ts b/dashboard/src/api/modules/channel.ts index 1fe842c4..ffc5ef07 100644 --- a/dashboard/src/api/modules/channel.ts +++ b/dashboard/src/api/modules/channel.ts @@ -150,6 +150,29 @@ export const channelApi = { body: JSON.stringify({ qrcode_token }), }), + /** Start DingTalk one-click application registration. */ + dingtalkQrcodeGenerate: (agentId: string) => + request<{ + registration_id: string; + qrcode_url: string; + user_code: string; + expires_in: number; + interval: number; + }>(`/agents/${agentId}/channels/dingtalk/qrcode/generate`, { + method: "POST", + }), + + /** Poll DingTalk registration; credentials are persisted by the backend. */ + dingtalkQrcodePoll: (agentId: string, registration_id: string) => + request<{ + status: "waiting" | "success" | "failed" | "expired"; + channel_id?: string; + message?: string; + }>(`/agents/${agentId}/channels/dingtalk/qrcode/poll`, { + method: "POST", + body: JSON.stringify({ registration_id }), + }), + /** Start the Feishu bot auto-creation flow. */ feishuBotCreatorStart: ( agentId: string, diff --git a/dashboard/src/api/modules/connectors.ts b/dashboard/src/api/modules/connectors.ts index 376271fd..4b2dde42 100644 --- a/dashboard/src/api/modules/connectors.ts +++ b/dashboard/src/api/modules/connectors.ts @@ -18,6 +18,17 @@ export interface ConnectorCatalogEntry { supports_quick_auth?: boolean; oauth_mode?: "dynamic" | "configured" | null; oauth_ready?: boolean; + credential_fields?: ConnectorCredentialField[]; +} + +export interface ConnectorCredentialField { + key: string; + label: string; + field_type: "text" | "password" | "url" | "tags"; + required: boolean; + placeholder?: string | null; + help?: string | null; + secret: boolean; } export interface ConnectorAuthInfo { @@ -42,6 +53,7 @@ export interface ConnectorInstance { } export interface ConnectorCredentialsPreview { + [key: string]: unknown; token_configured?: boolean; oauth_configured?: boolean; expires_at?: number; @@ -82,11 +94,29 @@ export interface ConnectorProbeResult { tool_count?: number; tools?: { name: string; description: string }[]; error?: string; + error_type?: string; status_code?: number; + oauth?: { + available: boolean; + issuer?: string; + resource?: string; + }; +} + +export interface WeKnoraLocalDetection { + found: boolean; + base_url?: string; + console_url?: string; } export type CustomMcpTransport = "streamable_http" | "stdio"; +export interface CustomMcpOAuthPreview { + configured?: boolean; + required?: boolean; + expires_at?: number; +} + export interface CustomMcpServerSpec { transport: CustomMcpTransport; url?: string; @@ -99,8 +129,13 @@ export interface CustomMcpServerSpec { display_name?: string; /** When true, chat composer pre-selects this MCP server. */ default_open?: boolean; + oauth?: CustomMcpOAuthPreview; } +export type OAuthStartTarget = + | { type: "catalog"; kind: string } + | { type: "custom_mcp"; server_name: string }; + export type CustomMcpServers = Record; export interface ConnectorCliInstallResult { @@ -141,10 +176,15 @@ export interface FeishuUserAuthCompleteResult { export const connectorsApi = { catalog: () => request("/connectors/catalog"), + detectLocalWeKnora: () => + request("/connectors/weknora/detect-local"), + listInstances: () => request("/connector-instances"), getInstance: (instanceId: string) => - request(`/connector-instances/${instanceId}`), + request( + `/connector-instances/${encodeURIComponent(instanceId)}`, + ), createInstance: (body: { kind: string; @@ -158,23 +198,41 @@ export const connectorsApi = { }), deleteInstance: (instanceId: string) => - request(`/connector-instances/${instanceId}`, { method: "DELETE" }), + request(`/connector-instances/${encodeURIComponent(instanceId)}`, { + method: "DELETE", + }), patchInstance: ( instanceId: string, body: { status?: "active" | "disabled"; default_open?: boolean }, ) => - request(`/connector-instances/${instanceId}`, { - method: "PATCH", - body: JSON.stringify(body), - }), + request( + `/connector-instances/${encodeURIComponent(instanceId)}`, + { + method: "PATCH", + body: JSON.stringify(body), + }, + ), testInstance: (instanceId: string) => - request(`/connector-instances/${instanceId}/test`, { - method: "POST", - }), + request( + `/connector-instances/${encodeURIComponent(instanceId)}/test`, + { + method: "POST", + }, + ), - oauthStart: (kind: string, redirectAfter?: string) => + oauthStart: (target: OAuthStartTarget, redirectAfter?: string) => + request<{ authorize_url: string; state_id: string }>( + "/connectors/oauth/start", + { + method: "POST", + body: JSON.stringify({ target, redirect_after: redirectAfter }), + }, + ), + + /** @deprecated Prefer oauthStart with `{ type: "catalog", kind }`. */ + oauthStartCatalog: (kind: string, redirectAfter?: string) => request<{ authorize_url: string; state_id: string }>( `/connectors/oauth/${kind}/start`, { @@ -184,9 +242,12 @@ export const connectorsApi = { ), oauthPending: (stateId: string) => - request<{ kind: string; tokens: Record }>( - `/connectors/oauth/pending/${stateId}`, - ), + request<{ + kind: string; + tokens: Record; + server_name?: string; + applied?: boolean; + }>(`/connectors/oauth/pending/${stateId}`), authorizeUrl: (kind: string) => request<{ authorize_url: string | null }>( @@ -284,7 +345,10 @@ export const connectorsApi = { body: JSON.stringify({ servers }), }), - patchCustomMcpServer: (name: string, body: { enabled: boolean }) => + patchCustomMcpServer: ( + name: string, + body: { enabled?: boolean; default_open?: boolean }, + ) => request<{ servers: CustomMcpServers }>( `/connectors/custom-mcp/servers/${encodeURIComponent(name)}`, { diff --git a/dashboard/src/api/modules/knowledgeBases.ts b/dashboard/src/api/modules/knowledgeBases.ts index 6a66d2d7..d8cfb256 100644 --- a/dashboard/src/api/modules/knowledgeBases.ts +++ b/dashboard/src/api/modules/knowledgeBases.ts @@ -37,6 +37,7 @@ export interface KnowledgeBase { embedding_model: string; embedding_dim: number; doc_count: number; + max_documents: number; created_at: number; updated_at: number; } @@ -151,6 +152,7 @@ export const knowledgeBasesApi = { default_open?: boolean; shared?: boolean; icon_name?: string; + max_documents?: number; }) => request("/knowledge-bases", { method: "POST", @@ -165,6 +167,7 @@ export const knowledgeBasesApi = { default_open?: boolean; shared?: boolean; icon_name?: string; + max_documents?: number; }, ) => request(`/knowledge-bases/${id}`, { diff --git a/dashboard/src/api/modules/octopThreads.ts b/dashboard/src/api/modules/octopThreads.ts index 8d031552..9791c0de 100644 --- a/dashboard/src/api/modules/octopThreads.ts +++ b/dashboard/src/api/modules/octopThreads.ts @@ -34,6 +34,10 @@ export interface OctopThreadHistory { has_more?: boolean; limit?: number; offset?: number; + /** Legacy checkpoint is being projected by the bounded background worker. */ + history_loading?: boolean; + history_status?: "pending" | "queued" | "running" | "ready" | "failed"; + history_retry_after_ms?: number; /** True while a turn is still streaming server-side for this thread. */ turn_active?: boolean; /** Pending tool approval for this thread (survives page reload). */ @@ -69,6 +73,18 @@ export interface ContextUsageBreakdown { segments: ContextUsageSegment[]; } +export interface HistoryMigrationStatus { + remaining: number; + pending: number; + queued: number; + running: number; + failed: number; + processing: boolean; + agent_busy: boolean; + can_start: boolean; + accepted?: number; +} + export const CHAT_HISTORY_PAGE_SIZE = 25; export const octopThreadsApi = { @@ -83,6 +99,17 @@ export const octopThreadsApi = { { method: "POST" }, ), + historyMigrationStatus: (agentId: string) => + request( + `/agents/${encodeURIComponent(agentId)}/history-migration/status`, + ), + + startHistoryMigration: (agentId: string) => + request( + `/agents/${encodeURIComponent(agentId)}/history-migration/start`, + { method: "POST" }, + ), + history: ( agentId: string, threadId: string, diff --git a/dashboard/src/api/types/hitl.ts b/dashboard/src/api/types/hitl.ts index 775f4c16..43be966f 100644 --- a/dashboard/src/api/types/hitl.ts +++ b/dashboard/src/api/types/hitl.ts @@ -14,3 +14,59 @@ export interface HitlPendingPayload { action_requests: HitlActionRequest[]; review_configs?: HitlReviewConfig[]; } + +/** Tool whose HITL pause is a question for the user, not an approval. */ +export const ASK_USER_TOOL_NAME = "ask_user_question"; + +export interface AskOption { + label: string; + description?: string; +} + +export interface AskQuestion { + question: string; + header?: string; + options?: AskOption[]; + multi_select?: boolean; +} + +/** Extract the `questions` payload from an `ask_user_question` pause. */ +export function extractAskQuestions( + actions: HitlActionRequest[] | undefined, +): AskQuestion[] { + 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); + } + return []; +} + +export function isAskHitl(actions: HitlActionRequest[] | undefined): boolean { + return Boolean(actions?.some((a) => a.name === ASK_USER_TOOL_NAME)); +} diff --git a/dashboard/src/assets/connectors/dify.svg b/dashboard/src/assets/connectors/dify.svg new file mode 100644 index 00000000..b0b4ba21 --- /dev/null +++ b/dashboard/src/assets/connectors/dify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/src/assets/connectors/index.ts b/dashboard/src/assets/connectors/index.ts index 15aa1264..067b3c77 100644 --- a/dashboard/src/assets/connectors/index.ts +++ b/dashboard/src/assets/connectors/index.ts @@ -2,6 +2,7 @@ import tencentArdot from "./tencent-ardot.png"; import baiduMap from "./baidu-map.png"; import ctripWendao from "./ctrip-wendao.png"; import dida365 from "./dida365.png"; +import dify from "./dify.svg"; import feishuCli from "./feishu-cli.png"; import fliggy from "./fliggy.png"; import meituanTravel from "./meituan-travel.png"; @@ -16,6 +17,7 @@ import tencentLexiang from "./tencent-lexiang.png"; import tencentWeiyun from "./tencent-weiyun.png"; import wechatReading from "./wechat-reading.png"; import wecomCli from "./wecom-cli.png"; +import weknora from "./weknora.svg"; import youdaoNote from "./youdao-note.png"; import yuandian from "./yuandian.png"; @@ -36,10 +38,12 @@ export const CONNECTOR_LOGOS: Record = { "tencent-meeting": tencentMeeting, notion, dida365: dida365, + dify, "tencent-news": tencentNews, "wechat-reading": wechatReading, "youdao-note": youdaoNote, "tencent-weiyun": tencentWeiyun, + weknora, }; export function getConnectorLogo(kind: string): string | undefined { diff --git a/dashboard/src/assets/connectors/weknora.svg b/dashboard/src/assets/connectors/weknora.svg new file mode 100644 index 00000000..8dd0fc59 --- /dev/null +++ b/dashboard/src/assets/connectors/weknora.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/src/components/AntdAppProvider.tsx b/dashboard/src/components/AntdAppProvider.tsx index 339c139f..5683f05d 100644 --- a/dashboard/src/components/AntdAppProvider.tsx +++ b/dashboard/src/components/AntdAppProvider.tsx @@ -1,16 +1,19 @@ import { App } from "antd"; import { useEffect, type ReactNode } from "react"; import { bindAntdMessage, unbindAntdMessage } from "../utils/antdMessage"; +import { bindAntdModal, unbindAntdModal } from "../utils/antdModal"; -/** Captures App.useApp() APIs for non-hook call sites (utils, hooks, callbacks). */ +/** Captures App.useApp() APIs for non-hook call sites (utils). */ function AntdAppApiBinder() { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); useEffect(() => { bindAntdMessage(message); + bindAntdModal(modal); return () => { unbindAntdMessage(); + unbindAntdModal(); }; - }, [message]); + }, [message, modal]); return null; } diff --git a/dashboard/src/context/BackupOperationContext.tsx b/dashboard/src/context/BackupOperationContext.tsx new file mode 100644 index 00000000..b6906767 --- /dev/null +++ b/dashboard/src/context/BackupOperationContext.tsx @@ -0,0 +1,235 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { App } from "antd"; +import { useTranslation } from "react-i18next"; + +import { + backupApi, + type BackupOperationKind, + type BackupStatusResponse, +} from "../api/modules/backup"; +import { apiErrorMessage } from "../utils/apiError"; + +export type BackupOpKind = BackupOperationKind | "upload" | null; + +interface BackupOperationContextValue { + kind: BackupOpKind; + restoreTarget: string | null; + uploadPercent: number | null; + /** True while create / restore / auto / export / upload is in flight. */ + busy: boolean; + creating: boolean; + restoring: boolean; + autoRunning: boolean; + createBackup: () => Promise; + runAutoBackup: () => Promise; + restoreBackup: (name: string, restoreConfig: boolean) => Promise; + uploadBackup: (file: File) => Promise; + /** Align UI with server lock (call from backup panel while mounted). */ + syncFromServer: () => Promise; + /** Called by the backup panel so list refresh can run after remote ops finish. */ + setOnSettled: (fn: (() => void) | null) => void; +} + +const BackupOperationContext = + createContext(null); + +function mapServerOperation( + op: BackupStatusResponse["operation"], +): BackupOpKind { + if (op === "create" || op === "auto" || op === "export") return op; + if (op === "restore") return "restore"; + return "create"; +} + +export function BackupOperationProvider({ children }: { children: ReactNode }) { + const { message } = App.useApp(); + const { t } = useTranslation(); + const [kind, setKind] = useState(null); + const [restoreTarget, setRestoreTarget] = useState(null); + const [uploadPercent, setUploadPercent] = useState(null); + /** Local request owns the kind; server poll must not clear it mid-flight. */ + const localOwnedRef = useRef(false); + const kindRef = useRef(null); + const onSettledRef = useRef<(() => void) | null>(null); + kindRef.current = kind; + + const setOnSettled = useCallback((fn: (() => void) | null) => { + onSettledRef.current = fn; + }, []); + + const notifySettled = useCallback(() => { + onSettledRef.current?.(); + }, []); + + const syncFromServer = useCallback(async () => { + try { + const status = await backupApi.getStatus(); + if (localOwnedRef.current) { + return; + } + if (status.busy) { + setKind((prev) => prev ?? mapServerOperation(status.operation)); + } else { + setKind((prev) => (prev === "upload" ? prev : null)); + setRestoreTarget(null); + } + } catch { + // Status is best-effort; ignore transient / permission errors. + } + }, []); + + const beginLocal = useCallback((next: BackupOpKind) => { + if (localOwnedRef.current || kindRef.current !== null) { + return false; + } + localOwnedRef.current = true; + setKind(next); + return true; + }, []); + + const createBackup = useCallback(async () => { + if (!beginLocal("create")) return false; + try { + await backupApi.createBackup(); + message.success(t("backup.createSuccess")); + notifySettled(); + return true; + } catch (err: unknown) { + message.error(apiErrorMessage(err, t("backup.createFailed"), t)); + return false; + } finally { + localOwnedRef.current = false; + setKind(null); + void syncFromServer(); + } + }, [beginLocal, message, notifySettled, syncFromServer, t]); + + const runAutoBackup = useCallback(async () => { + if (!beginLocal("auto")) return false; + try { + await backupApi.runAutoBackup(); + message.success(t("backup.autoRunSuccess")); + notifySettled(); + return true; + } catch (err: unknown) { + message.error(apiErrorMessage(err, t("backup.autoRunFailed"), t)); + return false; + } finally { + localOwnedRef.current = false; + setKind(null); + void syncFromServer(); + } + }, [beginLocal, message, notifySettled, syncFromServer, t]); + + const restoreBackup = useCallback( + async (name: string, restoreConfig: boolean) => { + if (!beginLocal("restore")) return false; + setRestoreTarget(name); + try { + const result = await backupApi.restoreBackup(name, restoreConfig); + message.success( + t("backup.importSuccess", { + agents: result.agents, + files: result.workspace_files, + }), + ); + notifySettled(); + return true; + } catch (err: unknown) { + message.error(apiErrorMessage(err, t("backup.importFailed"), t)); + return false; + } finally { + localOwnedRef.current = false; + setKind(null); + setRestoreTarget(null); + void syncFromServer(); + } + }, + [beginLocal, message, notifySettled, syncFromServer, t], + ); + + const uploadBackup = useCallback( + async (file: File) => { + if (!beginLocal("upload")) return false; + setUploadPercent(0); + try { + await backupApi.uploadBackup(file, (p) => setUploadPercent(p)); + setUploadPercent(100); + message.success(t("backup.uploadSuccess", { name: file.name })); + notifySettled(); + return true; + } catch (err: unknown) { + const detail = err instanceof Error ? err.message : String(err); + message.error(detail || t("backup.uploadFailed")); + return false; + } finally { + localOwnedRef.current = false; + setKind(null); + setUploadPercent(null); + } + }, + [beginLocal, message, notifySettled, t], + ); + + const creating = kind === "create" || kind === "export"; + const restoring = kind === "restore"; + const autoRunning = kind === "auto"; + const busy = kind !== null; + + const value = useMemo( + () => ({ + kind, + restoreTarget, + uploadPercent, + busy, + creating, + restoring, + autoRunning, + createBackup, + runAutoBackup, + restoreBackup, + uploadBackup, + syncFromServer, + setOnSettled, + }), + [ + kind, + restoreTarget, + uploadPercent, + busy, + creating, + restoring, + autoRunning, + createBackup, + runAutoBackup, + restoreBackup, + uploadBackup, + syncFromServer, + setOnSettled, + ], + ); + + return ( + + {children} + + ); +} + +export function useBackupOperation(): BackupOperationContextValue { + const ctx = useContext(BackupOperationContext); + if (!ctx) { + throw new Error( + "useBackupOperation must be used within BackupOperationProvider", + ); + } + return ctx; +} diff --git a/dashboard/src/hooks/useCardTableView.ts b/dashboard/src/hooks/useCardTableView.ts index 4ea30705..bc184bc1 100644 --- a/dashboard/src/hooks/useCardTableView.ts +++ b/dashboard/src/hooks/useCardTableView.ts @@ -4,12 +4,13 @@ import { useIsMobile } from "./useIsMobile"; export type CardTableViewMode = "card" | "table"; /** - * Shared card/table view toggle. On mobile, card view is always shown - * (matches Tasks page behaviour) regardless of Segmented selection. + * Shared card/table view toggle. + * The selected mode is honoured on all viewports (including mobile); + * tables rely on existing horizontal-scroll CSS in layout.css. */ export function useCardTableView(defaultMode: CardTableViewMode = "table") { const isMobile = useIsMobile(); const [viewMode, setViewMode] = useState(defaultMode); - const showCardView = isMobile || viewMode === "card"; + const showCardView = viewMode === "card"; return { isMobile, viewMode, setViewMode, showCardView }; } diff --git a/dashboard/src/hooks/useVoiceInput.ts b/dashboard/src/hooks/useVoiceInput.ts index e486de15..bad26a83 100644 --- a/dashboard/src/hooks/useVoiceInput.ts +++ b/dashboard/src/hooks/useVoiceInput.ts @@ -1,23 +1,41 @@ import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { voiceApi, type ActiveVoice } from "../api/modules/voice"; +import { speechLocaleFromUi } from "../utils/localePrefs"; import { cachedActiveVoice, fetchActiveVoice } from "./useVoiceConfig"; import { message as antMessage } from "@/utils/antdMessage"; +interface BrowserSpeechRecognitionAlternative { + transcript?: string; +} + +interface BrowserSpeechRecognitionResult { + isFinal: boolean; + length: number; + [index: number]: BrowserSpeechRecognitionAlternative; +} + interface BrowserSpeechRecognitionResultEvent { - results: ArrayLike<{ [index: number]: { transcript?: string } }>; + resultIndex: number; + results: ArrayLike & { length: number }; +} + +interface BrowserSpeechRecognitionErrorEvent { + error?: string; } interface BrowserSpeechRecognition { lang: string; + continuous: boolean; interimResults: boolean; maxAlternatives: number; onresult: ((event: BrowserSpeechRecognitionResultEvent) => void) | null; - onerror: (() => void) | null; + onerror: ((event: BrowserSpeechRecognitionErrorEvent) => void) | null; onend: (() => void) | null; start: () => void; stop?: () => void; + abort?: () => void; } type SpeechRecognitionCtor = new () => BrowserSpeechRecognition; @@ -49,6 +67,12 @@ function pickRecorderMimeType(): string { return ""; } +/** + * Native Web Speech API transcription. + * + * Important: settle only on `onend` / error / timeout. Resolving in `onresult` + * races Chrome's `onend` and often yields empty text for Chinese utterances. + */ async function transcribeWithBrowser(language: string): Promise { const Ctor = getSpeechRecognition(); if (!Ctor) { @@ -57,7 +81,11 @@ async function transcribeWithBrowser(language: string): Promise { return new Promise((resolve, reject) => { const rec = new Ctor(); let settled = false; + let finalText = ""; + let interimText = ""; let timer = 0; + let lastError: string | undefined; + const settle = (fn: () => void) => { if (settled) return; settled = true; @@ -67,23 +95,67 @@ async function transcribeWithBrowser(language: string): Promise { rec.onend = null; fn(); }; + rec.lang = language; - rec.interimResults = false; + rec.continuous = false; + rec.interimResults = true; rec.maxAlternatives = 1; + rec.onresult = (event) => { - const text = event.results[0]?.[0]?.transcript?.trim() ?? ""; - settle(() => resolve(text)); + let finals = ""; + let interim = ""; + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + const piece = result?.[0]?.transcript ?? ""; + if (result?.isFinal) finals += piece; + else interim += piece; + } + if (finals) finalText += finals; + interimText = interim; + }; + + rec.onerror = (event) => { + lastError = event.error; + // no-speech / aborted: let onend resolve with whatever we have. + if (event.error === "no-speech" || event.error === "aborted") return; + settle(() => { + if (event.error === "network") { + reject(new Error("browser STT network")); + return; + } + reject(new Error(`browser STT failed: ${event.error ?? "unknown"}`)); + }); }; - rec.onerror = () => settle(() => reject(new Error("browser STT failed"))); - rec.onend = () => settle(() => resolve("")); + + rec.onend = () => { + const text = (finalText || interimText).trim(); + settle(() => { + if ( + !text && + lastError && + lastError !== "no-speech" && + lastError !== "aborted" + ) { + reject(new Error(`browser STT failed: ${lastError}`)); + return; + } + resolve(text); + }); + }; + timer = window.setTimeout(() => { try { rec.stop?.(); } catch { // Browser implementations differ; the timeout still settles below. } - settle(() => reject(new Error("browser STT timed out"))); + settle(() => { + const text = (finalText || interimText).trim(); + if (text) resolve(text); + else reject(new Error("browser STT timed out")); + }); }, BROWSER_STT_TIMEOUT_MS); + try { rec.start(); } catch (err) { @@ -102,17 +174,19 @@ export function canRecordAudio(): boolean { /** Check whether any STT method is available. */ export function isSttAvailable(): boolean { if (canRecordAudio()) return true; // server STT via MediaRecorder - return browserSttAvailable(); // browser STT (Android Chrome only) + return browserSttAvailable(); // browser STT (Chrome / Edge) } export function useVoiceInput(onText: (text: string) => void) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [recording, setRecording] = useState(false); const [transcribing, setTranscribing] = useState(false); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); - const language = navigator.language || "zh-CN"; + // Follow dashboard UI locale — not navigator.language (often en-US on + // machines used with Chinese UI / Chinese speech). + const language = speechLocaleFromUi(i18n.language); const stopRecording = useCallback(async () => { const recorder = mediaRecorderRef.current; @@ -136,37 +210,24 @@ export function useVoiceInput(onText: (text: string) => void) { setTranscribing(true); try { const active = await fetchActiveVoice(); - let text = ""; - if (active.stt === "browser" && browserSttAvailable()) { - text = await transcribeWithBrowser(language); - } else { - try { - const result = await voiceApi.transcribe(blob, language); - text = result.text?.trim() ?? ""; - } catch (err) { - const msg = err instanceof Error ? err.message : ""; - if (msg.includes("VOICE_BROWSER_ONLY") || msg.includes("422")) { - if (browserSttAvailable()) { - text = await transcribeWithBrowser(language); - } else { - antMessage.error(t("voice.sttProviderRequired")); - return; - } - } else { - // Server STT failed — try browser fallback if available. - if (browserSttAvailable()) { - antMessage.warning(t("voice.sttFallback")); - text = await transcribeWithBrowser(language); - } else { - throw err; - } - } - } + // SpeechRecognition cannot consume a recorded blob. If STT is still + // "browser", ask the user to configure a server provider (or use the + // click-to-talk browser path from a cold start). + if (active.stt === "browser") { + antMessage.error(t("voice.sttProviderRequired")); + return; } + const result = await voiceApi.transcribe(blob, language); + const text = result.text?.trim() ?? ""; if (text) onText(text); else antMessage.info(t("voice.sttEmpty")); - } catch { - antMessage.error(t("voice.sttFailed")); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + if (msg.includes("VOICE_BROWSER_ONLY") || msg.includes("422")) { + antMessage.error(t("voice.sttProviderRequired")); + } else { + antMessage.error(t("voice.sttFailed")); + } } finally { setTranscribing(false); } @@ -183,6 +244,13 @@ export function useVoiceInput(onText: (text: string) => void) { const text = await transcribeWithBrowser(language); if (text) onText(text); else antMessage.info(t("voice.sttEmpty")); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + if (msg.includes("network")) { + antMessage.error(t("voice.sttNetworkFailed")); + } else { + antMessage.error(t("voice.sttFailed")); + } } finally { setTranscribing(false); } diff --git a/dashboard/src/layouts/MainLayout/index.tsx b/dashboard/src/layouts/MainLayout/index.tsx index 87436f11..f6b8687f 100644 --- a/dashboard/src/layouts/MainLayout/index.tsx +++ b/dashboard/src/layouts/MainLayout/index.tsx @@ -5,6 +5,7 @@ import Sidebar from "../Sidebar"; import Header from "../Header"; import RailEdgeControl from "../../components/RailEdgeControl"; import { ServiceRestartProvider } from "../../context/ServiceRestartContext"; +import { BackupOperationProvider } from "../../context/BackupOperationContext"; import PwaUpdatePrompt from "../../components/PwaUpdatePrompt"; import { PwaAutoPrompt } from "../../components/PwaInstallPrompt"; import { @@ -187,181 +188,183 @@ export default function MainLayout() { return ( -

- {/* Mobile overlay backdrop */} - {isMobile && !collapsed && ( -
- )} - +
- - {!isMobile && ( - )} -
- {isChatRoute && !isMinimalLayout && (
- )} - - {/* Right column: mobile header (if any) + page content */} -
- {isMobile && - !( - SELF_HEADER_PATHS.has(currentPath) || - [...SELF_HEADER_PATHS].some((p) => - currentPath.startsWith(p + "/"), - ) - ) && ( -
+ + {!isMobile && ( + )} +
- + )} + + {/* Right column: mobile header (if any) + page content */} +
- + currentPath.startsWith(p + "/"), + ) + ) && ( +
+ )} + + - - - - {workbenchMounted && ( -
- - - -
- } - > - - - -
- )} - - {/* Keep Routes mounted when visiting Workbench so leaving/re-entering - does not remount every lazy page (lag + lost UI state). */} -
- {isFullscreen ? ( + + + + {workbenchMounted && (
- {routes} + + + +
+ } + > + + +
- ) : ( -
{routes}
)} -
- - + + {/* Keep Routes mounted when visiting Workbench so leaving/re-entering + does not remount every lazy page (lag + lost UI state). */} +
+ {isFullscreen ? ( +
+ {routes} +
+ ) : ( +
{routes}
+ )} +
+ + +
- + ); } diff --git a/dashboard/src/layouts/PageShell.module.less b/dashboard/src/layouts/PageShell.module.less index 259a1d7e..d201bc68 100644 --- a/dashboard/src/layouts/PageShell.module.less +++ b/dashboard/src/layouts/PageShell.module.less @@ -83,7 +83,8 @@ @media (max-width: 767px) { flex: none; - overflow: visible; + overflow-x: hidden; + overflow-y: visible; :global(.octop-tabs-content-holder), :global(.ant-tabs-content-holder), @@ -92,7 +93,9 @@ :global(.octop-tabs-tabpane), :global(.ant-tabs-tabpane) { height: auto !important; - overflow: visible; + max-width: 100%; + overflow-x: hidden; + overflow-y: visible; } } } diff --git a/dashboard/src/layouts/PageShell.tsx b/dashboard/src/layouts/PageShell.tsx index b6cebfee..0e81f416 100644 --- a/dashboard/src/layouts/PageShell.tsx +++ b/dashboard/src/layouts/PageShell.tsx @@ -170,8 +170,12 @@ function PageShell({ background: "var(--fn-bg-container, var(--fn-bg-elevated))", borderRadius: 8, padding: contentPad, - overflow: pinBody ? "hidden" : "auto", + // Mobile: never create a page-level horizontal scrollbar; wide + // tables scroll via antd scroll.x inside their own wrapper. + overflowX: pinBody || isMobile ? "hidden" : "auto", + overflowY: pinBody ? "hidden" : "auto", minHeight: 0, + minWidth: 0, display: pinBody ? "flex" : undefined, flexDirection: pinBody ? "column" : undefined, }} diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index bce08fe8..66ba02c2 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -69,7 +69,7 @@ "SETUP_REQUIRED": "Initial setup is required.", "DATABASE_NOT_EMPTY": "The target database already has users. Use an empty database, or log in with the existing admin.", "BACKUP_DRIVER_MISMATCH": "This backup was made with a different database engine (SQLite vs PostgreSQL). Switch the runtime to match the backup, or create a new backup on the current engine. Cross-engine restore is not supported.", - "BACKUP_IN_PROGRESS": "A backup is already in progress. Try again shortly.", + "BACKUP_IN_PROGRESS": "A backup or restore is already in progress. Try again shortly.", "FORBIDDEN": "Permission denied.", "NOT_FOUND": "Not found.", "USER_DISABLED": "This account has been disabled.", @@ -322,6 +322,8 @@ "documents": "Documents", "documentLimit": "{{count}} / {{max}} documents", "documentLimitReached": "This knowledge base already contains the maximum of {{count}} documents.", + "maxDocuments": "Document limit", + "maxDocumentsHint": "Maximum number of files in this knowledge base. 0 = unlimited, default 100.", "documentTooLarge": "Each document must be at most {{sizeMb}} MB.", "baseLimitReached": "You can create at most {{count}} knowledge bases.", "uploadHint": "Supports md / txt / pdf / docx / pptx. Max {{sizeMb}} MB per file.", @@ -778,7 +780,7 @@ "defaultModelPlaceholder": "Select a model", "skillPackagesLabel": "Optional Skill Packages", "skillPackagesHint": "Mount packages at create time (local storage with root /).", - "skillPackagesUnsupportedHint": "Skill packages unavailable: storage root is not /. Set it to /, or use customized / built-in skills.", + "skillPackagesUnsupportedHint": "Skill packages unavailable: when the storage root is not /, the sandbox cannot reach skill-package directories, so packages cannot be used.", "skillPackagesClearFailed": "Backend settings saved, but clearing mounted skill packages failed", "skillPackagesPlaceholder": "Select skill packages", "manifestWriteFailed": "Page configuration could not be saved.", @@ -840,7 +842,8 @@ "backendRootDirPlaceholder": "Browse and select a directory", "backendRootDirDesc": "Defaults to the server user's home directory ({{home}}). You may also browse from filesystem root `/`.", "backendRootDirDescAdmin": "Defaults to the server user's home directory ({{home}}). You may also browse from filesystem root `/`.", - "backendRootDirJailHint": "When you save a non-root directory, Octop best-effort installs bubblewrap on Linux so shell/skill commands run in a directory jail. If install is unavailable (or on other platforms), execute uses the normal local shell without a jail; file tools still use the virtual filesystem.", + "backendRootDirJailHint": "When saving to a non-root directory, Octop sandboxes the AI filesystem to that directory and blocks cross-directory access.", + "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", @@ -996,6 +999,7 @@ "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.", + "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.", @@ -1018,6 +1022,20 @@ "hintBlocking": "Sending is paused for this agent until slimming finishes.", "elapsed": "{{seconds}}s elapsed" }, + "historyMigration": { + "title": "Upgrade complete: {{count}} old chats still need optimization", + "readyHint": "Run it in the background now so very long chats do not pause on first open. New chats remain available, and only one old chat is read at a time.", + "runningHint": "Processing old chats one at a time in the background. New chats remain available; avoid repeatedly refreshing the page.", + "agentBusyHint": "This expert is running a task. Wait until it is idle before starting old-chat optimization.", + "queueBusyHint": "The migration queue is handling other chats. You can start when capacity is available; this page refreshes automatically.", + "retryHint": "{{count}} chats failed during the previous attempt and can be retried safely.", + "startFailed": "Could not start background processing. Make sure the agent is running, then retry.", + "start": "Optimize in background", + "running": "Optimizing", + "queueRest": "Queue remaining chats", + "waiting": "Waiting for queue", + "retry": "Retry failed chats" + }, "thinking": "Thinking", "continuing": "Continuing", "generating": "Generating", @@ -1141,6 +1159,22 @@ "rejectedLabel": "Rejected", "rejected": "Rejected by user" }, + "ask": { + "title": "A few questions first", + "other": "Something else…", + "freeTextPlaceholder": "Type your answer", + "next": "Next", + "review": "Review & send", + "reviewTag": "Review", + "back": "Back", + "submit": "Send answers", + "skip": "You decide", + "skipMessage": "You decide — pick the best default and tell me what you assumed.", + "answered": "Answered", + "answeredSummary": "Answered {{count}} question(s)", + "dismissedSummary": "Closed {{count}} question(s)", + "expand": "View" + }, "modifiedFiles": "Modified files ({{count}})", "openBrowser": "View browser", "openBrowserHint": "Agent is browsing the web", @@ -1161,6 +1195,11 @@ "remoteBrowserTitle": "Remote Browser", "dockTerminalTitle": "Terminal", "dockPhoneTitle": "Remote Phone", + "dockToolUiTitle": "Plugin tool", + "dockToolUiMissing": "Tool result is no longer available in this conversation.", + "openToolUiInDock": "Open in side panel", + "toolUiDockedHint": "Moved to side panel", + "toolUiDockedOpen": "Open side panel", "loadEarlierMessages": "Load earlier messages" }, "tools": { @@ -1208,6 +1247,7 @@ "mobile_handoff_to_user": "Hand off to user (mobile)", "read_env_file": "Read env file", "write_env_file": "Write env file", + "ask_user_question": "Ask the user", "generate_image": "Generate image", "generate_video": "Generate video" }, @@ -1227,6 +1267,7 @@ "categories": { "filesystem": "Files & shell", "orchestration": "Planning & sub-agents", + "interaction": "User interaction", "web": "Web & browser", "media": "Media generation", "memory": "Memory", @@ -1331,6 +1372,7 @@ "storedTitle": "Backup files", "storedDesc": "System backups are stored in the directory below. Download or restore with one click.", "createButton": "Create backup", + "creating": "Backing up…", "uploadButton": "Upload backup", "uploadSuccess": "Saved {{name}}", "uploadFailed": "Upload failed", @@ -1357,9 +1399,9 @@ "importButton": "Choose backup file", "uploading": "Uploading… {{percent}}%", "restoring": "Restoring…", - "importWarning": "Restore overwrites the current database and local workspaces, then hot-reloads models, experts, and channels. If you also restore config/env, restart the service manually for those to fully apply. This cannot be undone.", + "importWarning": "Restore overwrites the current database and local workspaces, then hot-reloads models, experts, and channels. While restore runs, database-backed APIs may be briefly unavailable — prefer a maintenance window. If you also restore config/env, restart the service manually for those to fully apply. This cannot be undone.", "importConfirmTitle": "Confirm restore", - "importConfirmBody": "Restore from “{{name}}”? Existing data will be overwritten. Models, experts, and channels are hot-reloaded afterward (no service restart). If you restore config/env, restart manually for those settings to take full effect.", + "importConfirmBody": "Restore from “{{name}}”? Existing data will be overwritten, and the service may be briefly unavailable while the database is replaced. Models, experts, and channels are hot-reloaded afterward (no service restart). If you restore config/env, restart manually for those settings to take full effect.", "importConfirmOk": "Restore", "importSuccess": "Restore complete ({{agents}} agents, {{files}} workspace files)", "importFailed": "Failed to restore backup", @@ -1419,6 +1461,14 @@ "kindWorkspace": "Workspace", "path": "Path", "skillDescription": "Description", + "displayNameZh": "Chinese display name", + "displayNameEn": "English display name", + "displayNameZhPlaceholder": "Name shown when the interface is in Chinese", + "displayNameEnPlaceholder": "Name shown when the interface is in English", + "displaySummaryZh": "Chinese short description", + "displaySummaryEn": "English short description", + "displaySummaryZhPlaceholder": "Short card description in Chinese", + "displaySummaryEnPlaceholder": "Short card description in English", "noDescription": "No description available", "createSkill": "Create Skill", "viewSkill": "View Skill", @@ -1477,7 +1527,7 @@ "packagesUpdateFailed": "Failed to update mounted packages", "packageConflictHint": "The workspace skill “{{slug}}” shadows this package skill.", "mountBackendHint": "Skill packages need local storage with root /.", - "skillPackagesUnsupportedHint": "Skill packages unavailable: storage root is not /. Set it to / in expert settings, or use customized / built-in skills.", + "skillPackagesUnsupportedHint": "Skill packages unavailable: when the storage root is not /, the sandbox cannot reach skill-package directories, so packages cannot be used.", "installedSkillsDesc": "Manage installed built-in and customized skills.", "tencentSkillHubDesc": "Browse and install community skills from Tencent SkillHub.", "install": "Install", @@ -1845,6 +1895,19 @@ "weixinQrStep2": "Scan the QR code below to authorize", "weixinQrStep3": "Credentials will be auto-filled after scanning", "weixinQrScanHint": "Scan with WeChat App", + "dingtalkQrLoading": "Initializing DingTalk application registration...", + "dingtalkQrIntro1": "Generate a DingTalk application authorization QR code", + "dingtalkQrIntro2": "Scan and confirm in DingTalk while signed in to the same organization", + "dingtalkQrIntro3": "Octop creates and enables the DingTalk channel automatically", + "dingtalkQrStep1": "Open DingTalk App", + "dingtalkQrStep2": "Scan the application authorization code", + "dingtalkQrStep3": "Confirm application creation", + "dingtalkQrScanHint": "Scan with DingTalk App to finish authorization", + "dingtalkUserCode": "User code", + "dingtalkGenerateQr": "Generate DingTalk QR Code", + "dingtalkBindSuccess": "DingTalk application created and channel enabled", + "dingtalkQrExpired": "DingTalk authorization expired; generate a new QR code", + "dingtalkQrFailed": "DingTalk application authorization failed", "qqQrIntro1": "Generate a QQ Bot binding QR code", "qqQrIntro2": "Scan and confirm with mobile QQ", "qqQrIntro3": "Save the returned App ID and secret automatically", @@ -2103,6 +2166,7 @@ "sttFailed": "Speech recognition failed", "sttFallback": "Server STT failed — switched to browser recognition", "sttProviderRequired": "This browser does not support native speech recognition. Configure Tencent Cloud or OpenAI STT.", + "sttNetworkFailed": "Browser speech recognition could not reach the network (often Google). Configure Tencent Cloud, Xiaomi Mimo, or OpenAI STT under Settings → Voice.", "ttsFailed": "Text-to-speech failed", "nothingToRead": "No readable prose in this message (code blocks are skipped)", "browserNoChineseVoice": "Chrome has no Chinese voice — using Edge TTS instead", @@ -2440,7 +2504,6 @@ "localDownloadContinueBackground": "Continue in background", "localDownloadBackground": "Download moved to the background. You will be notified when it finishes.", "localDownloadBackgroundHint": "You can close this window. The download continues on the server and you will get a notification when it finishes.", - "onnxDownloadProgress": "Downloading {{model}} ({{percent}}%)", "onnxDownloadLoading": "Loading {{model}}…", "defaultModelDownloadedOnly": "Only downloaded models can be selected", "defaultModelNeedDownload": "Download a model below first", @@ -4170,35 +4233,67 @@ "modalEditTitle": "Edit user {{username}}" }, "adminSso": { + "panelTitle": "Single sign-on", + "panelDesc": "Connect an OpenID Connect identity provider so users can sign in with your organization account.", + "statusEnabled": "Enabled · {{name}}", + "statusDisabled": "Disabled", + "statusUnnamed": "Provider", + "guideStep1": "Fill issuer & client", + "guideStep2": "Copy redirect URI", + "guideStep3": "Save settings", + "guideStep4": "Test discovery", + "guideStep5": "Enable SSO", + "guideTitle": "Setup checklist", "enabled": "Enable single sign-on", "enabledHint": "Allow users to sign in through the configured OpenID Connect provider.", + "loginPreview": "Login page preview", + "loginPreviewDisabled": "This button appears on the login page only when SSO is enabled.", + "sectionProvider": "Identity provider", + "sectionProviderHint": "Choose a preset to fill defaults, then enter your issuer URL.", + "presetsLabel": "Quick start", + "presetAzure": "Azure AD", + "presetGoogle": "Google", + "presetKeycloak": "Keycloak", + "presetOkta": "Okta", "displayName": "Provider display name", + "displayNamePlaceholder": "Shown on the login button", "displayNameRequired": "Enter a provider display name", "issuer": "Issuer URL", "issuerHint": "The OpenID Connect issuer URL published by your identity provider.", "issuerRequired": "Enter a valid issuer URL", + "sectionCredentials": "OAuth credentials", + "sectionCredentialsHint": "Client ID and secret from your identity provider application registration.", "clientId": "Client ID", "clientIdRequired": "Enter the client ID", "clientSecret": "Client secret", "clientSecretHint": "Optional for public clients.", "clientSecretConfigured": "A client secret is configured. Leave blank to keep it unchanged.", + "clientSecretConfiguredTag": "Configured", "clientSecretPlaceholder": "Leave blank to keep the current secret", "scopes": "Scopes", + "scopesPlaceholder": "openid profile email", "scopesRequired": "Enter at least one scope", + "sectionAdvanced": "Advanced options", "dashboardOrigin": "Dashboard origin override", "dashboardOriginHint": "Optional public dashboard URL used after the identity provider redirects back.", "dashboardOriginInvalid": "Enter a valid dashboard origin URL", "redirectUri": "Redirect URI", "redirectUriHint": "Add this exact callback URL to your identity provider configuration.", + "redirectUriEmpty": "Save once to generate the redirect URI", + "redirectUriDocs": "In Azure AD, Google, Keycloak, or Okta, register this URL as an allowed redirect / callback URI.", "copy": "Copy", + "copied": "Copied", "copyRedirectUri": "Copy redirect URI", "copySuccess": "Redirect URI copied", "copyFailed": "Could not copy redirect URI", "save": "Save", + "discard": "Discard", + "unsavedChanges": "You have unsaved changes.", "saved": "Single sign-on settings saved", "saveFailed": "Could not save single sign-on settings", "loadFailed": "Could not load single sign-on settings", "testConnection": "Test connection", + "testNeedsSave": "Save your changes before testing.", "testSuccess": "OIDC connection succeeded", "testFailed": "OIDC connection test failed", "testHint": "Save changes before testing a new issuer or client ID." @@ -4280,9 +4375,22 @@ "duplicateName": "Server IDs must be unique", "emptyName": "Server ID is required", "probeNeedConfig": "Fill in the configuration before probing", + "probeOnSave": "Probe connection after save", + "probeOnSaveHint": "Octop will request your MCP URL to verify connectivity. If sign-in is required, we will guide you through OAuth.", + "probeNeedsOAuth": "This MCP requires OAuth before it can be used", + "probeComplete": "Connection verified", + "oauthConfigured": "OAuth authorization completed", + "oauthAuthorizeHint": "One-click OAuth saves your config first, then opens sign-in; we verify the connection again afterward.", + "oauthBeforeEnable": "Complete OAuth authorization before enabling this server", + "enable": "Enable connector", + "enableHint": "When off, agents will not load tools from this MCP.", + "defaultOpen": "Selected by default in chat", + "defaultOpenHint": "When on, your Dashboard, IM, and Cron (with no manual picks) include this MCP by default.", + "defaultOpenRequiresEnable": "Enable the connector first.", + "authorizing": "Authorizing…", "probeOk": "Probe ok — found {{count}} tools", "deleteConfirm": "Delete MCP server \"{{name}}\"?", - "deleteConfirmHint": "Changes take effect after you click Save." + "deleteConfirmHint": "Saved servers are removed immediately; unsaved drafts are dropped from this editor only." }, "configuredBadge": "Connected", "clickToConnect": "Click to connect", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index d94c3228..886984d0 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -69,7 +69,7 @@ "SETUP_REQUIRED": "需要完成初始设置。", "DATABASE_NOT_EMPTY": "目标数据库已有用户。请改用空库,或直接登录现有管理员账户。", "BACKUP_DRIVER_MISMATCH": "该备份与当前数据库引擎不一致(SQLite 与 PostgreSQL 不能互恢)。请将运行时切回备份所用引擎后再恢复,或在当前引擎上重新备份。暂不支持跨引擎恢复。", - "BACKUP_IN_PROGRESS": "已有备份任务正在进行,请稍后再试。", + "BACKUP_IN_PROGRESS": "已有备份或恢复任务正在进行,请稍后再试。", "FORBIDDEN": "没有权限。", "NOT_FOUND": "未找到。", "USER_DISABLED": "账号已禁用。", @@ -322,6 +322,8 @@ "documents": "文档", "documentLimit": "{{count}} / {{max}} 个文档", "documentLimitReached": "此知识库已达到 {{count}} 个文档的上限。", + "maxDocuments": "文档数量上限", + "maxDocumentsHint": "本知识库可容纳的最大文档数,0 表示不限制,默认为 100。", "documentTooLarge": "单个文档不能超过 {{sizeMb}} MB。", "baseLimitReached": "每个用户最多可创建 {{count}} 个知识库。", "uploadHint": "支持 md / txt / pdf / docx / pptx,单文件不超过 {{sizeMb}} MB。", @@ -778,7 +780,7 @@ "defaultModelPlaceholder": "选择一个模型", "skillPackagesLabel": "可选技能包", "skillPackagesHint": "创建时挂载选中的技能包(需本地存储,且根目录为 /)。", - "skillPackagesUnsupportedHint": "无法使用技能包:当前存储根目录不是 /,请改为 /,或改用自定义/内置技能。", + "skillPackagesUnsupportedHint": "无法使用技能包:存储根目录不是 / 时,因沙箱限制无法访问技能包的相关文件目录,因此技能包不可用。", "skillPackagesClearFailed": "已保存后端设置,但清除已挂载技能包失败", "skillPackagesPlaceholder": "选择技能包", "patchFailed": "保存失败", @@ -841,7 +843,8 @@ "backendRootDirPlaceholder": "浏览并选择目录", "backendRootDirDesc": "默认使用当前系统用户的 home 目录({{home}})。也可从文件系统根目录 `/` 起浏览并选择。", "backendRootDirDescAdmin": "默认使用当前系统用户的 home 目录({{home}})。也可从文件系统根目录 `/` 起浏览并选择。", - "backendRootDirJailHint": "保存非根目录时,Octop 会尽量用 bubblewrap(Linux)把 shell/Skill 限制在目录狱中;安装失败或非 Linux 则退化为普通本地 shell(无目录狱)。文件工具始终走虚拟文件系统。", + "backendRootDirJailHint": "保存至非根目录时,Octop 会通过沙箱技术将 AI 文件系统限制在该目录内,禁止跨目录访问。", + "backendRootDirImmutableHint": "创建后不可更改。", "rootDirOutsideHomeWarning": "当前路径不在你的 home 目录({{home}})内。", "rootDirListFailed": "无法列出子目录,请检查权限或选择其他路径。", "rootDirMkdir": "新建文件夹", @@ -996,6 +999,7 @@ "stream_stall": "模型响应中断:连接仍在,但长时间没有新内容。请点击「重试」,或稍后更换模型再试。若经常出现,请检查供应商状态,或联系管理员排查流式超时设置。", "rate_limit": "模型请求过于频繁,已被限流。请稍等片刻后重试,或切换其他模型。", "auth": "模型服务鉴权失败。请检查「设置 → 模型」中的 API Key 是否正确、是否过期。", + "insufficient_balance": "模型服务返回余额或额度不足。请为该 API Key 对应的账户充值或升级套餐后再试。", "context_length": "对话上下文过长,超出模型限制。请新开会话,或精简历史后再试。", "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", @@ -1018,6 +1022,20 @@ "hintBlocking": "整理期间此智能体暂时无法发送消息,完成后会自动恢复。", "elapsed": "已进行 {{seconds}}s" }, + "historyMigration": { + "title": "升级完成:还有 {{count}} 个旧会话需要优化", + "readyHint": "建议现在后台处理,之后首次打开超长会话就不必临时等待。处理时仍可使用新会话,系统每次只读取一个旧会话。", + "runningHint": "正在后台逐个处理;新会话可正常使用,请不要重复刷新页面。", + "agentBusyHint": "此专家正在执行任务,请等待专家空闲后再开始旧会话优化。", + "queueBusyHint": "后台迁移队列正在处理其他会话;空出位置后即可开始,本页会自动刷新状态。", + "retryHint": "有 {{count}} 个会话上次处理失败,可以安全重试。", + "startFailed": "未能启动后台处理,请确认 Agent 正在运行后重试。", + "start": "现在后台优化", + "running": "后台优化中", + "queueRest": "加入其余旧会话", + "waiting": "等待队列空闲", + "retry": "重试失败项" + }, "thinking": "正在思考", "continuing": "继续生成中", "generating": "生成中", @@ -1141,6 +1159,22 @@ "rejectedLabel": "已拒绝", "rejected": "用户已拒绝" }, + "ask": { + "title": "先问几个问题", + "other": "其他…", + "freeTextPlaceholder": "输入你的答案", + "next": "下一个", + "review": "确认并提交", + "reviewTag": "回顾", + "back": "返回", + "submit": "提交回答", + "skip": "你决定", + "skipMessage": "你决定吧——按最优默认方案继续,并告诉我你依据的假设。", + "answered": "已回答", + "answeredSummary": "已回答 {{count}} 个问题", + "dismissedSummary": "已关闭 {{count}} 个问题", + "expand": "查看" + }, "modifiedFiles": "已修改文件({{count}})", "openBrowser": "查看浏览器", "openBrowserHint": "Agent 正在网页中操作", @@ -1161,6 +1195,11 @@ "remoteBrowserTitle": "远程浏览器", "dockTerminalTitle": "终端", "dockPhoneTitle": "远程手机", + "dockToolUiTitle": "插件工具", + "dockToolUiMissing": "该工具结果已不在当前对话中。", + "openToolUiInDock": "在侧栏打开", + "toolUiDockedHint": "已移到侧栏", + "toolUiDockedOpen": "打开侧栏", "loadEarlierMessages": "加载更早的消息" }, "tools": { @@ -1208,6 +1247,7 @@ "mobile_handoff_to_user": "交给用户操作(手机)", "read_env_file": "读取环境变量", "write_env_file": "写入环境变量", + "ask_user_question": "向你提问", "generate_image": "生成图片", "generate_video": "生成视频" }, @@ -1227,6 +1267,7 @@ "categories": { "filesystem": "文件与终端", "orchestration": "规划与子智能体", + "interaction": "用户交互", "web": "网页与浏览器", "media": "媒体生成", "memory": "记忆", @@ -1330,6 +1371,7 @@ "storedTitle": "备份文件", "storedDesc": "系统备份保存在以下目录,可下载或一键恢复。", "createButton": "新建备份", + "creating": "备份中…", "uploadButton": "上传备份", "uploadSuccess": "已保存 {{name}}", "uploadFailed": "上传失败", @@ -1355,10 +1397,10 @@ "importDesc": "从先前导出的 .tar.gz 归档恢复系统。建议在维护窗口操作。", "importButton": "选择备份文件", "uploading": "正在上传… {{percent}}%", - "restoring": "正在恢复…", - "importWarning": "恢复将覆盖当前数据库与本地工作区,并热加载模型、专家与通道。若同时恢复了 config/env,完整生效仍需手动重启服务。此操作不可撤销。", + "restoring": "恢复中…", + "importWarning": "恢复将覆盖当前数据库与本地工作区,并热加载模型、专家与通道。恢复期间依赖数据库的接口可能短暂不可用,建议在维护窗口操作。若同时恢复了 config/env,完整生效仍需手动重启服务。此操作不可撤销。", "importConfirmTitle": "确认恢复备份", - "importConfirmBody": "确定从「{{name}}」恢复?这将覆盖现有数据。恢复后会热加载模型、专家与通道,无需重启服务;若勾选恢复配置,config/env 需重启后才完全生效。", + "importConfirmBody": "确定从「{{name}}」恢复?这将覆盖现有数据,替换数据库期间服务可能短暂不可用。恢复后会热加载模型、专家与通道,无需重启服务;若勾选恢复配置,config/env 需重启后才完全生效。", "importConfirmOk": "恢复", "importSuccess": "恢复完成({{agents}} 个 Agent,{{files}} 个工作区文件)", "importFailed": "恢复备份失败", @@ -1418,6 +1460,14 @@ "kindWorkspace": "工作区", "path": "路径", "skillDescription": "描述", + "displayNameZh": "中文展示名称", + "displayNameEn": "英文展示名称", + "displayNameZhPlaceholder": "界面为中文时显示的名称", + "displayNameEnPlaceholder": "界面为英文时显示的名称", + "displaySummaryZh": "中文简短描述", + "displaySummaryEn": "英文简短描述", + "displaySummaryZhPlaceholder": "用于技能卡片的中文短描述", + "displaySummaryEnPlaceholder": "用于技能卡片的英文短描述", "noDescription": "暂无描述", "createSkill": "创建技能", "viewSkill": "查看技能", @@ -1476,7 +1526,7 @@ "packagesUpdateFailed": "更新已挂载技能包失败", "packageConflictHint": "工作区技能“{{slug}}”会覆盖此技能包中的同名技能。", "mountBackendHint": "技能包需本地存储,且根目录为 /。", - "skillPackagesUnsupportedHint": "无法使用技能包:当前存储根目录不是 /,请在专家设置中改为 /,或改用自定义/内置技能。", + "skillPackagesUnsupportedHint": "无法使用技能包:存储根目录不是 / 时,因沙箱限制无法访问技能包的相关文件目录,因此技能包不可用。", "installedSkillsDesc": "管理已安装的内置和自定义技能。", "tencentSkillHubDesc": "浏览和安装来自腾讯 SkillHub 的社区技能。", "install": "安装", @@ -1759,14 +1809,14 @@ "configFailed": "配置保存失败", "channelEnabled": "频道已启用", "channelDisabled": "频道已停用", - "channelType": "频道类型", + "channelType": "通道类型", "status": "状态", "totalItems": "共 {{count}} 项", "botPrefix": "机器人前缀", "notSet": "未设置", "clickCardToEdit": "点击卡片进行编辑", "settings": "设置", - "channelSettings": "频道设置", + "channelSettings": "通道设置", "openPlatform": "开放平台", "qqGetCredentialsHint": "在这里获取你的机器人信息", "feishuGetCredentialsHint": "在这里获取你的机器人信息", @@ -1844,6 +1894,19 @@ "weixinQrStep2": "扫描下方二维码完成授权", "weixinQrStep3": "扫码成功后自动获取凭据并接入", "weixinQrScanHint": "请使用微信扫描二维码", + "dingtalkQrLoading": "正在初始化钉钉应用创建流程...", + "dingtalkQrIntro1": "点击按钮生成钉钉应用授权二维码", + "dingtalkQrIntro2": "使用同一组织账号登录的钉钉 App 扫码并确认", + "dingtalkQrIntro3": "Octop 将自动创建并启用钉钉频道", + "dingtalkQrStep1": "打开钉钉 App", + "dingtalkQrStep2": "扫描应用授权二维码", + "dingtalkQrStep3": "确认创建应用", + "dingtalkQrScanHint": "请使用钉钉 App 扫码完成授权", + "dingtalkUserCode": "用户码", + "dingtalkGenerateQr": "生成钉钉扫码二维码", + "dingtalkBindSuccess": "钉钉应用已创建,频道已启用", + "dingtalkQrExpired": "钉钉授权已过期,请重新生成二维码", + "dingtalkQrFailed": "钉钉应用授权失败", "qqQrIntro1": "点击按钮生成 QQ 机器人绑定二维码", "qqQrIntro2": "使用手机 QQ 扫码并确认授权", "qqQrIntro3": "自动获取 App ID 和密钥并保存频道", @@ -1906,7 +1969,7 @@ "botNamePlaceholder": "如:小助手", "noAgentSelected": "请先选择或创建一个 Agent 才能管理其频道", "statsSummary": "当前支持 {{supported}} 个通道,已配置 {{configured}} 个", - "createChannel": "新建频道", + "createChannel": "新建通道", "emptyHint": "暂无频道,点击右上角新建", "testSuccess": "频道探测通过", "testFailed": "频道探测失败: {{error}}", @@ -2103,6 +2166,7 @@ "sttFailed": "语音识别失败", "sttFallback": "服务端识别失败,已切换浏览器识别", "sttProviderRequired": "当前浏览器不支持原生语音识别,请配置腾讯云或 OpenAI STT", + "sttNetworkFailed": "浏览器语音识别无法联网(常依赖 Google)。请在设置 → 语音服务中配置腾讯云、小米 Mimo 或 OpenAI STT", "ttsFailed": "语音合成失败", "nothingToRead": "没有可朗读的正文(代码块等内容已跳过)", "browserNoChineseVoice": "Chrome 没有中文语音,已改用 Edge TTS 朗读", @@ -2438,7 +2502,6 @@ "localDownloadContinueBackground": "后台继续", "localDownloadBackground": "下载已转到后台,完成后会通知你", "localDownloadBackgroundHint": "可关闭此窗口,下载在服务端继续,完成后会弹出通知。", - "onnxDownloadProgress": "正在下载 {{model}}({{percent}}%)", "onnxDownloadLoading": "正在加载 {{model}}…", "defaultModelDownloadedOnly": "仅可选择已下载的模型", "defaultModelNeedDownload": "请先在下方管理模型中下载", @@ -4309,35 +4372,67 @@ "modalEditTitle": "编辑用户 {{username}}" }, "adminSso": { + "panelTitle": "单点登录", + "panelDesc": "接入 OpenID Connect 身份提供商,让用户使用组织账号登录。", + "statusEnabled": "已启用 · {{name}}", + "statusDisabled": "未启用", + "statusUnnamed": "提供商", + "guideStep1": "填写签发者与客户端", + "guideStep2": "复制回调地址", + "guideStep3": "保存配置", + "guideStep4": "测试发现", + "guideStep5": "启用 SSO", + "guideTitle": "配置清单", "enabled": "启用单点登录", "enabledHint": "允许用户通过已配置的 OpenID Connect 身份提供商登录。", + "loginPreview": "登录页预览", + "loginPreviewDisabled": "仅在启用 SSO 后,登录页才会显示此按钮。", + "sectionProvider": "身份提供商", + "sectionProviderHint": "可先选择预设填充默认值,再填写签发者 URL。", + "presetsLabel": "快速开始", + "presetAzure": "Azure AD", + "presetGoogle": "Google", + "presetKeycloak": "Keycloak", + "presetOkta": "Okta", "displayName": "提供商显示名称", + "displayNamePlaceholder": "显示在登录按钮上", "displayNameRequired": "请输入提供商显示名称", "issuer": "签发者 URL", "issuerHint": "身份提供商公开的 OpenID Connect 签发者 URL。", "issuerRequired": "请输入有效的签发者 URL", + "sectionCredentials": "OAuth 凭证", + "sectionCredentialsHint": "来自身份提供商应用注册的客户端 ID 与密钥。", "clientId": "客户端 ID", "clientIdRequired": "请输入客户端 ID", "clientSecret": "客户端密钥", "clientSecretHint": "公共客户端可不填写。", "clientSecretConfigured": "已配置客户端密钥;留空将保留现有密钥。", + "clientSecretConfiguredTag": "已配置", "clientSecretPlaceholder": "留空以保留现有密钥", "scopes": "授权范围", + "scopesPlaceholder": "openid profile email", "scopesRequired": "请至少输入一个授权范围", + "sectionAdvanced": "高级选项", "dashboardOrigin": "控制台来源地址覆盖", "dashboardOriginHint": "可选。身份提供商回调后使用的公开控制台 URL。", "dashboardOriginInvalid": "请输入有效的控制台来源地址 URL", "redirectUri": "回调地址", "redirectUriHint": "请将此精确回调 URL 添加到身份提供商配置中。", + "redirectUriEmpty": "保存一次后生成回调地址", + "redirectUriDocs": "在 Azure AD、Google、Keycloak 或 Okta 中,将此 URL 注册为允许的重定向 / 回调地址。", "copy": "复制", + "copied": "已复制", "copyRedirectUri": "复制回调地址", "copySuccess": "回调地址已复制", "copyFailed": "无法复制回调地址", "save": "保存", + "discard": "放弃更改", + "unsavedChanges": "有未保存的更改。", "saved": "单点登录设置已保存", "saveFailed": "无法保存单点登录设置", "loadFailed": "无法加载单点登录设置", "testConnection": "测试连接", + "testNeedsSave": "请先保存更改再测试。", "testSuccess": "OIDC 连接成功", "testFailed": "OIDC 连接测试失败", "testHint": "更改签发者或客户端 ID 后,请先保存再测试。" @@ -4419,9 +4514,22 @@ "duplicateName": "服务器 ID 不能重复", "emptyName": "请填写服务器 ID", "probeNeedConfig": "请先填写完整配置再探测", + "probeOnSave": "保存后自动探测连接", + "probeOnSaveHint": "将向您填写的 MCP 地址发起请求以验证可用性;若需登录,会引导您完成 OAuth。", + "probeNeedsOAuth": "此 MCP 需要 OAuth 授权才能访问", + "probeComplete": "连接正常", + "oauthConfigured": "已完成 OAuth 授权", + "oauthAuthorizeHint": "点击「一键授权」将先保存配置再打开登录页;完成后我们会自动再次验证连接。", + "oauthBeforeEnable": "请先完成 OAuth 授权后再启用", + "enable": "启用连接器", + "enableHint": "关闭后 Agent 不会加载此 MCP 的工具。", + "defaultOpen": "对话默认选中", + "defaultOpenHint": "开启后,你的 Dashboard、IM 与 Cron(未手动选连接器时)会默认带上此 MCP。", + "defaultOpenRequiresEnable": "需先启用连接器。", + "authorizing": "授权中…", "probeOk": "探测成功,发现 {{count}} 个工具", "deleteConfirm": "确定删除 MCP 服务器「{{name}}」?", - "deleteConfirmHint": "删除后需点击保存才会生效。" + "deleteConfirmHint": "已保存的服务器将立即删除;尚未保存的配置只会从当前编辑中移除。" }, "configuredBadge": "已连接", "clickToConnect": "点击连接", diff --git a/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx b/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx index 639f9562..6cb79b40 100644 --- a/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx +++ b/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx @@ -5,8 +5,7 @@ * enabled toggle, edit and delete actions. */ import { useState } from "react"; -import { Button, Modal, Switch, Tooltip } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Button, Switch, Tooltip } from "antd"; import { Pencil, @@ -39,6 +38,7 @@ export function StorageBackendCard({ isNew, }: StorageBackendCardProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const [editOpen, setEditOpen] = useState(false); const [browseOpen, setBrowseOpen] = useState(false); const [toggling, setToggling] = useState(false); @@ -96,7 +96,7 @@ export function StorageBackendCard({ }; const handleDelete = () => { - Modal.confirm({ + modal.confirm({ title: t("storage.deleteTitle"), content: t("storage.deleteConfirm", { name: backend.name }), okText: t("common.delete"), diff --git a/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx b/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx index 93cbc50a..d563d94c 100644 --- a/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx +++ b/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx @@ -1,7 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; -import { I18nextProvider } from "react-i18next"; -import i18n from "../../../i18n"; +import userEvent from "@testing-library/user-event"; const { getOidcConfig, putOidcConfig, testOidcConfig } = vi.hoisted(() => ({ getOidcConfig: vi.fn(), @@ -14,12 +13,16 @@ vi.mock("../../../api/modules/sso", () => ({ })); vi.mock("@/utils/antdMessage", () => ({ - message: { error: vi.fn(), success: vi.fn() }, + message: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, })); import SsoPanel from "./SsoPanel"; describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("loads the provider configuration and displays its callback URL", async () => { getOidcConfig.mockResolvedValue({ enabled: true, @@ -32,11 +35,7 @@ describe("", () => { redirect_uri: "https://octop.example.com/api/auth/oidc/callback", }); - render( - - - , - ); + render(); await waitFor(() => expect(getOidcConfig).toHaveBeenCalledOnce()); expect(screen.getByDisplayValue("Acme SSO")).toBeInTheDocument(); @@ -45,5 +44,29 @@ describe("", () => { "https://octop.example.com/api/auth/oidc/callback", ), ).toBeInTheDocument(); + // Mocked t() returns the key; interpolation keeps {{name}} unless options used. + expect(screen.getByText("adminSso.statusEnabled")).toBeInTheDocument(); + }); + + it("applies an IdP preset into display name", async () => { + const user = userEvent.setup(); + getOidcConfig.mockResolvedValue({ + enabled: false, + display_name: "", + issuer: "", + client_id: "", + scopes: "openid profile email", + dashboard_origin: null, + has_client_secret: false, + redirect_uri: "", + }); + + render(); + + await waitFor(() => expect(getOidcConfig).toHaveBeenCalledOnce()); + await user.click( + screen.getByRole("button", { name: "adminSso.presetGoogle" }), + ); + expect(screen.getByDisplayValue("Google")).toBeInTheDocument(); }); }); diff --git a/dashboard/src/pages/Admin/Users/SsoPanel.tsx b/dashboard/src/pages/Admin/Users/SsoPanel.tsx index af387dae..ffeb62ca 100644 --- a/dashboard/src/pages/Admin/Users/SsoPanel.tsx +++ b/dashboard/src/pages/Admin/Users/SsoPanel.tsx @@ -1,6 +1,34 @@ -import { useCallback, useEffect, useState } from "react"; -import { Button, Form, Input, Space, Spin, Switch, Typography } from "antd"; -import { Copy, FlaskConical, Save } from "lucide-react"; +import { + useCallback, + useEffect, + useRef, + useState, + type ChangeEvent, +} from "react"; +import { + Alert, + Button, + Collapse, + Form, + Input, + Select, + Space, + Spin, + Switch, + Tag, + Tooltip, + Typography, +} from "antd"; +import { + Check, + CheckCircle2, + Copy, + FlaskConical, + KeyRound, + Lock, + Save, + XCircle, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { message } from "@/utils/antdMessage"; import { @@ -10,6 +38,8 @@ import { } from "../../../api/modules/sso"; import { apiErrorMessage } from "../../../utils/apiError"; import { copyText } from "../../../utils/copyText"; +import { TabPanelHeader } from "../../Settings/AdvancedSettings/TabPanelHeader"; +import styles from "./index.module.less"; interface SsoFormValues { enabled: boolean; @@ -17,21 +47,83 @@ interface SsoFormValues { issuer: string; client_id: string; client_secret?: string; - scopes: string; + scopes: string[]; dashboard_origin?: string; } +type TestResult = { ok: boolean; detail: string } | null; + +type IdpPresetId = "azure" | "google" | "keycloak" | "okta"; + +interface IdpPreset { + id: IdpPresetId; + labelKey: string; + displayName: string; + scopes: string[]; + issuerPlaceholder: string; +} + +const IDP_PRESETS: IdpPreset[] = [ + { + id: "azure", + labelKey: "adminSso.presetAzure", + displayName: "Microsoft", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://login.microsoftonline.com/{tenant}/v2.0", + }, + { + id: "google", + labelKey: "adminSso.presetGoogle", + displayName: "Google", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://accounts.google.com", + }, + { + id: "keycloak", + labelKey: "adminSso.presetKeycloak", + displayName: "Keycloak", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://keycloak.example.com/realms/{realm}", + }, + { + id: "okta", + labelKey: "adminSso.presetOkta", + displayName: "Okta", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://{domain}.okta.com", + }, +]; + +const SCOPE_OPTIONS = ["openid", "profile", "email", "offline_access"].map( + (value) => ({ value, label: value }), +); + +const GUIDE_STEPS = [ + "adminSso.guideStep1", + "adminSso.guideStep2", + "adminSso.guideStep3", + "adminSso.guideStep4", + "adminSso.guideStep5", +] as const; + function configToFormValues(config: OidcConfig): SsoFormValues { return { enabled: config.enabled, display_name: config.display_name, issuer: config.issuer, client_id: config.client_id, - scopes: config.scopes, + scopes: config.scopes + .split(/\s+/) + .map((s) => s.trim()) + .filter(Boolean), dashboard_origin: config.dashboard_origin ?? "", }; } +function normalizeIssuer(raw: string): string { + return raw.trim().replace(/\/+$/, ""); +} + export default function SsoPanel() { const { t } = useTranslation(); const [form] = Form.useForm(); @@ -40,40 +132,85 @@ export default function SsoPanel() { const [testing, setTesting] = useState(false); const [redirectUri, setRedirectUri] = useState(""); const [hasClientSecret, setHasClientSecret] = useState(false); + const [dirty, setDirty] = useState(false); + const [copied, setCopied] = useState(false); + const [testResult, setTestResult] = useState(null); + const [issuerPlaceholder, setIssuerPlaceholder] = useState( + "https://identity.example.com", + ); + const [activePreset, setActivePreset] = useState(null); + const hydratingRef = useRef(false); + + const enabled = Form.useWatch("enabled", form) ?? false; + const displayName = Form.useWatch("display_name", form) ?? ""; + const issuer = Form.useWatch("issuer", form) ?? ""; + const clientId = Form.useWatch("client_id", form) ?? ""; + + const applyConfig = useCallback( + (config: OidcConfig) => { + hydratingRef.current = true; + form.setFieldsValue(configToFormValues(config)); + form.setFieldValue("client_secret", undefined); + setRedirectUri(config.redirect_uri ?? ""); + setHasClientSecret(config.has_client_secret); + setDirty(false); + setTestResult(null); + setActivePreset(null); + queueMicrotask(() => { + hydratingRef.current = false; + }); + }, + [form], + ); const loadConfig = useCallback(async () => { setLoading(true); try { const config = await ssoApi.getOidcConfig(); - form.setFieldsValue(configToFormValues(config)); - setRedirectUri(config.redirect_uri ?? ""); - setHasClientSecret(config.has_client_secret); + applyConfig(config); } catch (error) { message.error(apiErrorMessage(error, t("adminSso.loadFailed"), t)); } finally { setLoading(false); } - }, [form, t]); + }, [applyConfig, t]); useEffect(() => { - void loadConfig(); - }, [loadConfig]); + let cancelled = false; + setLoading(true); + void (async () => { + try { + const config = await ssoApi.getOidcConfig(); + if (cancelled) return; + applyConfig(config); + } catch (error) { + if (cancelled) return; + message.error(apiErrorMessage(error, t("adminSso.loadFailed"), t)); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + // Mount-only load; reload goes through loadConfig(). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const saveConfig = async (values: SsoFormValues) => { setSaving(true); try { const body: OidcConfigPut = { - ...values, + enabled: values.enabled, + display_name: values.display_name.trim(), + issuer: normalizeIssuer(values.issuer), + client_id: values.client_id.trim(), client_secret: values.client_secret?.trim() || undefined, + scopes: values.scopes.join(" "), dashboard_origin: values.dashboard_origin?.trim() || null, }; const saved = await ssoApi.putOidcConfig(body); - form.setFieldsValue(configToFormValues(saved)); - form.setFieldValue("client_secret", undefined); - setHasClientSecret(saved.has_client_secret); - if (saved.redirect_uri) { - setRedirectUri(saved.redirect_uri); - } + applyConfig(saved); message.success(t("adminSso.saved")); } catch (error) { message.error(apiErrorMessage(error, t("adminSso.saveFailed"), t)); @@ -83,146 +220,455 @@ export default function SsoPanel() { }; const testConnection = async () => { + if (dirty) { + message.warning(t("adminSso.testNeedsSave")); + return; + } setTesting(true); + setTestResult(null); try { const result = await ssoApi.testOidcConfig(); - if (result.ok) { - message.success(result.detail || t("adminSso.testSuccess")); - } else { - message.error(result.detail || t("adminSso.testFailed")); - } + const detail = + result.detail || + (result.ok ? t("adminSso.testSuccess") : t("adminSso.testFailed")); + setTestResult({ ok: result.ok, detail }); + if (result.ok) message.success(detail); + else message.error(detail); } catch (error) { - message.error(apiErrorMessage(error, t("adminSso.testFailed"), t)); + const detail = apiErrorMessage(error, t("adminSso.testFailed"), t); + setTestResult({ ok: false, detail }); + message.error(detail); } finally { setTesting(false); } }; const copyRedirectUri = async () => { + if (!redirectUri) return; const ok = await copyText(redirectUri); - if (ok) message.success(t("adminSso.copySuccess")); - else message.error(t("adminSso.copyFailed")); + if (ok) { + message.success(t("adminSso.copySuccess")); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } else { + message.error(t("adminSso.copyFailed")); + } + }; + + const applyPreset = (preset: IdpPreset) => { + form.setFieldsValue({ + display_name: preset.displayName, + scopes: preset.scopes, + }); + setIssuerPlaceholder(preset.issuerPlaceholder); + setActivePreset(preset.id); + setDirty(true); + setTestResult(null); }; + const guideStep = (() => { + if (!issuer.trim() || !clientId.trim()) return 0; + if (!redirectUri) return 1; + if (dirty) return 2; + if (!testResult?.ok) return 3; + if (!enabled) return 4; + return 5; + })(); + + const statusLabel = enabled + ? t("adminSso.statusEnabled", { + name: displayName.trim() || t("adminSso.statusUnnamed"), + }) + : t("adminSso.statusDisabled"); + return ( - - - form={form} - layout="vertical" - requiredMark={false} - onFinish={(values) => void saveConfig(values)} - style={{ maxWidth: 680 }} - initialValues={{ enabled: false, scopes: "openid profile email" }} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + {t("adminSso.redirectUriDocs")} + + + + {testResult && ( + + ) : ( + + ) + } + message={ + testResult.ok + ? t("adminSso.testSuccess") + : t("adminSso.testFailed") + } + description={testResult.detail} + closable + onClose={() => setTestResult(null)} + /> + )} + + + + form={form} + layout="vertical" + requiredMark={false} + onFinish={(values) => void saveConfig(values)} + onValuesChange={() => { + if (hydratingRef.current) return; + setDirty(true); + setTestResult(null); + }} + initialValues={{ + enabled: false, + scopes: ["openid", "profile", "email"], + }} + className={styles.ssoForm} > - {t("adminSso.testConnection")} - - - - {t("adminSso.testHint")} - - - +
+
+
+
+ {t("adminSso.enabled")} +
+

+ {t("adminSso.enabledHint")} +

+
+ + + +
+
+ +
+
+

+ {t("adminSso.sectionProvider")} +

+

+ {t("adminSso.sectionProviderHint")} +

+
+ +
+ + {t("adminSso.presetsLabel")} + +
+ {IDP_PRESETS.map((preset) => ( + + ))} +
+
+ + + + + ) => + e.target.value + } + > + { + const next = normalizeIssuer(e.target.value); + if (next !== e.target.value) { + form.setFieldValue("issuer", next); + if (!hydratingRef.current) setDirty(true); + } + }} + /> + +
+ +
+
+

+ {t("adminSso.sectionCredentials")} +

+

+ {t("adminSso.sectionCredentialsHint")} +

+
+
+ + + + + {t("adminSso.clientSecret")} + {hasClientSecret && ( + + + {t("adminSso.clientSecretConfiguredTag")} + + )} + + } + extra={ + hasClientSecret + ? t("adminSso.clientSecretConfigured") + : t("adminSso.clientSecretHint") + } + > + + +
+ { + if (!value || value.length === 0) { + throw new Error(t("adminSso.scopesRequired")); + } + }, + }, + ]} + > + + + ), + }, + ]} + /> + +
+
+ + + {dirty && ( + + )} +
+
+ {dirty ? ( + + {t("adminSso.unsavedChanges")} + + ) : ( + + {t("adminSso.testHint")} + + )} +
+
+ + + + ); } diff --git a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx index f88c9d60..0129127a 100644 --- a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx +++ b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx @@ -2,7 +2,7 @@ * Admin → Users page (plan §14.7). * * List all users with role/disabled toggles, password reset, delete. - * Card and table views; default is card on mobile, table on desktop. The view switcher + refresh + + * Card and table views (default table). The view switcher + refresh + * new-user buttons live in a content-area toolbar (mirrors the Experts * page layout). Each row/card shows agent count; click opens a drawer * with that user's agents. diff --git a/dashboard/src/pages/Admin/Users/index.module.less b/dashboard/src/pages/Admin/Users/index.module.less index f31958f3..8e5fa8d2 100644 --- a/dashboard/src/pages/Admin/Users/index.module.less +++ b/dashboard/src/pages/Admin/Users/index.module.less @@ -993,3 +993,441 @@ gap: 2px; font-size: 12px; } + +/* ── SSO panel ──────────────────────────────────────────────────── */ + +.ssoPanel { + width: 100%; + min-width: 0; + padding-bottom: 88px; +} + +.ssoLayout { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + align-items: start; + + @media (min-width: 1100px) { + grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); + gap: 24px; + } +} + +.ssoAside { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + order: -1; + + @media (min-width: 1100px) { + order: 0; + position: sticky; + top: 8px; + /* Place aside in the second column */ + grid-column: 2; + grid-row: 1; + } +} + +.ssoForm { + min-width: 0; + + @media (min-width: 1100px) { + grid-column: 1; + grid-row: 1; + } + + :global(.ant-form-item) { + margin-bottom: 16px; + } + + :global(.ant-form-item-label > label) { + color: var(--fn-text-secondary); + font-weight: 500; + } +} + +.ssoAsideTitle { + margin: 0 0 10px; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.ssoAsideCard { + padding: 14px 16px; + border: 1px solid var(--fn-border-primary); + border-radius: var(--fn-radius-lg, 12px); + background: var(--fn-bg-primary); +} + +.ssoFieldGrid { + display: grid; + grid-template-columns: 1fr; + gap: 0 16px; + + @media (min-width: 720px) { + grid-template-columns: 1fr 1fr; + } +} + +.ssoStatusTagOn, +.ssoStatusTagOff { + display: inline-flex !important; + align-items: center; + gap: 6px; + margin: 0 !important; + padding: 2px 10px !important; + border-radius: 999px !important; + font-size: 12px !important; + font-weight: 500 !important; + line-height: 1.5 !important; + border: 1px solid transparent !important; +} + +.ssoStatusTagOn { + color: var(--fn-color-brand) !important; + background: var(--fn-color-brand-bg) !important; + border-color: var( + --fn-color-brand-border, + color-mix(in srgb, var(--fn-color-brand) 28%, transparent) + ) !important; +} + +.ssoStatusTagOff { + color: var(--fn-text-tertiary) !important; + background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)) !important; + border-color: var(--fn-border-primary) !important; +} + +.ssoStatusDotOn, +.ssoStatusDotOff { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.ssoStatusDotOn { + background: var(--fn-color-brand); +} + +.ssoStatusDotOff { + background: var(--fn-text-tertiary); +} + +.ssoGuide { + margin: 0; + padding: 14px 16px; + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-lg, 12px); + background: var(--fn-bg-secondary, var(--fn-bg-elevated)); +} + +.ssoGuideList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.ssoGuideItem { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 12px; + line-height: 1.45; + color: var(--fn-text-tertiary); +} + +.ssoGuideCurrent { + color: var(--fn-text-primary); + font-weight: 500; +} + +.ssoGuideDone { + color: var(--fn-text-secondary); +} + +.ssoGuideIndex { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; + background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)); + color: inherit; +} + +.ssoGuideDone .ssoGuideIndex { + color: var(--fn-color-brand); + background: var(--fn-color-brand-bg); +} + +.ssoGuideCurrent .ssoGuideIndex { + color: #fff; + background: var(--fn-color-brand); +} + +.ssoAlert { + margin: 0; +} + +.ssoSection { + margin-bottom: 16px; + padding: 16px 18px; + border: 1px solid var(--fn-border-primary); + border-radius: var(--fn-radius-lg, 12px); + background: var(--fn-bg-primary); +} + +.ssoSectionHeader { + margin-bottom: 14px; +} + +.ssoSectionTitle { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.4; + color: var(--fn-text-primary); +} + +.ssoSectionHint { + margin: 4px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--fn-text-tertiary); +} + +.ssoEnableRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.ssoEnableText { + flex: 1; + min-width: 0; +} + +.ssoEnableSwitch { + margin: 0 !important; + flex-shrink: 0; +} + +.ssoPreviewBtn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 40px; + padding: 0 12px; + border: 1px solid var(--fn-border-primary); + border-radius: 10px; + background: var(--fn-bg-elevated, var(--fn-bg-secondary)); + color: var(--fn-text-primary); + font-size: 13px; + font-weight: 500; + text-align: center; + pointer-events: none; + user-select: none; +} + +.ssoPreviewBtnMuted { + opacity: 0.55; +} + +.ssoPreviewHint { + margin: 6px 0 0; + font-size: 12px; + color: var(--fn-text-tertiary); +} + +.ssoPresets { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 14px; +} + +.ssoPresetsLabel { + font-size: 12px; + font-weight: 500; + color: var(--fn-text-tertiary); +} + +.ssoPresetChips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ssoPresetChip { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 10px; + border: 1px solid var(--fn-border-primary); + border-radius: 999px; + background: var(--fn-bg-elevated, var(--fn-bg-primary)); + color: var(--fn-text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease, + color 0.15s ease; + + &:hover { + border-color: var(--fn-border-strong, var(--fn-border-primary)); + color: var(--fn-text-primary); + } +} + +.ssoPresetChipActive { + border-color: var( + --fn-color-brand-border, + color-mix(in srgb, var(--fn-color-brand) 45%, transparent) + ); + background: var(--fn-color-brand-bg); + color: var(--fn-color-brand); +} + +.ssoSecretLabel { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ssoSecretTag { + display: inline-flex !important; + align-items: center; + gap: 4px; + margin: 0 !important; + padding: 0 6px !important; + border: none !important; + border-radius: 4px !important; + font-size: 11px !important; + line-height: 18px !important; + color: var(--fn-color-brand) !important; + background: var(--fn-color-brand-bg) !important; +} + +.ssoAdvanced { + margin-bottom: 16px; + + :global(.ant-collapse-header) { + padding: 10px 4px !important; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-secondary) !important; + } + + :global(.ant-collapse-content-box) { + padding: 0 4px 4px !important; + } +} + +.ssoRedirectCard { + margin: 0; + padding: 14px 16px; + border: 1px solid + var( + --fn-color-brand-border, + color-mix(in srgb, var(--fn-color-brand) 28%, transparent) + ); + border-radius: var(--fn-radius-lg, 12px); + background: var( + --fn-color-brand-bg, + color-mix(in srgb, var(--fn-color-brand) 6%, transparent) + ); +} + +.ssoRedirectHeader { + margin-bottom: 12px; +} + +.ssoRedirectRow { + width: 100%; +} + +.ssoRedirectInput { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.ssoRedirectDocs { + margin: 10px 0 0 !important; + font-size: 12px !important; + line-height: 1.5; +} + +.ssoFooter { + position: sticky; + bottom: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 16px; + margin-top: 8px; + padding: 14px 0 4px; + border-top: 1px solid var(--fn-border-primary); + background: linear-gradient( + to top, + var(--fn-bg-primary) 70%, + color-mix(in srgb, var(--fn-bg-primary) 80%, transparent) + ); +} + +.ssoFooterActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.ssoFooterMeta { + flex: 1; + min-width: 0; + font-size: 12px; + line-height: 1.5; +} + +.ssoDirtyHint { + color: #d48806; +} + +.ssoTestHint { + color: var(--fn-text-tertiary); +} + +@media (max-width: 640px) { + .ssoPanel { + padding-bottom: 24px; + } + + .ssoEnableRow { + flex-direction: column; + align-items: stretch; + } + + .ssoFooter { + position: static; + flex-direction: column; + align-items: stretch; + } +} diff --git a/dashboard/src/pages/Agent/ACP/index.tsx b/dashboard/src/pages/Agent/ACP/index.tsx index 15dca3fb..5a47beee 100644 --- a/dashboard/src/pages/Agent/ACP/index.tsx +++ b/dashboard/src/pages/Agent/ACP/index.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Button, Empty, Form, Modal, Switch } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Button, Empty, Form, Switch } from "antd"; import { useTranslation } from "react-i18next"; import PageShell from "../../../layouts/PageShell"; @@ -25,6 +24,7 @@ const EMPTY_RUNNERS: Record = {}; export default function ACPPage() { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const { activeAgentId } = useAgent(); const [runners, setRunners] = useState>(EMPTY_RUNNERS); @@ -232,7 +232,7 @@ export default function ACPPage() { const handleDelete = () => { if (!activeKey || isBuiltinRunner(activeKey)) return; - Modal.confirm({ + modal.confirm({ title: t("acp.deleteTitle", { name: activeKey }), content: t("acp.deleteConfirm"), okText: t("common.delete"), diff --git a/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx b/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx index de4c6d09..895ad5a0 100644 --- a/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx +++ b/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx @@ -199,10 +199,7 @@ export default function ChannelsPanel({ agentId }: ChannelsPanelProps) { const next: ChannelFormValues = { kind: row.kind as ChannelKey, enabled: row.enabled, - response_mode: - cfg.response_mode === "stream" - ? "stream" - : DEFAULT_CHANNEL_DISPLAY_CONFIG.response_mode, + response_mode: cfg.response_mode === "stream" ? "stream" : "invoke", show_thinking: typeof cfg.show_thinking === "boolean" ? cfg.show_thinking @@ -240,6 +237,14 @@ export default function ChannelsPanel({ agentId }: ChannelsPanelProps) { setDrawerInitialValues(undefined); }, []); + const handleProvisioned = useCallback(() => { + message.success(t("channels.dingtalkBindSuccess")); + setDrawerOpen(false); + setEditing(null); + setDrawerInitialValues(undefined); + void fetchChannels(); + }, [fetchChannels, t]); + const handleSubmit = useCallback( async ( kind: ChannelKey, @@ -435,6 +440,7 @@ export default function ChannelsPanel({ agentId }: ChannelsPanelProps) { onDelete={editing ? handleDeleteFromDrawer : undefined} onClose={handleDrawerClose} onSubmit={handleSubmit} + onProvisioned={handleProvisioned} onTest={handleTestFromDrawer} testing={ testState.loadingKey !== null && diff --git a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx index 1aa24bd3..444b8e67 100644 --- a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx +++ b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx @@ -87,6 +87,7 @@ const QUICK_CONFIG_CHANNELS: ChannelKey[] = [ "qq", "wecom", "weixin", + "dingtalk", "feishu", "yuanbao", ]; @@ -114,6 +115,12 @@ type QrPhase = token: string; baseUrl: string; } + | { + phase: "dingtalk_ready"; + qrcodeUrl: string; + userCode: string; + } + | { phase: "dingtalk_success"; channelId: string } | { phase: "feishu_creating"; message: string } | { phase: "feishu_qr"; qrToken: string } | { phase: "feishu_progress"; message: string } @@ -146,6 +153,7 @@ interface ChannelDrawerProps { config: Record, enabled: boolean, ) => Promise; + onProvisioned: () => void; onTest?: () => void; testing?: boolean; agentId: string; @@ -456,6 +464,7 @@ export function ChannelDrawer({ deleting, onClose, onSubmit, + onProvisioned, onTest, testing, agentId, @@ -641,6 +650,61 @@ export function ChannelDrawer({ } }, [agentId, stopPolling]); + // ── DingTalk Flow ─────────────────────────────────────────────────────── + const startDingtalkQr = useCallback(async () => { + setQrState({ phase: "loading" }); + try { + const res = await channelApi.dingtalkQrcodeGenerate(agentId); + setQrState({ + phase: "dingtalk_ready", + qrcodeUrl: res.qrcode_url, + userCode: res.user_code, + }); + let polling = false; + const timer = setInterval( + async () => { + if (polling) return; + polling = true; + try { + const poll = await channelApi.dingtalkQrcodePoll( + agentId, + res.registration_id, + ); + if (poll.status === "success" && poll.channel_id) { + stopPolling(); + setQrState({ + phase: "dingtalk_success", + channelId: poll.channel_id, + }); + onProvisioned(); + } else if (poll.status === "failed" || poll.status === "expired") { + stopPolling(); + setQrState({ + phase: "error", + reason: + poll.message ?? + (poll.status === "expired" + ? t("channels.dingtalkQrExpired") + : t("channels.dingtalkQrFailed")), + }); + } + } catch { + // Transient network error: keep polling until DingTalk expires the flow. + } finally { + polling = false; + } + }, + Math.max(1000, res.interval * 1000), + ); + pollTimerRef.current = timer; + } catch (e: unknown) { + setQrState({ + phase: "error", + reason: e instanceof Error ? e.message : String(e), + }); + } + }, [agentId, onProvisioned, stopPolling, t]); + // ── Feishu Flow ───────────────────────────────────────────────────────── const startFeishuCreator = useCallback( async (platform: "feishu" | "lark" = "feishu") => { @@ -1354,6 +1418,105 @@ export function ChannelDrawer({ ); } + function renderDingtalkPanel() { + const s = qrState; + if (s.phase === "loading") { + return ( +
+ +

{t("channels.dingtalkQrLoading")}

+
+ ); + } + if (s.phase === "dingtalk_success") { + return ( +
+ +
+ ); + } + if (s.phase === "dingtalk_ready") { + return ( +
+
+ + 1 + {t("channels.dingtalkQrStep1")} + + + + 2 + {t("channels.dingtalkQrStep2")} + + + + 3 + {t("channels.dingtalkQrStep3")} + +
+
+
+ +
+
+

+ {t("channels.dingtalkQrScanHint")} +

+

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

+ +
+ ); + } + if (s.phase === "error") { + return ( +
+ + +
+ ); + } + return ( +
+
+
+ 1 + {t("channels.dingtalkQrIntro1")} +
+
+ 2 + {t("channels.dingtalkQrIntro2")} +
+
+ 3 + {t("channels.dingtalkQrIntro3")} +
+
+ +
+ ); + } + function renderFeishuPanel() { const s = qrState; if (s.phase === "feishu_creating" || s.phase === "feishu_progress") { @@ -1591,7 +1754,11 @@ export function ChannelDrawer({ style={{ width: 22, height: 22 }} /> )} - {isEdit ? `${kindLabel} 频道设置` : "新建频道"} + + {isEdit + ? `${kindLabel} ${t("channels.channelSettings")}` + : t("channels.createChannel")} + } open={open} @@ -1681,6 +1848,7 @@ export function ChannelDrawer({ {selectedKind === "qq" && renderQqPanel()} {selectedKind === "wecom" && renderWecomPanel()} {selectedKind === "weixin" && renderWeixinPanel()} + {selectedKind === "dingtalk" && renderDingtalkPanel()} {selectedKind === "feishu" && renderFeishuPanel()} {selectedKind === "yuanbao" && renderYuanbaoPanel()} @@ -1709,7 +1877,7 @@ export function ChannelDrawer({ + {entry.auth_kind === "custom_fields" && + (entry.credential_fields ?? []).map((field) => { + const isSecret = field.secret || field.field_type === "password"; + const input = isSecret ? ( + + ) : ( + + ); + return ( + + !value || isDifyMcpServerUrl(String(value)) + ? Promise.resolve() + : Promise.reject( + new Error( + t( + "connectors.difyMcpUrlInvalid", + "请粘贴 Dify 访问点提供的完整 MCP Server URL", + ), + ), + ), + }, + ] + : []), + ]} + extra={ + configuredExtra(preview, `${field.key}_configured`, t) ?? + field.help + } + > + {input} + + ); + })} + {entry.auth_kind === "personal_token" && ( (null); const [loading, setLoading] = useState(true); @@ -102,7 +102,7 @@ export default function VectorSearchConfig() { /** Disable vector search in one action by applying provider=none immediately. */ const handleDisableVectorSearch = useCallback(() => { if (!config || config.provider === "none") return; - Modal.confirm({ + modal.confirm({ title: t("memory.vs.disableVectorConfirmTitle"), content: t("memory.vs.disableVectorConfirmDesc"), okText: t("common.disable"), @@ -181,7 +181,7 @@ export default function VectorSearchConfig() { const currentModel = config?.localModel || ""; if (!currentModel) return; - Modal.confirm({ + modal.confirm({ title: t("memory.vs.deleteModelCacheTitle"), content: t("memory.vs.deleteModelCacheDesc", { model: currentModel }), okText: t("common.delete"), diff --git a/dashboard/src/pages/Agent/Memory/shared/deprecateAtom.tsx b/dashboard/src/pages/Agent/Memory/shared/deprecateAtom.tsx index 87543a99..6fecc9ab 100644 --- a/dashboard/src/pages/Agent/Memory/shared/deprecateAtom.tsx +++ b/dashboard/src/pages/Agent/Memory/shared/deprecateAtom.tsx @@ -4,8 +4,9 @@ * Reuses the same confirm dialog, optional reason input, deprecateAtom call, * and toast behavior across tree and list drawers. */ -import { Input, Modal, Typography } from "antd"; +import { Input, Typography } from "antd"; import { message } from "@/utils/antdMessage"; +import { modal } from "@/utils/antdModal"; import i18n from "@/i18n"; import { @@ -24,7 +25,7 @@ export function confirmDeprecateAtom({ onSuccess?: () => void; }) { let reason = ""; - Modal.confirm({ + modal.confirm({ title: i18n.t("memory.deprecate.title"), content: (
diff --git a/dashboard/src/pages/Agent/Skills/components/InstalledSkillsTab.tsx b/dashboard/src/pages/Agent/Skills/components/InstalledSkillsTab.tsx index 5085ad99..f666d3a5 100644 --- a/dashboard/src/pages/Agent/Skills/components/InstalledSkillsTab.tsx +++ b/dashboard/src/pages/Agent/Skills/components/InstalledSkillsTab.tsx @@ -10,7 +10,6 @@ import { EmptyState } from "../../../../components/EmptyState"; import { SkillCard } from "./SkillCard"; import { SkillDrawer, type SkillFormValues } from "./SkillDrawer"; import { SkillImportModal } from "./SkillImportModal"; -import { hubInfoBySlugFromCache } from "./skillHubCache"; import SkillsTable from "./SkillsTable"; import type { SkillDetail, SkillSpec } from "../useSkills"; import styles from "../index.module.less"; @@ -80,8 +79,6 @@ export default function InstalledSkillsTab({ const [hoverKey, setHoverKey] = useState(null); const [form] = Form.useForm(); - const hubSkillsBySlug = useMemo(() => hubInfoBySlugFromCache(), []); - const filteredSkills = useMemo( () => skills @@ -173,7 +170,6 @@ export default function InstalledSkillsTab({ void handleEdit(skill)} onMouseEnter={() => setHoverKey(skill.slug)} diff --git a/dashboard/src/pages/Agent/Skills/components/SkillCard.tsx b/dashboard/src/pages/Agent/Skills/components/SkillCard.tsx index cb34f0ec..42fba928 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillCard.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillCard.tsx @@ -14,18 +14,8 @@ import type { SkillSpec } from "../useSkills"; import { useSkillDisplayName } from "../skillDisplayNames"; import styles from "../index.module.less"; -/** SkillHub metadata matched by slug — used to show the marketplace's - * Chinese name / description / icon for installed skills (falls back to the - * local SKILL.md values when absent). */ -export interface SkillHubInfo { - name?: string; - description_zh?: string; - iconUrl?: string | null; -} - interface SkillCardProps { skill: SkillSpec; - hubInfo?: SkillHubInfo; isHover: boolean; onClick: () => void; onMouseEnter: () => void; @@ -186,7 +176,6 @@ const renderSkillIcon = (skill: SkillSpec) => { export function SkillCard({ skill, - hubInfo, onClick, onMouseEnter, onMouseLeave, @@ -201,11 +190,9 @@ export function SkillCard({ const iconColor = DEFAULT_COLOR; const iconBg = `${iconColor}18`; // ~10% opacity tint - // Prefer the SkillHub marketplace's Chinese name / description / icon when - // this installed skill matches a hub skill by slug; fall back to SKILL.md. - const displayName = hubInfo?.name || skillDisplayName(skill); - const displayDesc = hubInfo?.description_zh || skill.description; - const hubIcon = hubInfo?.iconUrl || skill.iconUrl; + const displayName = skillDisplayName(skill); + const displayDesc = skill.description; + const hubIcon = skill.iconUrl; const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation(); diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts index fd695a2f..1f6e8e79 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts +++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts @@ -37,6 +37,27 @@ describe("SkillDrawer emoji metadata", () => { expect(md).toContain("octop:"); }); + it("writes localized presentation fields into octop metadata", () => { + const md = buildSkillMarkdown({ + name: "demo", + description: "Agent trigger description", + labelZh: "演示技能", + labelEn: "Demo Skill", + summaryZh: "完成演示任务", + summaryEn: "Complete demo tasks", + emoji: "⚙️", + metadata: [], + body: "Do things.", + }); + + expect(md).toContain("label:"); + expect(md).toContain("zh: 演示技能"); + expect(md).toContain("en: Demo Skill"); + expect(md).toContain("summary:"); + expect(md).toContain("zh: 完成演示任务"); + expect(md).toContain("en: Complete demo tasks"); + }); + it("extracts emoji from flattened metadata and keeps other keys", () => { const { emoji, metadata } = parseSkillEmojiAndMetadata([ { key: OCTOP_EMOJI_META_KEY, value: "🔧" }, diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx index b49354d3..def4c391 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx @@ -33,6 +33,10 @@ export interface MetadataEntry { export interface SkillFormValues { name: string; description: string; + labelZh?: string; + labelEn?: string; + summaryZh?: string; + summaryEn?: string; /** Surfaced as ``metadata.octop.emoji`` in SKILL.md. */ emoji: string; metadata: MetadataEntry[]; @@ -43,6 +47,16 @@ export interface SkillFormValues { } export const OCTOP_EMOJI_META_KEY = "octop.emoji"; +const OCTOP_LABEL_ZH_META_KEY = "octop.label.zh"; +const OCTOP_LABEL_EN_META_KEY = "octop.label.en"; +const OCTOP_SUMMARY_ZH_META_KEY = "octop.summary.zh"; +const OCTOP_SUMMARY_EN_META_KEY = "octop.summary.en"; +const PRESENTATION_META_KEYS = new Set([ + OCTOP_LABEL_ZH_META_KEY, + OCTOP_LABEL_EN_META_KEY, + OCTOP_SUMMARY_ZH_META_KEY, + OCTOP_SUMMARY_EN_META_KEY, +]); /** * The skill name doubles as the workspace directory slug, so only @@ -135,6 +149,24 @@ function withEmojiMetadata( return [{ key: OCTOP_EMOJI_META_KEY, value }, ...rest]; } +function withPresentationMetadata( + pairs: MetadataEntry[] | undefined, + values: SkillFormValues, +): MetadataEntry[] { + const rest = (pairs ?? []).filter( + (row) => !PRESENTATION_META_KEYS.has(row.key.trim()), + ); + const presentation = [ + [OCTOP_LABEL_ZH_META_KEY, values.labelZh], + [OCTOP_LABEL_EN_META_KEY, values.labelEn], + [OCTOP_SUMMARY_ZH_META_KEY, values.summaryZh], + [OCTOP_SUMMARY_EN_META_KEY, values.summaryEn], + ] + .filter((entry): entry is [string, string] => Boolean(entry[1]?.trim())) + .map(([key, value]) => ({ key, value: value.trim() })); + return [...presentation, ...rest]; +} + function buildMetadataObject( pairs: MetadataEntry[] | undefined, ): Record { @@ -154,7 +186,10 @@ export function buildSkillMarkdown(values: SkillFormValues): string { `description: ${yamlQuote(values.description.trim())}`, ]; const meta = buildMetadataObject( - withEmojiMetadata(values.metadata, values.emoji ?? ""), + withEmojiMetadata( + withPresentationMetadata(values.metadata, values), + values.emoji ?? "", + ), ); if (Object.keys(meta).length > 0) { lines.push("metadata:"); @@ -214,11 +249,19 @@ function parseSkillFormFromDetail(detail: SkillDetail): SkillFormValues { flattenMetadata(fm.metadata), detail.emoji?.trim() || DEFAULT_SKILL_EMOJI, ); + const presentationValue = (key: string) => + metadata.find((entry) => entry.key === key)?.value ?? ""; return { name: displayName, description, + labelZh: presentationValue(OCTOP_LABEL_ZH_META_KEY), + labelEn: presentationValue(OCTOP_LABEL_EN_META_KEY), + summaryZh: presentationValue(OCTOP_SUMMARY_ZH_META_KEY), + summaryEn: presentationValue(OCTOP_SUMMARY_EN_META_KEY), emoji, - metadata, + metadata: metadata.filter( + (entry) => !PRESENTATION_META_KEYS.has(entry.key), + ), body: detail.body || "", content: detail.raw, source: detail.kind === "builtin" ? "builtin" : "workspace", @@ -490,6 +533,37 @@ export function SkillDrawer({ ); + const presentationFields = ( + <> + + + + + + + + + + + + + + ); + const emojiField = ( {nameField} {descriptionField} + {presentationFields} {emojiField} {metadataFields}
@@ -726,6 +801,7 @@ export function SkillDrawer({
{nameField} {descriptionField} + {presentationFields} {emojiField} diff --git a/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx b/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx index 0f3c1ad5..7982e52c 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx @@ -49,6 +49,41 @@ function skillDesc(skill: SkillHubSkill): string { return skill.description_zh || skill.description || ""; } +function firstText(...values: unknown[]): string { + for (const value of values) { + const text = String(value ?? "").trim(); + if (text) return text; + } + return ""; +} + +function localizedPair( + zh: string, + en: string, +): { zh?: string; en?: string } | undefined { + const pair: { zh?: string; en?: string } = {}; + if (zh) pair.zh = zh; + if (en && en !== zh) pair.en = en; + return Object.keys(pair).length > 0 ? pair : undefined; +} + +function hubInstallPresentation(skill: SkillHubSkill): { + label?: { zh?: string; en?: string }; + summary?: { zh?: string; en?: string }; +} { + const raw = skill as SkillHubSkill & Record; + return { + label: localizedPair( + firstText(raw.display_name_zh, raw.displayName, skill.name), + firstText(raw.display_name_en, raw.displayNameEn), + ), + summary: localizedPair( + firstText(skill.description_zh, raw.summary_zh, skill.description), + firstText(raw.description_en, raw.summary_en), + ), + }; +} + function normalizeHubSkill(raw: Record): SkillHubSkill { const slug = String(raw.slug ?? raw.name ?? ""); return { @@ -226,10 +261,12 @@ export default function SkillHubTab({ target, onInstalled }: SkillHubTabProps) { setDrawerOpen(false); setInstallingSlug(skill.slug); try { + const presentation = hubInstallPresentation(skill); const body: Record = { skill_name: skill.slug, - display_name: skill.name, icon_url: skill.iconUrl ?? null, + label: presentation.label, + summary: presentation.summary, overwrite: true, }; if (installTarget.type === "agent") { diff --git a/dashboard/src/pages/Agent/Skills/components/SkillPackagesTab.tsx b/dashboard/src/pages/Agent/Skills/components/SkillPackagesTab.tsx index b3c57332..5f6af122 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillPackagesTab.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillPackagesTab.tsx @@ -13,7 +13,6 @@ import { PackageIcon } from "../../../SkillPackages/PackageIcon"; import { showApiError } from "../../../../utils/showApiToast"; import { supportsHostSkillPackagesFromConfig } from "../../../Experts/components/agentBackendForm"; import type { SkillSpec } from "../useSkills"; -import { hubInfoBySlugFromCache } from "./skillHubCache"; import styles from "../index.module.less"; interface SkillPackagesTabProps { @@ -26,10 +25,8 @@ interface SkillPackagesTabProps { function resolvePackageSkillIcon( packageSkill: SkillPackageSkill, installed: SkillSpec | undefined, - hubIconUrl?: string | null, ): { iconUrl?: string; emoji?: string } { - const iconUrl = - hubIconUrl || packageSkill.icon_url || installed?.iconUrl || undefined; + const iconUrl = packageSkill.icon_url || installed?.iconUrl || undefined; const emoji = packageSkill.emoji || installed?.emoji; return { iconUrl, emoji }; } @@ -77,8 +74,6 @@ export default function SkillPackagesTab({ ); const [detailLoading, setDetailLoading] = useState(false); - const hubSkillsBySlug = useMemo(() => hubInfoBySlugFromCache(), []); - useEffect(() => { let cancelled = false; setLoading(true); @@ -302,18 +297,13 @@ export default function SkillPackagesTab({
{detailPackage.skills.map((packageSkill) => { const installed = skillsBySlug.get(packageSkill.slug); - const hubInfo = hubSkillsBySlug.get(packageSkill.slug); const { iconUrl, emoji } = resolvePackageSkillIcon( packageSkill, installed, - hubInfo?.iconUrl, ); - const displayName = - hubInfo?.name || packageSkill.name || packageSkill.slug; + const displayName = packageSkill.name || packageSkill.slug; const displayDesc = - hubInfo?.description_zh || - packageSkill.description || - t("skills.noDescription"); + packageSkill.description || t("skills.noDescription"); const shadows = workspaceSlugs.has(packageSkill.slug); const canToggle = detailMounted && !!installed && !shadows; diff --git a/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx b/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx index 3020f2b0..abae4174 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx @@ -1,6 +1,6 @@ -import { Popconfirm, Switch, Table, Tag } from "antd"; +import { Popconfirm, Switch, Table, Tag, Tooltip } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { Trash2 } from "lucide-react"; +import { Eye, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { SkillSpec } from "../useSkills"; import { useSkillDisplayName } from "../skillDisplayNames"; @@ -71,20 +71,23 @@ export default function SkillsTable({ { title: t("skills.table.actions", "操作"), key: "actions", - width: kind === "custom" ? "12%" : "8%", + width: kind === "custom" ? 88 : 56, align: "center", render: (_v, row) => (
- + + + {kind === "custom" && onDelete ? ( ({ onClick: () => onView(row), style: { cursor: "pointer" }, diff --git a/dashboard/src/pages/Agent/Skills/components/skillHubCache.ts b/dashboard/src/pages/Agent/Skills/components/skillHubCache.ts index e20751d3..a52790e4 100644 --- a/dashboard/src/pages/Agent/Skills/components/skillHubCache.ts +++ b/dashboard/src/pages/Agent/Skills/components/skillHubCache.ts @@ -1,4 +1,3 @@ -import type { SkillHubInfo } from "./SkillCard"; import type { SkillHubSkill } from "./SkillHubDetailDrawer"; const RANKINGS_CACHE_KEY = "octop:skillhub-rankings:v1"; @@ -32,15 +31,3 @@ export function saveRankingsCache(data: Record): void { // localStorage may be full or unavailable; ignore silently. } } - -/** Flatten rankings cache into a slug → presentation map (same as installed skills). */ -export function hubInfoBySlugFromCache(): Map { - const bySlug = new Map(); - const cached = loadRankingsCache() ?? {}; - for (const rows of Object.values(cached)) { - for (const row of rows) { - bySlug.set(row.slug, row); - } - } - return bySlug; -} diff --git a/dashboard/src/pages/Agent/Skills/skillDisplayNames.test.ts b/dashboard/src/pages/Agent/Skills/skillDisplayNames.test.ts new file mode 100644 index 00000000..c52523cb --- /dev/null +++ b/dashboard/src/pages/Agent/Skills/skillDisplayNames.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { resolveSkillDisplayName } from "./skillDisplayNames"; + +describe("resolveSkillDisplayName", () => { + it("prefers the API presentation name over the slug", () => { + expect( + resolveSkillDisplayName({ slug: "pdf", name: "PDF 阅读与编辑" }), + ).toBe("PDF 阅读与编辑"); + }); + + it("uses the slug when the API only returns the identity name", () => { + expect(resolveSkillDisplayName({ slug: "pdf", name: "pdf" })).toBe("pdf"); + }); +}); diff --git a/dashboard/src/pages/Agent/Skills/skillDisplayNames.ts b/dashboard/src/pages/Agent/Skills/skillDisplayNames.ts index ba4733f6..fa09493b 100644 --- a/dashboard/src/pages/Agent/Skills/skillDisplayNames.ts +++ b/dashboard/src/pages/Agent/Skills/skillDisplayNames.ts @@ -1,36 +1,22 @@ /** - * Localised labels for built-in agent skills shown in the dashboard. - * Labels are defined in backend ``src/octop/i18n/*.json`` (``skills.*``). + * Resolve API-provided presentation names. ``name`` is the localized label + * from the list API; ``slug`` is the stable identity. */ -import { useTranslation } from "react-i18next"; - export interface SkillLabelInput { slug?: string; name?: string; } -export function resolveSkillDisplayName( - skill: SkillLabelInput, - translate: (slug: string) => string, -): string { - const slug = skill.slug ?? skill.name ?? ""; - if (!slug) return ""; - const localized = translate(slug); - if (localized !== slug) return localized; - return skill.name || slug; +export function resolveSkillDisplayName(skill: SkillLabelInput): string { + const slug = (skill.slug ?? "").trim(); + const name = (skill.name ?? "").trim(); + if (name && slug && name !== slug) return name; + return name || slug; } export function useSkillDisplayName(): (skill: SkillLabelInput) => string { - const { t } = useTranslation(); - - const translate = (slug: string) => { - const key = `skills.${slug}`; - const translated = t(key); - return translated === key ? slug : translated; - }; - - return (skill: SkillLabelInput) => resolveSkillDisplayName(skill, translate); + return resolveSkillDisplayName; } /** Resolve by slug alone (e.g. expert template preview before agent exists). */ diff --git a/dashboard/src/pages/Agent/Skills/useSkills.ts b/dashboard/src/pages/Agent/Skills/useSkills.ts index 56ed41be..0f6cc7f1 100644 --- a/dashboard/src/pages/Agent/Skills/useSkills.ts +++ b/dashboard/src/pages/Agent/Skills/useSkills.ts @@ -1,6 +1,5 @@ import { useCallback, useState } from "react"; -import { Modal } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App } from "antd"; import { useTranslation } from "react-i18next"; import { request } from "../../../api/request"; @@ -94,6 +93,7 @@ export function useSkills( options?: { enabled?: boolean }, ) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const enabled = options?.enabled !== false && !!agentId; const { @@ -209,7 +209,7 @@ export function useSkills( async (skill: SkillSpec): Promise => { if (!agentId) return false; const confirmed = await new Promise((resolve) => { - Modal.confirm({ + modal.confirm({ title: t("common.delete"), content: t("skills.deleteConfirmContent", { slug: skill.slug }), okText: t("common.delete"), diff --git a/dashboard/src/pages/Agent/Tools/ToolsPanel.tsx b/dashboard/src/pages/Agent/Tools/ToolsPanel.tsx index 647bff3c..231f0e50 100644 --- a/dashboard/src/pages/Agent/Tools/ToolsPanel.tsx +++ b/dashboard/src/pages/Agent/Tools/ToolsPanel.tsx @@ -55,6 +55,7 @@ import styles from "./ToolsPanel.module.less"; const CATEGORY_ORDER = [ "filesystem", "orchestration", + "interaction", "web", "media", "memory", @@ -70,6 +71,7 @@ const CATEGORY_ORDER = [ const CATEGORY_ACCENT: Record = { filesystem: "#3B82F6", orchestration: "#8B5CF6", + interaction: "#14B8A6", web: "#22C55E", media: "#EC4899", memory: "#F59E0B", diff --git a/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx b/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx index c59f1b75..07e83300 100644 --- a/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx +++ b/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react"; import { + App, Tree, Empty, Spin, @@ -21,7 +22,6 @@ import { Popconfirm, Input, } from "antd"; -import { message } from "@/utils/antdMessage"; import type { TreeDataNode, TreeProps } from "antd"; import { @@ -259,6 +259,7 @@ export default function WorkspaceDrawer({ onClose, }: WorkspaceDrawerProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const isMobile = useIsMobile(); const timeZone = useServerTimezone(); const { agents } = useAgent(); @@ -707,7 +708,7 @@ export default function WorkspaceDrawer({ label: t("common.delete"), danger: true, onClick: () => { - Modal.confirm({ + modal.confirm({ title: target.is_dir ? t("workspace.deleteDirConfirm") : t("workspace.deleteConfirm"), diff --git a/dashboard/src/pages/Chat/ChatToolDockContext.tsx b/dashboard/src/pages/Chat/ChatToolDockContext.tsx new file mode 100644 index 00000000..ef35825a --- /dev/null +++ b/dashboard/src/pages/Chat/ChatToolDockContext.tsx @@ -0,0 +1,70 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { dockToolUiTabId } from "./utils/dockToolUiTabId"; +import type { DockTab, DockTabId } from "./hooks/useChatDockPanel"; + +export interface OpenToolUiPanelOptions { + callId: string; + title?: string; + toolName?: string; +} + +interface ChatToolDockContextValue { + openToolUiPanel: (opts: OpenToolUiPanelOptions) => void; + closeToolUiPanel: (callId: string) => void; + focusToolUiPanel: (callId: string) => void; + isToolUiDocked: (callId: string | undefined) => boolean; +} + +const ChatToolDockContext = createContext( + null, +); + +export function ChatToolDockProvider({ + dockOpen, + openTabs, + activeTabId, + openToolUiPanel, + closeToolUiPanel, + focusToolUiPanel, + children, +}: { + dockOpen: boolean; + openTabs: DockTab[]; + activeTabId: DockTabId | null; + openToolUiPanel: (opts: OpenToolUiPanelOptions) => void; + closeToolUiPanel: (callId: string) => void; + focusToolUiPanel: (callId: string) => void; + children: ReactNode; +}) { + const activeToolUiCallId = useMemo(() => { + if (!dockOpen || activeTabId == null) return null; + const active = openTabs.find((tab) => tab.id === activeTabId); + return active?.kind === "toolUi" ? active.callId : null; + }, [dockOpen, openTabs, activeTabId]); + + const value = useMemo( + () => ({ + openToolUiPanel, + closeToolUiPanel, + focusToolUiPanel, + // Placeholder only while this tool's tab is the visible dock surface. + // Closing the dock, the tab, or switching away restores the chat card. + isToolUiDocked: (callId) => !!callId && activeToolUiCallId === callId, + }), + [activeToolUiCallId, openToolUiPanel, closeToolUiPanel, focusToolUiPanel], + ); + + return ( + + {children} + + ); +} + +export function useChatToolDock(): ChatToolDockContextValue | null { + return useContext(ChatToolDockContext); +} + +export function dockTabIdForToolCall(callId: string): DockTabId { + return dockToolUiTabId(callId); +} diff --git a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less index bc26bfbd..2b0269c8 100644 --- a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less +++ b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less @@ -221,6 +221,39 @@ justify-content: center; } +.dockToolUiBody { + flex: 1; + min-width: 0; + min-height: 0; + width: 100%; + overflow: auto; + padding: 12px; + box-sizing: border-box; + + /* Stretch plugin cards that use chat-column maxWidth (often inline). */ + .toolUiRendererWrap, + [data-octop-tool-renderer], + [data-octop-plugin-ui], + .octop-builtin-ui-fallback { + display: block; + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box; + } +} + +.dockToolUiMissing { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--fn-text-tertiary); + font-size: 13px; + text-align: center; +} + .dockFileList { flex: 1; min-height: 0; diff --git a/dashboard/src/pages/Chat/chatContextChips.partial.less b/dashboard/src/pages/Chat/chatContextChips.partial.less index b9d9c817..7e4f0ef5 100644 --- a/dashboard/src/pages/Chat/chatContextChips.partial.less +++ b/dashboard/src/pages/Chat/chatContextChips.partial.less @@ -48,6 +48,17 @@ height: 16px; } +.contextChipIconImg { + width: 14px; + height: 14px; + object-fit: contain; +} + +.contextChipCompact .contextChipIconImg { + width: 12px; + height: 12px; +} + .contextChipLabel { min-width: 0; flex: 1; diff --git a/dashboard/src/pages/Chat/chatInputCore.partial.less b/dashboard/src/pages/Chat/chatInputCore.partial.less index c0365e22..eba3f8f6 100644 --- a/dashboard/src/pages/Chat/chatInputCore.partial.less +++ b/dashboard/src/pages/Chat/chatInputCore.partial.less @@ -18,6 +18,22 @@ } } +.askQuestionDock { + flex-shrink: 0; + padding: 0 20px 8px; + background: var(--fn-bg-elevated, #fff); + + @media (max-width: 767px) { + padding: 0 12px 6px; + } +} + +.askQuestionDockInner { + width: 100%; + max-width: var(--chat-column-max, 960px); + margin: 0 auto; +} + .chatInput { flex-shrink: 0; padding: 16px 24px 20px; diff --git a/dashboard/src/pages/Chat/chatInputPickers.partial.less b/dashboard/src/pages/Chat/chatInputPickers.partial.less index 74fa80cb..ac246312 100644 --- a/dashboard/src/pages/Chat/chatInputPickers.partial.less +++ b/dashboard/src/pages/Chat/chatInputPickers.partial.less @@ -514,6 +514,13 @@ font-size: 13px; font-weight: 600; line-height: 1; + overflow: hidden; +} + +.skillPickerAvatarImg { + width: 18px; + height: 18px; + object-fit: contain; } .skillPickerText { diff --git a/dashboard/src/pages/Chat/chatMessages.partial.less b/dashboard/src/pages/Chat/chatMessages.partial.less index 7c9cc3f4..0d296912 100644 --- a/dashboard/src/pages/Chat/chatMessages.partial.less +++ b/dashboard/src/pages/Chat/chatMessages.partial.less @@ -709,6 +709,104 @@ max-width: 100%; } +.toolUiRendererWrap { + position: relative; + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + /* Normal block flow: cards with maxWidth keep filling up to that cap; + cards with width:100% fill the message column. Do not use fit-content / + inline-grid here — those shrink to content width. */ + + .toolUiDockActions { + position: absolute; + z-index: 2; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } + + &:hover .toolUiDockActions, + &:focus-within .toolUiDockActions { + opacity: 1; + pointer-events: auto; + } + + @media (hover: none) { + .toolUiDockActions { + opacity: 1; + pointer-events: auto; + } + } +} + +.toolUiDockBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--fn-border-secondary); + border-radius: 8px; + background: color-mix(in srgb, var(--fn-bg-primary) 92%, transparent); + color: var(--fn-text-secondary); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); + cursor: pointer; + backdrop-filter: blur(4px); + + &:hover { + color: var(--fn-color-brand); + border-color: color-mix(in srgb, var(--fn-color-brand) 38%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } +} + +.toolUiDockPlaceholder { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 8px; + max-width: 100%; + padding: 8px 12px; + border: 1px dashed var(--fn-border-secondary); + border-radius: 10px; + background: color-mix(in srgb, var(--fn-bg-secondary) 70%, transparent); + color: var(--fn-text-secondary); + text-align: left; + cursor: pointer; + + &:hover { + border-color: color-mix(in srgb, var(--fn-color-brand) 38%, transparent); + color: var(--fn-color-brand); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } +} + +.toolUiDockPlaceholderTitle { + font-size: 13px; + font-weight: 500; + color: var(--fn-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolUiDockPlaceholderHint { + font-size: 12px; + color: var(--fn-text-tertiary); + flex-shrink: 0; +} + .assistantTurnAnswer { min-width: 0; } diff --git a/dashboard/src/pages/Chat/components/AskQuestionCard.module.less b/dashboard/src/pages/Chat/components/AskQuestionCard.module.less new file mode 100644 index 00000000..d7e613ca --- /dev/null +++ b/dashboard/src/pages/Chat/components/AskQuestionCard.module.less @@ -0,0 +1,342 @@ +.card { + border: 1px solid var(--fn-border-primary); + border-radius: var(--fn-radius-lg); + padding: 14px 16px; + background: var(--fn-bg-primary); + box-shadow: var(--fn-shadow-xs); + box-sizing: border-box; + width: 100%; + max-width: var(--chat-column-max, 960px); +} + +.titleRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.title { + font-weight: 600; + font-size: 14px; + color: var(--fn-text-primary); +} + +.progress { + padding: 2px 8px; + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-full); + background: var(--fn-bg-secondary); + font-size: 12px; + line-height: 18px; + color: var(--fn-text-tertiary); + font-variant-numeric: tabular-nums; +} + +.steps { + max-height: min(420px, 42vh); + overflow-y: auto; +} + +.block { + padding: 4px 0 14px; +} + +.questionRow { + display: flex; + gap: 8px; + margin-bottom: 8px; + align-items: baseline; +} + +.qIndex { + color: var(--fn-text-tertiary); + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +.qText { + font-weight: 500; + line-height: 1.5; + color: var(--fn-text-primary); +} + +.qHeader { + color: var(--fn-text-secondary); + font-weight: 400; +} + +.options { + display: flex; + flex-direction: column; + gap: 6px; + padding-left: 20px; +} + +.option { + display: flex; + align-items: flex-start; + gap: 10px; + text-align: left; + min-height: 42px; + padding: 9px 11px; + border: 1px solid var(--fn-border-primary); + border-radius: var(--fn-radius-md); + background: var(--fn-bg-primary); + color: var(--fn-text-primary); + cursor: pointer; + transition: + border-color var(--fn-transition-fast), + background var(--fn-transition-fast), + box-shadow var(--fn-transition-fast), + transform var(--fn-transition-fast); + + &:hover:not(:disabled) { + border-color: var(--fn-color-brand); + background: var(--fn-bg-hover); + box-shadow: var(--fn-shadow-xs); + transform: translateY(-1px); + } + + &:active:not(:disabled) { + transform: scale(0.99); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } + + &:disabled { + cursor: default; + } +} + +.selected { + border-color: var(--fn-color-brand); + background: var(--fn-color-brand-bg); +} + +.key { + flex-shrink: 0; + width: 18px; + height: 18px; + border-radius: 4px; + border: 1px solid var(--fn-border-primary); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: var(--fn-text-secondary); + margin-top: 1px; +} + +.selected .key { + border-color: var(--fn-color-brand); + color: var(--fn-color-on-brand); + background: var(--fn-color-brand); +} + +.optionBody { + display: flex; + flex-direction: column; + min-width: 0; +} + +.optionLabel { + font-weight: 500; + line-height: 1.4; +} + +.optionDesc { + font-size: 12px; + line-height: 1.5; + color: var(--fn-text-tertiary); +} + +.freeText { + margin-top: 6px; + + &:global(.octop-input) { + border-color: var(--fn-border-input); + border-radius: var(--fn-radius-md); + background: var(--fn-bg-primary); + + &:focus, + &:focus-within { + border-color: var(--fn-color-brand); + box-shadow: 0 0 0 2px var(--fn-color-brand-glow); + } + } +} + +.doneAnswer { + padding-left: 20px; + font-size: 13px; + color: var(--fn-text-secondary); + line-height: 1.5; +} + +.actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; + padding-top: 12px; + border-top: 1px solid var(--fn-border-secondary); +} + +.actionButton { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 68px; + height: 34px; + padding: 0 15px; + border: 1px solid transparent; + border-radius: var(--fn-radius-md); + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 1; + cursor: pointer; + transition: + color var(--fn-transition-fast), + background var(--fn-transition-fast), + border-color var(--fn-transition-fast), + box-shadow var(--fn-transition-fast), + transform var(--fn-transition-fast); + + &:active:not(:disabled) { + transform: scale(0.97); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } +} + +.primaryAction { + color: var(--fn-color-on-brand); + background: var(--fn-color-brand); + border-color: var(--fn-color-brand); + box-shadow: 0 1px 2px var(--fn-color-brand-shadow); + + &:hover:not(:disabled) { + background: var(--fn-color-brand-hover); + border-color: var(--fn-color-brand-hover); + box-shadow: var(--fn-shadow-brand); + } +} + +.secondaryAction { + color: var(--fn-text-secondary); + background: var(--fn-bg-primary); + border-color: var(--fn-border-primary); + + &:hover:not(:disabled) { + color: var(--fn-text-brand); + background: var(--fn-bg-hover); + border-color: var(--fn-color-brand-border); + } +} + +.skipAction { + margin-left: auto; + min-width: auto; + padding-inline: 10px; + color: var(--fn-text-tertiary); + background: transparent; + + &:hover:not(:disabled) { + color: var(--fn-text-secondary); + background: var(--fn-bg-secondary); + } +} + +.completedCard { + padding: 0; + border-color: transparent; + background: transparent; + box-shadow: none; +} + +.completedSummary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-md); + color: var(--fn-text-secondary); + background: var(--fn-bg-secondary); + cursor: pointer; + list-style: none; + font-size: 13px; + + &::-webkit-details-marker { + display: none; + } + + &::before { + content: "›"; + flex-shrink: 0; + color: var(--fn-text-tertiary); + transition: transform 0.15s ease; + } +} + +.completedCard[open] .completedSummary::before { + transform: rotate(90deg); +} + +.completedStatus { + flex: 1; + min-width: 0; + font-weight: 500; +} + +.expandHint { + flex-shrink: 0; + font-size: 12px; + color: var(--fn-text-tertiary); +} + +.completedQuestions { + margin-top: 6px; + padding: 10px 12px 0; + border-left: 2px solid var(--fn-border-primary); +} + +@media (max-width: 767px) { + .card { + padding: 12px; + } + + .options, + .doneAnswer { + padding-left: 0; + } + + .option { + min-height: 44px; + } + + .actions { + gap: 6px; + } + + .actionButton { + height: 36px; + padding-inline: 13px; + } +} diff --git a/dashboard/src/pages/Chat/components/AskQuestionCard.test.tsx b/dashboard/src/pages/Chat/components/AskQuestionCard.test.tsx new file mode 100644 index 00000000..a87bb33c --- /dev/null +++ b/dashboard/src/pages/Chat/components/AskQuestionCard.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import AskQuestionCard from "./AskQuestionCard"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const questions = [ + { + header: "Framework", + question: "Which framework?", + options: [{ label: "React" }, { label: "Vue" }], + }, + { + header: "Database", + question: "Which databases?", + multi_select: true, + options: [{ label: "PostgreSQL" }, { label: "Redis" }], + }, +]; + +describe("AskQuestionCard", () => { + it("shows exactly one question at a time", () => { + render( + , + ); + + expect(screen.getByText("Which framework?")).toBeInTheDocument(); + expect(screen.queryByText("Which databases?")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /React/ })); + + expect(screen.queryByText("Which framework?")).not.toBeInTheDocument(); + expect(screen.getByText("Which databases?")).toBeInTheDocument(); + }); + + it("collects all answers before submitting once", () => { + const onSubmit = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /React/ })); + fireEvent.click(screen.getByRole("button", { name: /PostgreSQL/ })); + fireEvent.click(screen.getByRole("button", { name: "chat.ask.review" })); + + expect(screen.getByText("Which framework?")).toBeInTheDocument(); + expect(screen.getByText("Which databases?")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "chat.ask.submit" })); + expect(onSubmit).toHaveBeenCalledWith( + "Framework: React\nDatabase: PostgreSQL", + ); + }); + + it("renders open-ended questions as a text field directly", () => { + render( + , + ); + + expect( + screen.getByPlaceholderText("chat.ask.freeTextPlaceholder"), + ).toBeInTheDocument(); + expect(screen.queryByText("chat.ask.other")).not.toBeInTheDocument(); + }); + + it("collapses an answered question set in message history", () => { + const { container } = render( + , + ); + + const details = container.querySelector("details"); + expect(details).not.toHaveAttribute("open"); + expect(screen.getByText("chat.ask.answeredSummary")).toBeInTheDocument(); + expect(screen.queryByText("Which framework?")).not.toBeVisible(); + + fireEvent.click(container.querySelector("summary")!); + expect(details).toHaveAttribute("open"); + expect(screen.getByText("Which framework?")).toBeVisible(); + }); +}); diff --git a/dashboard/src/pages/Chat/components/AskQuestionCard.tsx b/dashboard/src/pages/Chat/components/AskQuestionCard.tsx new file mode 100644 index 00000000..19020530 --- /dev/null +++ b/dashboard/src/pages/Chat/components/AskQuestionCard.tsx @@ -0,0 +1,320 @@ +import { useState } from "react"; +import { Input } from "antd"; +import { useTranslation } from "react-i18next"; +import type { AskQuestion } from "../../../api/types/hitl"; +import styles from "./AskQuestionCard.module.less"; + +const { TextArea } = Input; + +/** Sentinel choice that reveals the free-text input for a question. */ +const OTHER = "__other__"; + +const OPTION_KEYS = ["A", "B", "C", "D", "E", "F"] as const; + +export interface AskQuestionCardProps { + questions: AskQuestion[]; + status: "pending" | "approved" | "rejected"; + onSubmit?: (message: string) => void; +} + +/** What the user picked for one question. */ +type Answer = { picked: string[]; other: string }; + +/** Resolve one question's answer to display text, or `null` when unanswered. */ +function answerText(question: AskQuestion, answer: Answer): string | null { + const labels = answer.picked.filter((v) => v !== OTHER); + const wantsOther = answer.picked.includes(OTHER) || !question.options?.length; + const other = answer.other.trim(); + if (wantsOther && other) labels.push(other); + if (!labels.length) return null; + return labels.join("; "); +} + +interface QuestionCardProps { + question: AskQuestion; + index: number; + /** Stepped flow state for this question. */ + state: "done" | "current"; + answer: Answer; + onAnswer: (answer: Answer, changed: string) => void; +} + +function QuestionCard({ + question, + index, + state, + answer, + onAnswer, +}: QuestionCardProps) { + const { t } = useTranslation(); + const options = question.options ?? []; + const done = state === "done"; + const interactive = state === "current"; + const openEnded = options.length === 0; + const hasOther = openEnded || answer.picked.includes(OTHER); + + const toggle = (label: string) => { + if (!interactive) return; + if (question.multi_select) { + const picked = answer.picked.includes(label) + ? answer.picked.filter((v) => v !== label) + : [...answer.picked, label]; + onAnswer({ ...answer, picked }, label); + } else { + onAnswer({ ...answer, picked: [label] }, label); + } + }; + + const confirmed = answerText(question, answer); + + return ( +
+
+ {index + 1}. + + {question.header ? ( + {question.header} · + ) : null} + {question.question} + +
+ + {done && confirmed !== null ? ( +
{confirmed}
+ ) : ( +
+ {options.map((option, optionIndex) => { + const key = OPTION_KEYS[optionIndex] ?? String(optionIndex + 1); + const selected = answer.picked.includes(option.label); + return ( + + ); + })} + {!openEnded ? ( + + ) : null} + {interactive && hasOther ? ( +