From f438438e6f8ee5f9ad90595df056d3586958384b Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Mon, 3 Aug 2026 18:27:31 +0800 Subject: [PATCH 1/2] Develop (#150) --- .githooks/pre-commit | 25 +- .github/workflows/release.yml | 10 +- AGENTS.md | 16 +- CHANGELOG.md | 13 + CONTRIBUTING.md | 6 +- Makefile | 14 +- README.md | 4 +- README_CN.md | 4 +- dashboard/src/api/modules/connectors.ts | 19 +- .../src/api/modules/skillPackages.test.ts | 18 +- dashboard/src/api/modules/skillPackages.ts | 11 +- .../BrowserWorkspace/ChatDockPanelShell.tsx | 34 +- dashboard/src/components/RailEdgeControl.tsx | 6 +- dashboard/src/hooks/usePathTabs.ts | 8 +- dashboard/src/layouts/PageShell.tsx | 6 +- dashboard/src/locales/en.json | 50 +- dashboard/src/locales/zh.json | 54 +- .../Admin/Plugins/InstalledPluginsPanel.tsx | 6 +- .../Admin/Storage/StorageBackendModal.tsx | 5 +- .../src/pages/Admin/Users/index.module.less | 19 +- dashboard/src/pages/Admin/Users/index.tsx | 4 +- .../Channels/components/ChannelDrawer.tsx | 9 +- .../pages/Agent/Connectors/index.module.less | 9 +- .../src/pages/Agent/Connectors/index.tsx | 196 ++++---- .../src/pages/Agent/Memory/MemoryPanel.tsx | 16 +- dashboard/src/pages/Agent/Memory/Overview.tsx | 5 +- .../pages/Agent/Memory/RawEventsList.test.tsx | 34 ++ .../src/pages/Agent/Memory/RawEventsList.tsx | 14 +- .../pages/Agent/Memory/VectorSearchConfig.tsx | 10 +- .../components/MBTISelector.tsx | 3 +- .../Skills/components/SkillDrawer.module.less | 1 - .../Agent/Skills/components/SkillDrawer.tsx | 8 +- .../Agent/Skills/components/SkillHubTab.tsx | 6 +- .../Skills/components/SkillImportModal.tsx | 14 +- .../Skills/components/SkillPackagesTab.tsx | 24 +- .../Agent/Skills/components/SkillsTabs.tsx | 9 +- .../Skills/components/parseSkillZip.test.ts | 8 +- .../Agent/Skills/components/parseSkillZip.ts | 7 +- .../src/pages/Agent/Skills/index.module.less | 1 - dashboard/src/pages/Agent/Skills/useSkills.ts | 4 +- .../pages/Chat/chatBrowserPanel.partial.less | 5 +- .../components/AssistantProcessSummary.tsx | 16 +- .../Chat/components/ChatDockFileList.tsx | 9 +- .../pages/Chat/components/ChatDockPanels.tsx | 8 +- .../pages/Chat/components/ChatTitleBar.tsx | 8 +- .../Chat/components/FilePanelContent.tsx | 4 +- .../Chat/components/GeneratingIndicator.tsx | 6 +- .../src/pages/Chat/components/MessageList.tsx | 6 +- .../Chat/components/SessionChannelIcon.tsx | 7 +- .../Chat/components/generatingGate.test.ts | 5 +- .../pages/Chat/components/generatingGate.ts | 3 +- .../pages/Chat/hooks/scrollFreeMode.test.ts | 24 +- .../sealPriorStreamingAssistants.test.ts | 7 +- .../hooks/sealPriorStreamingAssistants.ts | 6 +- .../src/pages/Chat/hooks/useAutoScroll.ts | 13 +- .../pages/Chat/hooks/useChatDockPanel.test.ts | 6 +- .../src/pages/Chat/hooks/useChatDockPanel.ts | 14 +- .../pages/Chat/hooks/useChatFileDetection.ts | 16 +- dashboard/src/pages/Chat/index.module.less | 3 +- dashboard/src/pages/Chat/index.tsx | 168 +++---- .../src/pages/Chat/utils/dockFilePath.test.ts | 18 +- .../src/pages/Chat/utils/dockFilePath.ts | 37 +- .../src/pages/Control/RemoteDesktop/index.tsx | 4 +- .../src/pages/Control/TokenUsage/index.tsx | 7 +- .../components/CreateFromExpertDrawer.tsx | 10 +- .../AdvancedSettings/UpdateConfig.module.less | 170 ++++++- .../AdvancedSettings/UpdateConfig.tsx | 468 ++++++++++++------ .../pages/Settings/AdvancedSettings/index.tsx | 12 +- .../src/pages/Settings/Embedding/index.tsx | 10 +- .../Settings/HttpsSettings/index.module.less | 125 +++++ .../pages/Settings/HttpsSettings/index.tsx | 304 ++++++------ .../components/modals/ModelListEditor.tsx | 10 +- .../pages/Settings/Observability/index.tsx | 10 +- .../Settings/SearchConfig/index.test.tsx | 4 +- .../src/pages/Settings/SearchConfig/index.tsx | 9 +- .../src/pages/Settings/Security/index.tsx | 10 +- dashboard/src/pages/Settings/Voice/index.tsx | 19 +- .../src/pages/SkillPackages/PackageIcon.tsx | 4 +- .../SkillsetFromHubDrawer.test.tsx | 14 +- .../SkillPackages/SkillsetFromHubDrawer.tsx | 5 +- .../src/pages/SkillPackages/index.module.less | 12 +- dashboard/src/pages/SkillPackages/index.tsx | 301 +++++------ dashboard/src/utils/chatStreamError.test.ts | 9 +- docs/assets/qrcode.png | Bin 22485 -> 16752 bytes src/octop/infra/agents/manager.py | 8 +- tests/unit/agents/test_agent_manager.py | 41 ++ 86 files changed, 1607 insertions(+), 1058 deletions(-) create mode 100644 dashboard/src/pages/Settings/HttpsSettings/index.module.less diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 611959cf..56391dbe 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Pre-commit gate: backend ship bar + dashboard production build. +# Pre-commit gate: make all (format BE+FE + lint + typecheck + test) + dashboard build. # Enable once per clone: make install-hooks # Bypass (emergency only): SKIP_PRECOMMIT=1 git commit ... # Or: git commit --no-verify @@ -13,9 +13,30 @@ fi ROOT="$(git rev-parse --show-toplevel)" cd "$ROOT" -echo "[pre-commit] make all (lint + typecheck + test)" +# Remember staged paths so we can re-add them after auto-format rewrites the worktree. +# Portable (bash 3.2+): avoid `mapfile` (bash 4+ only). +STAGED_FILES=() +while IFS= read -r line; do + [[ -n "$line" ]] && STAGED_FILES+=("$line") +done < <(git diff --cached --name-only --diff-filter=ACMR || true) + +echo "[pre-commit] make all (format-all + lint + typecheck + test)" make all +# If format rewrote files that were already staged, refresh the index so the +# commit includes the formatted content (worktree vs index drift). +if ((${#STAGED_FILES[@]} > 0)); then + existing=() + for f in "${STAGED_FILES[@]}"; do + if [[ -e "$f" || -L "$f" ]]; then + existing+=("$f") + fi + done + if ((${#existing[@]} > 0)); then + git add -- "${existing[@]}" + fi +fi + echo "[pre-commit] dashboard npm run build" ( cd "$ROOT/dashboard" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c8c0ec6..de83ed74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,12 +124,20 @@ jobs: # workflow_run / release:published are unreliable when Release itself was # started via GITHUB_TOKEN workflow_dispatch (Auto Tag). Explicitly cascade. + # + # This job intentionally has no checkout: pass --repo so `gh` does not need a + # local .git (bare runner → "fatal: not a git repository"). continue-on-error + # keeps a cascade miss from marking PyPI/GitHub release as failed. sync-develop: name: Trigger sync main into develop needs: github-release runs-on: ubuntu-latest + continue-on-error: true steps: - name: Dispatch Sync Main Into Develop env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh workflow run sync-main-to-develop.yml --ref main + run: | + gh workflow run sync-main-to-develop.yml \ + --repo "${{ github.repository }}" \ + --ref main diff --git a/AGENTS.md b/AGENTS.md index 84c36f7f..29188bf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,18 +183,20 @@ Frontend talks to Octop **only** via `/api` HTTP — never import or assume Pyth ```bash make install-hooks # once per clone: enable .githooks pre-commit +make all # format-all + lint + typecheck + test (ship bar) +make format-all # backend Ruff + dashboard Prettier write +make lint # ruff check + format check +make typecheck # mypy --strict src/octop +make format # ruff auto-fix + format (backend only) +make format-frontend # prettier write (dashboard only) uv run pytest -m "not live" # full test suite (no LLM calls) uv run pytest tests/unit -x -q # unit tests only, stop on first fail uv run pytest tests/integration -x -q # integration tests only -make lint # ruff check + format check -make typecheck # mypy --strict src/octop -make format # auto-fix lint issues -make all # lint + typecheck + test (ship bar) cd dashboard && npx tsc --noEmit # frontend typecheck (after UI changes) make build-frontend # dashboard/ → src/octop/dashboard/ ``` -**Git hooks (required for local commits):** after cloning, run **`make install-hooks`** once. That sets `core.hooksPath=.githooks` so every `git commit` runs `make all` plus `dashboard` `npm run build` before the commit is created. Bypass only in emergencies: `SKIP_PRECOMMIT=1 git commit …` or `git commit --no-verify`. Do **not** skip hooks to land red tests — fix the suite first (CI runs on Linux **and** Windows). +**Git hooks (required for local commits):** after cloning, run **`make install-hooks`** once. That sets `core.hooksPath=.githooks` so every `git commit` runs **`make all`** (which first runs **`format-all`**: backend Ruff + dashboard Prettier write, then lint / typecheck / test) and dashboard **`npm run build`**. Formatted files that were already staged are re-added so the commit includes the formatted content. Bypass only in emergencies: `SKIP_PRECOMMIT=1 git commit …` or `git commit --no-verify`. Do **not** skip hooks to land red tests — fix the suite first (CI runs on Linux **and** Windows). ## 7. Key patterns @@ -336,7 +338,7 @@ Boundary rules are in [§5](#5-module-boundaries). Additionally: | Internationalization (dashboard) | `dashboard/src/locales/`, `dashboard/src/i18n.ts`, `dashboard/src/utils/apiError.ts` | | Server timezone (config.json) | `default_timezone` in `config.py`; `GET /api/settings/timezone`; `dashboard/src/hooks/useServerTimezone.ts`; `dashboard/src/utils/formatMessageTime.ts` | | Test layout & shared helpers | `tests/support/` (`fakes`, `auth`, `http`, `scenarios`, `app`), `tests/integration/conftest.py`, `tests/unit/{db,cron,gateway,agents,api,cli}/` | -| Pre-commit hooks | `make install-hooks` → `.githooks/pre-commit` (`make all` + dashboard build) | +| Pre-commit hooks | `make install-hooks` → `.githooks/pre-commit` (`make all` incl. `format-all` + dashboard build) | | What is a Thread? | `infra/gateway/threads.py`, `infra/db/repos/threads.py` | | Workspace backend resolution | `infra/backend/resolver.py`, `infra/backend/adapter.py` | | Connectors & OAuth | `infra/connectors/`, `api/routers/connectors.py` | @@ -350,7 +352,7 @@ Boundary rules are in [§5](#5-module-boundaries). Additionally: 1. **Clarify scope** — read relevant code/docs; confirm assumptions and ambiguities with the user (see [§1](#1-collaboration-principles)). 2. **Hooks** — if this clone has not run `make install-hooks` yet, do it before committing (see [§6](#6-run-commands)). Pre-commit must stay green (`make all` + dashboard build). 3. **Minimal implementation** — change only task-related files; dashboard source is in `dashboard/`, build output in `src/octop/dashboard/` (run `make build-frontend` after UI changes). -4. **Verify** — backend: `make all` (`lint` + `typecheck` + `test`). After `dashboard/` changes, also run `cd dashboard && npx tsc --noEmit` (and `npm run lint` when appropriate). After API route changes, glance at `/api/docs` for readable summaries and schemas. After i18n JSON changes, run `uv run pytest tests/unit/i18n -q`. Treat Windows CI as part of the bar: follow [§7 Cross-platform tests](#7-key-patterns). +4. **Verify** — backend/ship bar: `make all` (`format-all` + `lint` + `typecheck` + `test`). After `dashboard/` changes, also run `cd dashboard && npx tsc --noEmit` (and `npm run lint` when appropriate). After API route changes, glance at `/api/docs` for readable summaries and schemas. After i18n JSON changes, run `uv run pytest tests/unit/i18n -q`. Treat Windows CI as part of the bar: follow [§7 Cross-platform tests](#7-key-patterns). 5. **Wrap up** — remove orphan symbols introduced in this change; do not commit or push unless asked. ### Branching & release diff --git a/CHANGELOG.md b/CHANGELOG.md index 358dba6c..82dd1788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ ## [Unreleased] +### 新增 +- 高级设置「更新」页提供按安装方式升级说明与一键检查升级双栏布局 +- 高级设置 HTTPS 页优化签发状态与预检结果展示 + +### 变更 +- `make all` 先执行前后端 `format-all`(Ruff + Prettier);pre-commit 在 format 后回写已暂存文件并构建 dashboard + ## [0.9.18] - 2026-08-02 ### 新增 @@ -35,6 +42,12 @@ - 备份/恢复纳入 `skill-packages/` 目录,恢复前清空避免残留 (#108) - 统一聊天生成中 / 滚动辅助逻辑;antd message 经 App.useApp 绑定,支持主题感知 toast (#119) +### 修复 +- Memory 原始事件列表的时间戳按服务器时区展示,与其余 Memory 页保持一致 (#110) + +### 修复 +- 记忆提取 / 提升等 harness 内部辅助 LLM 默认跟随全局偏好模型(此前切换全局模型后仍回退到首个可用模型)(#110) + ## [0.9.16] - 2026-07-29 ### 新增 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40766289..afc6ad32 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ git clone https://github.com/TencentCloud/Octop.git octop cd octop make install # backend dev dependencies make install-hooks # once per clone: pre-commit runs make all + dashboard build -make all # backend lint + typecheck + test (CI ship bar) +make all # format-all + backend lint + typecheck + test (ship bar) ``` For frontend work (separate terminal): @@ -29,7 +29,7 @@ make check-all # full stack quality gate |---------|-------------| | `make install` | Install Python dev dependencies | | `make install-hooks` | Point git at `.githooks` (pre-commit: `make all` + dashboard build) | -| `make all` | Backend lint + typecheck + test | +| `make all` | `format-all` + backend lint + typecheck + test | | `make check-all` | Full stack quality gate | | `make dev` | Start frontend + backend dev servers | | `make build` | Build dashboard + Python wheel | @@ -90,7 +90,7 @@ git clone https://github.com/TencentCloud/Octop.git octop cd octop make install make install-hooks # 每个 clone 执行一次:提交前跑 make all + 前端 build -make all # 后端质量门禁 +make all # format-all + 后端 lint / typecheck / test ``` 前端开发(另开终端): diff --git a/Makefile b/Makefile index f628bd68..6a72cdd4 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Makefile for Octop # Usage: # make - Show this help -# make all - Backend lint + typecheck + test (CI ship bar) +# make all - format (BE+FE) + backend lint + typecheck + test (ship bar) # make build - Build frontend + Python wheel # make publish - Build + upload to PyPI # @@ -56,8 +56,8 @@ help: @echo " test-online pytest against .venv-online (not live)" @echo " run-online Start octop run from .venv-online" @echo "" - @echo "Quality targets (backend — CI ship bar):" - @echo " all lint + typecheck + test (backend)" + @echo "Quality targets (ship bar):" + @echo " all format-all + lint + typecheck + test (backend lint/typecheck/test)" @echo " lint Ruff check + format check (src, tests)" @echo " format Ruff auto-fix + format (src, tests)" @echo " typecheck mypy --strict src/octop" @@ -71,12 +71,12 @@ help: @echo "" @echo "Quality targets (full stack):" @echo " lint-all lint + lint-frontend" - @echo " format-all format + format-frontend" + @echo " format-all format + format-frontend (also first step of make all)" @echo " typecheck-all typecheck + typecheck-frontend" @echo " check-all lint-all + typecheck-all + test" @echo "" @echo "Utility targets:" - @echo " install-hooks Point git to .githooks (pre-commit runs make all + npm run build)" + @echo " install-hooks Point git to .githooks (pre-commit: make all + dashboard build)" @echo " install Install Python dev dependencies (alias: install-dev)" @echo " install-dev uv sync / pip install -e \".[dev]\"" @echo " install-tools Install build + twine for publishing" @@ -196,7 +196,7 @@ run-online: # ─── Quality (backend) ─────────────────────────────────────────────────────── .PHONY: all -all: lint typecheck test +all: format-all lint typecheck test .PHONY: lint lint: @@ -266,7 +266,7 @@ install-hooks: @echo "[install-hooks] Setting core.hooksPath=.githooks" git config core.hooksPath .githooks @chmod +x "$(REPO_ROOT)/.githooks/"* 2>/dev/null || true - @echo "[install-hooks] Done. Pre-commit will run: make all && (cd dashboard && npm run build)" + @echo "[install-hooks] Done. Pre-commit will run: make all (incl. format-all), dashboard build" @echo "[install-hooks] Bypass: SKIP_PRECOMMIT=1 git commit … or git commit --no-verify" .PHONY: install install-dev diff --git a/README.md b/README.md index 5d7ad208..07432b92 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ tests/ unit/ + integration/ ```bash # Backend make install # pip install -e ".[dev]" -make all # lint + typecheck + test (ship bar) +make all # format-all + lint + typecheck + test (ship bar) # Frontend (separate terminal) make dev-frontend # Vite dev server on :5173 (override with VITE_DEV_PORT) @@ -468,7 +468,7 @@ For the customer WeCom support group, scan: WeCom customer group QR code

-> This QR code is valid until **2026-08-03** (UTC+8). Please ask the maintainer for an updated code after it expires. +> This QR code is valid until **2026-08-08** (UTC+8). Please ask the maintainer for an updated code after it expires. ## 📄 License diff --git a/README_CN.md b/README_CN.md index df99263c..5af540e6 100644 --- a/README_CN.md +++ b/README_CN.md @@ -424,7 +424,7 @@ tests/ unit/ + integration/ ```bash # 后端 make install # pip install -e ".[dev]" -make all # lint + typecheck + test(发布门槛) +make all # format-all + lint + typecheck + test(发布门槛) # 前端(另开终端) make dev-frontend # Vite 开发服务器 :5173 @@ -478,7 +478,7 @@ cd dashboard && npx tsc --noEmit 客户企业微信服务群二维码

-> 二维码有效期至 **2026-08-03**,过期后请联系管理员更新。 +> 二维码有效期至 **2026-08-08**,过期后请联系管理员更新。 ### 📄 许可证 diff --git a/dashboard/src/api/modules/connectors.ts b/dashboard/src/api/modules/connectors.ts index 18fb30a4..b03acd1e 100644 --- a/dashboard/src/api/modules/connectors.ts +++ b/dashboard/src/api/modules/connectors.ts @@ -226,10 +226,13 @@ export const connectorsApi = { cli_config_key?: string; domains?: string[]; }) => - request("/connectors/feishu-cli/user-auth/start", { - method: "POST", - body: JSON.stringify(body), - }), + request( + "/connectors/feishu-cli/user-auth/start", + { + method: "POST", + body: JSON.stringify(body), + }, + ), feishuUserAuthComplete: (body: { app_id: string; @@ -247,7 +250,9 @@ export const connectorsApi = { feishuUserAuthStartInstance: (instanceId: string) => request( - `/connector-instances/${encodeURIComponent(instanceId)}/feishu-user-auth/start`, + `/connector-instances/${encodeURIComponent( + instanceId, + )}/feishu-user-auth/start`, { method: "POST" }, ), @@ -256,7 +261,9 @@ export const connectorsApi = { body: { device_code: string; cli_config_key?: string }, ) => request( - `/connector-instances/${encodeURIComponent(instanceId)}/feishu-user-auth/complete`, + `/connector-instances/${encodeURIComponent( + instanceId, + )}/feishu-user-auth/complete`, { method: "POST", body: JSON.stringify(body), diff --git a/dashboard/src/api/modules/skillPackages.test.ts b/dashboard/src/api/modules/skillPackages.test.ts index 097cee38..9060e762 100644 --- a/dashboard/src/api/modules/skillPackages.test.ts +++ b/dashboard/src/api/modules/skillPackages.test.ts @@ -54,13 +54,17 @@ describe("skillPackagesApi", () => { method: "PATCH", body: JSON.stringify({ name: "renamed" }), }); - expect(request).toHaveBeenNthCalledWith(3, "/skill-packages/from-skillhub", { - method: "POST", - body: JSON.stringify({ - slug: "starter", - icon_name: "sparkles", - }), - }); + expect(request).toHaveBeenNthCalledWith( + 3, + "/skill-packages/from-skillhub", + { + method: "POST", + body: JSON.stringify({ + slug: "starter", + icon_name: "sparkles", + }), + }, + ); expect(request).toHaveBeenNthCalledWith(4, "/skill-packages/pkg-1/skills", { method: "POST", body: JSON.stringify({ diff --git a/dashboard/src/api/modules/skillPackages.ts b/dashboard/src/api/modules/skillPackages.ts index c95340c3..a71c5463 100644 --- a/dashboard/src/api/modules/skillPackages.ts +++ b/dashboard/src/api/modules/skillPackages.ts @@ -86,13 +86,10 @@ export const skillPackagesApi = { packageId: string, body: { bundle_url: string; version?: string; overwrite?: boolean }, ) => - request( - `/skill-packages/${packageId}/skills/import`, - { - method: "POST", - body: JSON.stringify(body), - }, - ), + request(`/skill-packages/${packageId}/skills/import`, { + method: "POST", + body: JSON.stringify(body), + }), hubSearch: (q: string, limit = 50) => request[]>( diff --git a/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx b/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx index 0938298b..efd06497 100644 --- a/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx +++ b/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Dropdown, Tooltip } from "antd"; import type { MenuProps } from "antd"; import { useTranslation } from "react-i18next"; @@ -48,13 +54,7 @@ const POPUP_Z = { */ function clearPopupPlacementStyles(panel: HTMLElement | null) { if (!panel) return; - for (const prop of [ - "left", - "top", - "right", - "bottom", - "transform", - ] as const) { + for (const prop of ["left", "top", "right", "bottom", "transform"] as const) { panel.style.removeProperty(prop); } } @@ -378,15 +378,15 @@ const ChatDockPanelShell: React.FC = ({ mode === "popup" && popupFullscreen ? { ...style } : mode === "popup" && popupPos - ? { - ...style, - left: popupPos.x, - top: popupPos.y, - right: "auto", - bottom: "auto", - transform: "none", - } - : style; + ? { + ...style, + left: popupPos.x, + top: popupPos.y, + right: "auto", + bottom: "auto", + transform: "none", + } + : style; const layoutMenuItems: MenuProps["items"] = useMemo( () => [ diff --git a/dashboard/src/components/RailEdgeControl.tsx b/dashboard/src/components/RailEdgeControl.tsx index 7bff72b5..03ffb542 100644 --- a/dashboard/src/components/RailEdgeControl.tsx +++ b/dashboard/src/components/RailEdgeControl.tsx @@ -31,7 +31,11 @@ function WideChevron({ dir }: { dir: "left" | "right" }) { aria-hidden > ({ location.pathname === basePath || location.pathname.startsWith(`${basePath}/`); const isBare = location.pathname === basePath; - const hasInvalidSegment = - underBase && !isBare && pathTab === null; + const hasInvalidSegment = underBase && !isBare && pathTab === null; const activeTab: T = pathTab ?? readSaved(); const [mounted, setMounted] = useState>>( @@ -118,10 +117,7 @@ export function usePathTabs({ [activeTab, basePath, isTab, location.search, location.hash, navigate], ); - const isMounted = useCallback( - (tab: T) => Boolean(mounted[tab]), - [mounted], - ); + const isMounted = useCallback((tab: T) => Boolean(mounted[tab]), [mounted]); return { activeTab, diff --git a/dashboard/src/layouts/PageShell.tsx b/dashboard/src/layouts/PageShell.tsx index 17bd5064..b6cebfee 100644 --- a/dashboard/src/layouts/PageShell.tsx +++ b/dashboard/src/layouts/PageShell.tsx @@ -207,11 +207,7 @@ type PageShellTabbedProps = TabbedShellProps & { }; /** Custom tab bar + scrollable body (desktop pins the bar). */ -function PageShellTabbed({ - tabBar, - children, - ...shell -}: PageShellTabbedProps) { +function PageShellTabbed({ tabBar, children, ...shell }: PageShellTabbedProps) { const isMobile = useIsMobile(); return ( diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 1fb3b4fb..cbb6f13a 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -1664,6 +1664,17 @@ "tls": { "title": "HTTPS (Let's Encrypt)", "subtitle": "Issue a free TLS certificate when this server listens on 0.0.0.0:80. After issuance, Octop runs HTTPS on 443 and keeps HTTP on 80 for ACME renewals.", + "setupTitle": "Certificate setup", + "setupDesc": "Enter a domain that points to this host's public IP. Run checks first, then issue or renew the certificate.", + "progressTitle": "Issuance progress", + "progressDesc": "Tracks preflight and Let's Encrypt progress. Check results appear below when available.", + "checksTitle": "Preflight results", + "statusDomain": "Domain", + "statusExpires": "Expires", + "statusTask": "Status", + "statusNone": "Not configured", + "stateIdle": "Idle", + "stateFailed": "Failed", "loadError": "Failed to load TLS status", "domainRequired": "Enter a domain name", "domainLabel": "Domain", @@ -1954,7 +1965,9 @@ }, "update": { "title": "Version & Updates", - "description": "Check for the latest release and upgrade Octop with one click.", + "description": "Check for the latest release and upgrade in place; use the guide on the right for other install methods.", + "panelTitle": "Version upgrade", + "panelDesc": "Check for a new release and install it when supported. Restart the service after a successful upgrade.", "currentVersion": "Current Version", "latestVersion": "Latest Version", "checkButton": "Check for Updates", @@ -1985,7 +1998,40 @@ "restartTimeout": "Restart timed out. Run manually: octop service restart", "restartFailed": "Restart request failed. Check the service and try again.", "releaseNotes": "Release Notes", - "updateModalTitle": "New Version Available" + "updateModalTitle": "New Version Available", + "guideTitle": "Update by install method", + "guideIntro": "If the one-click upgrade on the left is unavailable, pick the commands for your install method. Restart after upgrading; data stays in ~/.octop (or OCTOP_HOME).", + "guideDataNote": "Upgrades do not wipe local data: accounts, agents, chats and memory, skills, channels, and model settings live in the working directory. Reinstalling or updating the package keeps using that data.", + "guide": { + "ui": { + "title": "In-console upgrade", + "body": "Prefer Check for Updates and Upgrade Now on the left (package installs, not editable/source). In service mode you can Restart Service when done." + }, + "installer": { + "title": "One-line installer", + "body": "If you used the official install script, re-run the same install command to upgrade in place (it reuses the environment and data under ~/.octop)." + }, + "cli": { + "title": "CLI: octop update", + "body": "If the octop command is on your PATH (installer or managed venv), run:" + }, + "pip": { + "title": "pip install", + "body": "If you installed into your own Python environment with pip:" + }, + "source": { + "title": "From source", + "body": "For git / editable installs, in-page upgrade is disabled. Pull the latest code, build the dashboard, then reinstall:" + }, + "docker": { + "title": "Docker", + "body": "Rebuild and restart the image, and keep the same data volume (~/.octop or octop-data) so nothing is lost:" + }, + "restart": { + "title": "Restart after upgrade", + "body": "The new package only takes effect after the process restarts. Use the command that matches how you launched Octop:" + } + } } }, "agentConfig": { diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 04d1e5b1..35925b3f 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -490,7 +490,7 @@ "defaultModelLabel": "默认模型", "defaultModelPlaceholder": "选择一个模型", "skillPackagesLabel": "技能包", - "skillPackagesHint": "创建专家时挂载选中的技能包。目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录必须为 \/。", + "skillPackagesHint": "创建专家时挂载选中的技能包。目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录必须为 /。", "skillPackagesPlaceholder": "选择技能包", "iconLabels": { "sparkles": "闪光", @@ -987,7 +987,7 @@ "packagesLoadFailed": "加载技能包失败", "packagesUpdateFailed": "更新已挂载技能包失败", "packageConflictHint": "工作区技能“{{slug}}”会覆盖此技能包中的同名技能。", - "mountBackendHint": "挂载目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录必须为 \/,其他后端暂不支持。", + "mountBackendHint": "挂载目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录必须为 /,其他后端暂不支持。", "installedSkillsDesc": "管理已安装的内置和自定义技能。", "tencentSkillHubDesc": "浏览和安装来自腾讯 SkillHub 的社区技能。", "install": "安装", @@ -1660,6 +1660,17 @@ "tls": { "title": "HTTPS(Let's Encrypt)", "subtitle": "当服务监听 0.0.0.0:80 且域名已指向公网时,可申请免费 TLS 证书。签发后 HTTPS 走 443,HTTP 80 保留用于 ACME 续期。", + "setupTitle": "证书配置", + "setupDesc": "填写已解析到本机公网 IP 的域名,先运行检查,通过后再申请或续期证书。", + "progressTitle": "签发进度", + "progressDesc": "展示预检与 Let's Encrypt 签发流程进度;检查结果会显示在下方。", + "checksTitle": "预检结果", + "statusDomain": "域名", + "statusExpires": "证书到期", + "statusTask": "当前状态", + "statusNone": "未配置", + "stateIdle": "空闲", + "stateFailed": "失败", "loadError": "加载 TLS 状态失败", "domainRequired": "请输入域名", "domainLabel": "域名", @@ -1949,7 +1960,9 @@ }, "update": { "title": "版本与更新", - "description": "检查最新版本,一键升级 Octop。", + "description": "检查最新版本,一键升级 Octop;右侧可按安装方式查看完整升级说明。", + "panelTitle": "版本升级", + "panelDesc": "检查是否有新版本,并在支持时一键安装。更新完成后请重启服务。", "currentVersion": "当前版本", "latestVersion": "最新版本", "checkButton": "检查更新", @@ -1980,7 +1993,40 @@ "restartTimeout": "重启超时,请手动执行:octop service restart", "restartFailed": "重启请求失败,请检查服务状态后重试", "releaseNotes": "更新说明", - "updateModalTitle": "发现新版本" + "updateModalTitle": "发现新版本", + "guideTitle": "按安装方式更新", + "guideIntro": "若不能使用左侧一键升级,请按你的安装方式选择下方命令。升级后请重启服务;配置与数据默认保留在 ~/.octop(或 OCTOP_HOME)。", + "guideDataNote": "升级不会清空本机数据:用户账号、Agent、对话与记忆、技能、通道与模型配置等均保存在工作目录中,重新安装或更新软件本身即可继续使用。", + "guide": { + "ui": { + "title": "控制台一键升级", + "body": "优先使用左侧「检查更新」与「立即升级」(非源码/可编辑安装)。服务模式可在完成后点「重启服务」。" + }, + "installer": { + "title": "一键安装脚本", + "body": "若你是用官方安装脚本装的,重新执行同一安装命令即可自动升级到最新版(会复用 ~/.octop 下的环境与数据)。" + }, + "cli": { + "title": "CLI:octop update", + "body": "若命令行可直接使用 octop(安装脚本或 venv 布局),在终端执行:" + }, + "pip": { + "title": "pip 安装", + "body": "若你是用 pip 装到自己的 Python 环境,可升级包本身:" + }, + "source": { + "title": "源码安装", + "body": "从 Git 源码开发/editable 安装时,本页一键升级不可用。请拉取最新代码、构建控制台前端后重新安装到当前环境:" + }, + "docker": { + "title": "Docker", + "body": "Docker 部署请重新构建并启动镜像,并继续挂载原有数据卷(~/.octop 或 octop-data),避免丢数据:" + }, + "restart": { + "title": "升级后重启", + "body": "包更新完成后需要重启进程才生效。任选与你的启动方式匹配的命令:" + } + } } }, "agentConfig": { diff --git a/dashboard/src/pages/Admin/Plugins/InstalledPluginsPanel.tsx b/dashboard/src/pages/Admin/Plugins/InstalledPluginsPanel.tsx index b3bc3a45..7704b873 100644 --- a/dashboard/src/pages/Admin/Plugins/InstalledPluginsPanel.tsx +++ b/dashboard/src/pages/Admin/Plugins/InstalledPluginsPanel.tsx @@ -231,11 +231,7 @@ export function InstalledPluginsPanel() { okText={t("plugins.install")} > - + } placeholder={t("plugins.installUrlPlaceholder")} diff --git a/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx b/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx index 8d3ef3c0..71858522 100644 --- a/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx +++ b/dashboard/src/pages/Admin/Storage/StorageBackendModal.tsx @@ -292,7 +292,10 @@ export function StorageBackendDrawer({ layout="vertical" onValuesChange={(_, all) => { if (!restoringDraftRef.current) { - saveFormDraft(draftScope, all as unknown as Record); + saveFormDraft( + draftScope, + all as unknown as Record, + ); } }} > diff --git a/dashboard/src/pages/Admin/Users/index.module.less b/dashboard/src/pages/Admin/Users/index.module.less index c05ad56b..ae92953e 100644 --- a/dashboard/src/pages/Admin/Users/index.module.less +++ b/dashboard/src/pages/Admin/Users/index.module.less @@ -89,11 +89,19 @@ .roleOptionSelected { box-shadow: 0 0 0 1px var(--role-accent); border-color: var(--role-accent); - background: color-mix(in srgb, var(--role-accent) 10%, var(--fn-bg-elevated, var(--fn-bg-primary))); + background: color-mix( + in srgb, + var(--role-accent) 10%, + var(--fn-bg-elevated, var(--fn-bg-primary)) + ); &:hover { border-color: var(--role-accent); - background: color-mix(in srgb, var(--role-accent) 14%, var(--fn-bg-elevated, var(--fn-bg-primary))); + background: color-mix( + in srgb, + var(--role-accent) 14%, + var(--fn-bg-elevated, var(--fn-bg-primary)) + ); } } @@ -156,7 +164,12 @@ row-gap: 4px; padding: 9px 12px 9px 11px; border: 1px solid var(--fn-border-secondary); - border-left: 3px solid color-mix(in srgb, var(--fn-color-brand, #e85d75) 55%, var(--fn-border-primary)); + border-left: 3px solid + color-mix( + in srgb, + var(--fn-color-brand, #e85d75) 55%, + var(--fn-border-primary) + ); border-radius: var(--fn-radius-md, 8px); background: var(--fn-bg-secondary, var(--fn-bg-elevated)); box-sizing: border-box; diff --git a/dashboard/src/pages/Admin/Users/index.tsx b/dashboard/src/pages/Admin/Users/index.tsx index ab652d37..f6313926 100644 --- a/dashboard/src/pages/Admin/Users/index.tsx +++ b/dashboard/src/pages/Admin/Users/index.tsx @@ -1054,9 +1054,7 @@ export default function AdminUsersPage() { } diff --git a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx index 76d36616..9636a0b9 100644 --- a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx +++ b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx @@ -199,8 +199,8 @@ export function ChannelDrawer({ const draftScope = editing ? `channel:${editing.id}` : selectedKind - ? `channel:new:${selectedKind}` - : ""; + ? `channel:new:${selectedKind}` + : ""; const restoringDraftRef = useRef(false); const stopPolling = useCallback(() => { @@ -1155,7 +1155,10 @@ export function ChannelDrawer({ onValuesChange={(changed, all) => { if (changed.kind) setSelectedKind(changed.kind as ChannelKey); if (!restoringDraftRef.current && draftScope) { - saveFormDraft(draftScope, all as unknown as Record); + saveFormDraft( + draftScope, + all as unknown as Record, + ); } }} > diff --git a/dashboard/src/pages/Agent/Connectors/index.module.less b/dashboard/src/pages/Agent/Connectors/index.module.less index 29757eca..4bfb5a5c 100644 --- a/dashboard/src/pages/Agent/Connectors/index.module.less +++ b/dashboard/src/pages/Agent/Connectors/index.module.less @@ -514,8 +514,13 @@ .cliInstallHintError { color: var(--fn-text-secondary); - background: color-mix(in srgb, var(--fn-danger, #c0392b) 8%, var(--fn-surface-sunken)); - border: 1px solid color-mix(in srgb, var(--fn-danger, #c0392b) 25%, transparent); + background: color-mix( + in srgb, + var(--fn-danger, #c0392b) 8%, + var(--fn-surface-sunken) + ); + border: 1px solid + color-mix(in srgb, var(--fn-danger, #c0392b) 25%, transparent); } .cliInstallCommand { diff --git a/dashboard/src/pages/Agent/Connectors/index.tsx b/dashboard/src/pages/Agent/Connectors/index.tsx index c3ee6690..9ea136d2 100644 --- a/dashboard/src/pages/Agent/Connectors/index.tsx +++ b/dashboard/src/pages/Agent/Connectors/index.tsx @@ -365,7 +365,7 @@ function ConnectorConfigDrawer({ try { popup.document.title = "Feishu Auth"; popup.document.body.innerHTML = - "

Loading…

"; + '

Loading…

'; } catch { // Cross-origin / closed — ignore. } @@ -458,8 +458,7 @@ function ConnectorConfigDrawer({ ); } else { message.error( - result.error ?? - t("connectors.cliInstallFailed", "主机 CLI 安装失败"), + result.error ?? t("connectors.cliInstallFailed", "主机 CLI 安装失败"), ); } } catch (e) { @@ -515,7 +514,8 @@ function ConnectorConfigDrawer({ started = await connectorsApi.feishuUserAuthStart({ app_id, app_secret, - cli_config_key: String(values.cli_config_key ?? "").trim() || undefined, + cli_config_key: + String(values.cli_config_key ?? "").trim() || undefined, }); } form.setFieldsValue({ cli_config_key: started.cli_config_key }); @@ -532,11 +532,7 @@ function ConnectorConfigDrawer({ popup?.close(); console.error(e); message.error( - apiErrorMessage( - e, - t("connectors.feishuUserAuthFailed", "授权失败"), - t, - ), + apiErrorMessage(e, t("connectors.feishuUserAuthFailed", "授权失败"), t), ); } finally { setFeishuUserAuthBusy(false); @@ -586,12 +582,11 @@ function ConnectorConfigDrawer({ setFeishuUserReady(true); setFeishuAuthNeedsReauth(false); setFeishuUserAuth(null); - const persisted = - Boolean(instance?.instance_id) && hasStoredCredentials; + const persisted = Boolean(instance?.instance_id) && hasStoredCredentials; if (done.warning || done.search_docs_scope === false) { message.warning( - done.warning - || t( + done.warning || + t( "connectors.feishuUserAuthWarning", "已登录,但文档搜索权限可能未开通,请检查开放平台权限后重新授权", ), @@ -600,20 +595,13 @@ function ConnectorConfigDrawer({ message.success( persisted ? t("connectors.feishuUserAuthSuccessSaved", "授权完成") - : t( - "connectors.feishuUserAuthSuccess", - "授权完成,请保存连接器", - ), + : t("connectors.feishuUserAuthSuccess", "授权完成,请保存连接器"), ); } } catch (e) { console.error(e); message.error( - apiErrorMessage( - e, - t("connectors.feishuUserAuthFailed", "授权失败"), - t, - ), + apiErrorMessage(e, t("connectors.feishuUserAuthFailed", "授权失败"), t), ); } finally { setFeishuUserAuthBusy(false); @@ -732,11 +720,13 @@ function ConnectorConfigDrawer({ const feishuAppIdChanged = entry.kind === "feishu-cli" && Boolean(preview.app_id) && - String(values.app_id ?? "").trim() !== String(preview.app_id ?? "").trim(); + String(values.app_id ?? "").trim() !== + String(preview.app_id ?? "").trim(); const wecomBotIdChanged = entry.kind === "wecom-cli" && Boolean(preview.bot_id) && - String(values.bot_id ?? "").trim() !== String(preview.bot_id ?? "").trim(); + String(values.bot_id ?? "").trim() !== + String(preview.bot_id ?? "").trim(); const identityChanged = feishuAppIdChanged || wecomBotIdChanged; if (identityChanged && !freshSecret) { message.warning( @@ -941,7 +931,11 @@ function ConnectorConfigDrawer({ )} {!isAdmin && cliInfo?.installed && ( - )} @@ -1104,7 +1098,10 @@ function ConnectorConfigDrawer({ layout="vertical" onValuesChange={(_, all) => { if (!restoringDraftRef.current && draftScope) { - saveFormDraft(draftScope, all as unknown as Record); + saveFormDraft( + draftScope, + all as unknown as Record, + ); } }} > @@ -1237,19 +1234,19 @@ function ConnectorConfigDrawer({ "用户授权已失效,请重新登录授权。", ) : feishuUserReady - ? t( - "connectors.feishuUserAuthReady", - "已授权,可搜索文档。", - ) - : feishuUserAuth - ? t( - "connectors.feishuUserAuthPendingHint", - "请在弹出的页面完成授权,然后点「我已授权」。", - ) - : t( - "connectors.feishuUserAuthHint", - "点击登录授权,完成后点「我已授权」。", - )} + ? t( + "connectors.feishuUserAuthReady", + "已授权,可搜索文档。", + ) + : feishuUserAuth + ? t( + "connectors.feishuUserAuthPendingHint", + "请在弹出的页面完成授权,然后点「我已授权」。", + ) + : t( + "connectors.feishuUserAuthHint", + "点击登录授权,完成后点「我已授权」。", + )} {feishuUserReady && feishuRefreshExpiresAt && (
@@ -1262,7 +1259,11 @@ function ConnectorConfigDrawer({ )}
)}
@@ -1343,66 +1341,66 @@ function ConnectorConfigDrawer({ {entry.auth_kind === "api_key" && entry.kind !== "feishu-cli" && entry.kind !== "wecom-cli" && ( - <> - - {t("connectors.apiKeyDoc", "查看如何获取 API Key")} - - ) : undefined) - } - > - - - {entry.kind === "tencent-ima" && ( + <> + {t("connectors.apiKeyDoc", "查看如何获取 API Key")} + + ) : undefined) + } > - - )} - {entry.kind === "tencent-lexiang" && ( - - + + + )} + {entry.kind === "tencent-lexiang" && ( + - - )} - - )} + rules={[{ required: true }]} + > + + + )} + + )} {entry.auth_kind === "oauth2" && ( <> diff --git a/dashboard/src/pages/Agent/Memory/MemoryPanel.tsx b/dashboard/src/pages/Agent/Memory/MemoryPanel.tsx index 77aae029..ab2ab742 100644 --- a/dashboard/src/pages/Agent/Memory/MemoryPanel.tsx +++ b/dashboard/src/pages/Agent/Memory/MemoryPanel.tsx @@ -133,14 +133,14 @@ export default function MemoryPanel({ "按人、项目、工具等主题,分组浏览相关记忆", ) : libraryView === "atoms" - ? t( - "memory.library.hintAtoms", - "扁平展示全部记忆,可按重要程度筛选", - ) - : t( - "memory.library.hintRaw", - "提炼前捕获的原始对话记忆(条数与「对话记录」不一一对应)", - )} + ? t( + "memory.library.hintAtoms", + "扁平展示全部记忆,可按重要程度筛选", + ) + : t( + "memory.library.hintRaw", + "提炼前捕获的原始对话记忆(条数与「对话记录」不一一对应)", + )}
{libraryView === "tree" ? ( diff --git a/dashboard/src/pages/Agent/Memory/Overview.tsx b/dashboard/src/pages/Agent/Memory/Overview.tsx index 30e52efe..b2e07d96 100644 --- a/dashboard/src/pages/Agent/Memory/Overview.tsx +++ b/dashboard/src/pages/Agent/Memory/Overview.tsx @@ -215,10 +215,7 @@ export default function Overview({ ) : !state.growth || state.growth.series.length === 0 ? ( ) : (
diff --git a/dashboard/src/pages/Agent/Memory/RawEventsList.test.tsx b/dashboard/src/pages/Agent/Memory/RawEventsList.test.tsx index 534b356e..34b8df3a 100644 --- a/dashboard/src/pages/Agent/Memory/RawEventsList.test.tsx +++ b/dashboard/src/pages/Agent/Memory/RawEventsList.test.tsx @@ -19,6 +19,12 @@ vi.mock("../../../api/modules/memoryDashboard", () => ({ }, })); +vi.mock("../../../api/modules/settings", () => ({ + octopSettingsApi: { + timezone: vi.fn().mockResolvedValue({ timezone: "Asia/Shanghai" }), + }, +})); + import { memoryDashboardApi } from "../../../api/modules/memoryDashboard"; import RawEventsList from "./RawEventsList"; @@ -113,4 +119,32 @@ describe("", () => { // MemoryPipelineEmpty consults statsCounts to decide its copy. await waitFor(() => expect(api.statsCounts).toHaveBeenCalledWith("ZYWZTD")); }); + + it("renders timestamps in the server timezone (Asia/Shanghai)", async () => { + api.listRawEvents.mockResolvedValue( + rawResp([makeRaw({ timestamp: "2026-07-15T02:00:00Z" })]), + ); + + render(); + await waitFor(() => { + expect(screen.getByText("项目截止日期是周五")).toBeInTheDocument(); + }); + // 2026-07-15T02:00:00Z == 10:00 in Asia/Shanghai (UTC+8). + await waitFor(() => { + expect(screen.getByText(/2026/)).toBeInTheDocument(); + }); + const timeText = screen.getByText(/2026/).textContent ?? ""; + expect(timeText).toContain("10:"); + }); + + it("falls back to the raw ISO string for an invalid timestamp", async () => { + api.listRawEvents.mockResolvedValue( + rawResp([makeRaw({ timestamp: "not-a-date" })]), + ); + + render(); + await waitFor(() => { + expect(screen.getByText("not-a-date")).toBeInTheDocument(); + }); + }); }); diff --git a/dashboard/src/pages/Agent/Memory/RawEventsList.tsx b/dashboard/src/pages/Agent/Memory/RawEventsList.tsx index b7659bf2..18411a9d 100644 --- a/dashboard/src/pages/Agent/Memory/RawEventsList.tsx +++ b/dashboard/src/pages/Agent/Memory/RawEventsList.tsx @@ -20,6 +20,8 @@ import { } from "../../../api/modules/memoryDashboard"; import MemoryLayerView from "./shared/MemoryLayerView"; import MemoryPipelineEmpty from "./shared/MemoryPipelineEmpty"; +import { useServerTimezone } from "../../../hooks/useServerTimezone"; +import { formatServerIsoDateTime } from "../../../utils/formatMessageTime"; const PAGE_SIZE = 20; @@ -66,14 +68,9 @@ function eventTypeColor(type: string): string | undefined { return undefined; } -function formatTime(iso: string): string { - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return iso; - return d.toLocaleString(); -} - export default function RawEventsList({ agentId }: Props) { const { t } = useTranslation(); + const timeZone = useServerTimezone(); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -174,7 +171,7 @@ export default function RawEventsList({ agentId }: Props) { {e.content}
- {formatTime(e.timestamp)} + {formatServerIsoDateTime(e.timestamp, timeZone)}
)} @@ -197,7 +194,8 @@ export default function RawEventsList({ agentId }: Props) { type="secondary" style={{ fontSize: 12, marginTop: 16 }} > - {t("memory.raw.capturedAt", "捕获于")} {formatTime(e.timestamp)} + {t("memory.raw.capturedAt", "捕获于")}{" "} + {formatServerIsoDateTime(e.timestamp, timeZone)} )} diff --git a/dashboard/src/pages/Agent/Memory/VectorSearchConfig.tsx b/dashboard/src/pages/Agent/Memory/VectorSearchConfig.tsx index 38ead1be..037e591a 100644 --- a/dashboard/src/pages/Agent/Memory/VectorSearchConfig.tsx +++ b/dashboard/src/pages/Agent/Memory/VectorSearchConfig.tsx @@ -1,14 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { - Select, - Input, - Button, - Spin, - Progress, - Modal, - Alert, -} from "antd"; +import { Select, Input, Button, Spin, Progress, Modal, Alert } from "antd"; import { message } from "@/utils/antdMessage"; import { Download, CheckCircle, AlertCircle } from "lucide-react"; diff --git a/dashboard/src/pages/Agent/Personalization/components/MBTISelector.tsx b/dashboard/src/pages/Agent/Personalization/components/MBTISelector.tsx index c55135e3..b63cb55f 100644 --- a/dashboard/src/pages/Agent/Personalization/components/MBTISelector.tsx +++ b/dashboard/src/pages/Agent/Personalization/components/MBTISelector.tsx @@ -408,8 +408,7 @@ export default function MBTISelector({ {t("personalization.mbti.listSummary", { total: types.length, selected: currentLabel, - defaultValue: - "当前有({{total}})个人格,已选中「{{selected}}」", + defaultValue: "当前有({{total}})个人格,已选中「{{selected}}」", })} {!showHeader && showTestAction && ( diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.module.less b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.module.less index 086b30a9..a1f8a9b9 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.module.less +++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.module.less @@ -30,7 +30,6 @@ gap: 16px; } - .createFields { min-width: 0; flex-shrink: 0; diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx index 86468924..a5c997dc 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx @@ -280,8 +280,8 @@ export function SkillDrawer({ const drawerTitle = isCreate ? t("skills.createSkill") : localEditMode - ? t("skills.editSkill") - : t("skills.viewSkill"); + ? t("skills.editSkill") + : t("skills.viewSkill"); const nameField = ( {isCreate || isEdit ? ( diff --git a/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx b/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx index 7bae893d..30757cde 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx @@ -62,10 +62,7 @@ function normalizeHubSkill(raw: Record): SkillHubSkill { }; } -export default function SkillHubTab({ - target, - onInstalled, -}: SkillHubTabProps) { +export default function SkillHubTab({ target, onInstalled }: SkillHubTabProps) { const { t } = useTranslation(); const [hubSkills, setHubSkills] = useState([]); const [rankings, setRankings] = useState>( @@ -108,7 +105,6 @@ export default function SkillHubTab({ return null; }, [target?.type, agentId, packageId]); - const fetchRankings = useCallback( async (force = false) => { if (!force) { diff --git a/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx b/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx index 1aae7124..28fb2265 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx @@ -145,11 +145,7 @@ export function SkillImportModal({ keyboard={!busy} footer={
- - - - )} - {panelFilePaths.length > 0 && ( - - - - {panelFilePaths.length > 1 && ( - - {panelFilePaths.length > 99 - ? "99+" - : panelFilePaths.length} - - )} - - - )} +
+ {resolvedAgentId && ( -
- )} + )} + {panelFilePaths.length > 0 && ( + + + + {panelFilePaths.length > 1 && ( + + {panelFilePaths.length > 99 + ? "99+" + : panelFilePaths.length} + + )} + + + )} + + + + + +
+ )} { "main", ), ).toBe("generated/iron-man.pptx"); - expect( - canonicalizeDockFilePath("generated/iron-man.pptx", "main"), - ).toBe("generated/iron-man.pptx"); + expect(canonicalizeDockFilePath("generated/iron-man.pptx", "main")).toBe( + "generated/iron-man.pptx", + ); }); it("canonicalizes Windows agent-home paths", () => { @@ -105,10 +105,10 @@ describe("dockFilePath", () => { "main", ); expect(tree).toHaveLength(1); - expect(tree[0].name).toBe("home / wally / .octop / agents / main / generated"); - expect(tree[0].path).toBe( - "/home/wally/.octop/agents/main/generated", + expect(tree[0].name).toBe( + "home / wally / .octop / agents / main / generated", ); + expect(tree[0].path).toBe("/home/wally/.octop/agents/main/generated"); expect(tree[0].children.map((c) => c.name)).toEqual(["iron-man.pptx"]); }); @@ -195,11 +195,7 @@ describe("dockFilePath", () => { const collapsed = new Set(); // user collapsed a/b const second = buildDockPathTree(["a/b/one.txt", "c/d/two.txt"]); const folders2 = collectDockFolderPaths(second); - const merged = mergeDockExpandedFolders( - collapsed, - folders2, - initial.seen, - ); + const merged = mergeDockExpandedFolders(collapsed, folders2, initial.seen); expect(merged.expanded.has("a/b")).toBe(false); expect(merged.expanded.has("c/d")).toBe(true); }); diff --git a/dashboard/src/pages/Chat/utils/dockFilePath.ts b/dashboard/src/pages/Chat/utils/dockFilePath.ts index 616ac89c..4516b0f7 100644 --- a/dashboard/src/pages/Chat/utils/dockFilePath.ts +++ b/dashboard/src/pages/Chat/utils/dockFilePath.ts @@ -44,10 +44,7 @@ export function canonicalizeDockFilePath( if (agentId) { const id = agentId.replace(/\\/g, "/"); const idLower = id.toLowerCase(); - const markers = [ - `/.octop/agents/${idLower}/`, - `.octop/agents/${idLower}/`, - ]; + const markers = [`/.octop/agents/${idLower}/`, `.octop/agents/${idLower}/`]; for (const marker of markers) { const idx = lower.lastIndexOf(marker); if (idx >= 0) { @@ -99,10 +96,7 @@ export function dockFileBasename(path: string): string { } /** Stable tab id for an open file path. */ -export function dockFileTabId( - path: string, - agentId?: string | null, -): string { +export function dockFileTabId(path: string, agentId?: string | null): string { return `file:${canonicalizeDockFilePath(path, agentId)}`; } @@ -213,7 +207,8 @@ export function buildDockPathTree( for (let i = 0; i < parts.length; i++) { const part = parts[i]; const isLast = i === parts.length - 1; - acc = acc === "" && abs ? `/${part}` : acc === "" ? part : `${acc}/${part}`; + acc = + acc === "" && abs ? `/${part}` : acc === "" ? part : `${acc}/${part}`; let child = node.children.get(part); if (!child) { child = { @@ -241,12 +236,10 @@ export function buildDockPathTree( cur = [...cur.children.values()][0]; names.push(cur.name); } - const children = [...cur.children.values()] - .map(collapse) - .sort((a, b) => { - if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; - return a.name.localeCompare(b.name); - }); + const children = [...cur.children.values()].map(collapse).sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); return { name: names.filter(Boolean).join(" / "), path: cur.path, @@ -255,18 +248,14 @@ export function buildDockPathTree( }; } - return [...root.children.values()] - .map(collapse) - .sort((a, b) => { - if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; - return a.name.localeCompare(b.name); - }); + return [...root.children.values()].map(collapse).sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); } /** Collect every directory node path from a dock tree. */ -export function collectDockFolderPaths( - nodes: DockPathTreeNode[], -): Set { +export function collectDockFolderPaths(nodes: DockPathTreeNode[]): Set { const out = new Set(); const walk = (list: DockPathTreeNode[]) => { for (const n of list) { diff --git a/dashboard/src/pages/Control/RemoteDesktop/index.tsx b/dashboard/src/pages/Control/RemoteDesktop/index.tsx index 27a6bfd9..6ce7b18f 100644 --- a/dashboard/src/pages/Control/RemoteDesktop/index.tsx +++ b/dashboard/src/pages/Control/RemoteDesktop/index.tsx @@ -959,7 +959,9 @@ export default function RemoteDesktopPage() { "pageShell.desktop.subtitle", "查看并操控 Octop 主机操作系统桌面", ); - const setupMascot = ; + const setupMascot = ( + + ); if (role === null) { return ( diff --git a/dashboard/src/pages/Control/TokenUsage/index.tsx b/dashboard/src/pages/Control/TokenUsage/index.tsx index fcd470f7..d14b64ba 100644 --- a/dashboard/src/pages/Control/TokenUsage/index.tsx +++ b/dashboard/src/pages/Control/TokenUsage/index.tsx @@ -93,7 +93,12 @@ const AXIS_TICK = { fontSize: 11, fill: "var(--fn-text-tertiary)" } as const; const AXIS_LINE = { stroke: "var(--fn-border-primary)" } as const; /** Plot insets: leave room for X ticks (bottom) and optional top legend. */ const BAR_MARGIN = { top: 12, right: 16, bottom: 8, left: 4 } as const; -const BAR_MARGIN_WITH_LEGEND = { top: 32, right: 16, bottom: 8, left: 4 } as const; +const BAR_MARGIN_WITH_LEGEND = { + top: 32, + right: 16, + bottom: 8, + left: 4, +} as const; const CHART_HEIGHT = { bar: 280, diff --git a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx index 477b688d..6806f9b7 100644 --- a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx +++ b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx @@ -1,15 +1,7 @@ // dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Alert, - Collapse, - Drawer, - Form, - Input, - Select, - Spin, -} from "antd"; +import { Alert, Collapse, Drawer, Form, Input, Select, Spin } from "antd"; import { message } from "@/utils/antdMessage"; import { request } from "../../../api/request"; diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less index ede7fe1a..50100aec 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less +++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.module.less @@ -1,7 +1,58 @@ .container { width: 100%; min-width: 0; - max-width: 720px; +} + +/* ── Page layout: upgrade left, guide right ── */ + +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); + gap: 20px; + align-items: start; + + @media (max-width: 960px) { + grid-template-columns: 1fr; + } +} + +.panel { + min-width: 0; + border: 1px solid var(--fn-border-primary); + border-radius: 12px; + background: var( + --fn-bg-elevated, + var(--fn-bg-secondary, rgba(0, 0, 0, 0.02)) + ); + padding: 18px 20px; +} + +.panelTitleRow { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.panelTitle { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--fn-text-primary); + line-height: 1.35; +} + +.panelTitleIcon { + display: flex; + color: var(--fn-text-secondary); + flex-shrink: 0; +} + +.panelDesc { + margin: 0 0 16px; + font-size: 13px; + line-height: 1.55; + color: var(--fn-text-secondary); } /* ── Version cards ── */ @@ -10,9 +61,9 @@ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; - margin-bottom: 20px; + margin-bottom: 16px; - @media (max-width: 600px) { + @media (max-width: 520px) { grid-template-columns: 1fr; } } @@ -21,25 +72,26 @@ display: flex; flex-direction: column; gap: 6px; - padding: 16px; + padding: 14px 16px; border: 1px solid var(--fn-border-primary); - border-radius: 8px; - background: var(--fn-bg-secondary, rgba(0, 0, 0, 0.03)); + border-radius: 10px; + background: var(--fn-bg-primary, var(--fn-bg-base, #fff)); } .versionLabel { font-size: 12px; color: var(--fn-text-tertiary); - text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.02em; font-weight: 500; } .versionValue { - font-size: 18px; + font-size: 20px; font-weight: 700; - letter-spacing: -0.01em; + letter-spacing: -0.02em; font-variant-numeric: tabular-nums; + color: var(--fn-text-primary); + line-height: 1.25; } .notChecked { @@ -75,6 +127,10 @@ flex-shrink: 0; margin-top: 2px; } + + p { + margin: 0; + } } .alertWarn { @@ -119,17 +175,17 @@ display: flex; gap: 10px; flex-wrap: wrap; - margin-bottom: 16px; + margin-bottom: 4px; } .btnSecondary { display: inline-flex; align-items: center; gap: 6px; - padding: 7px 14px; + padding: 8px 14px; border: 1px solid var(--fn-border-primary); - border-radius: 6px; - background: transparent; + border-radius: 8px; + background: var(--fn-bg-primary, transparent); color: var(--fn-text-primary); font-size: 13px; font-weight: 500; @@ -151,9 +207,9 @@ display: inline-flex; align-items: center; gap: 6px; - padding: 7px 16px; + padding: 8px 16px; border: none; - border-radius: 6px; + border-radius: 8px; background: var(--fn-color-brand); color: #fff; font-size: 13px; @@ -174,7 +230,7 @@ /* ── Progress bar ── */ .progressSection { - margin-top: 4px; + margin-top: 14px; } .progressLabel { @@ -213,8 +269,6 @@ margin: 0; } - /* Keep the restart button at content width so it does not stretch - and leave empty space on the right side of the label. */ button { align-self: flex-start; } @@ -253,6 +307,84 @@ code { opacity: 0.75; } +.releaseNotes { + margin-top: 14px; + + :global(.ant-collapse-header) { + align-items: center !important; + } +} + +/* ── Upgrade guide (right column) ── */ + +.guide { + display: flex; + flex-direction: column; + min-width: 0; +} + +.guideMethods { + display: flex; + flex-direction: column; + gap: 12px; +} + +.guideNote { + margin: 0 0 14px; + padding: 10px 12px; + border-radius: 8px; + font-size: 12.5px; + line-height: 1.55; + color: var(--fn-text-secondary); + background: color-mix(in srgb, var(--fn-color-brand) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--fn-color-brand) 20%, transparent); +} + +.guideMethod { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--fn-border-primary); + background: var(--fn-bg-primary, var(--fn-bg-base, #fff)); +} + +.guideMethodTitle { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.guideMethodBody { + margin: 0; + font-size: 12.5px; + line-height: 1.55; + color: var(--fn-text-secondary); +} + +.guideCode { + margin: 2px 0 0; + padding: 10px 12px; + border-radius: 8px; + background: color-mix(in srgb, var(--fn-text-primary) 4%, transparent); + border: 1px solid var(--fn-border-secondary, var(--fn-border-primary)); + font-family: var( + --fn-font-mono, + ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace + ); + font-size: 11.5px; + line-height: 1.55; + color: var(--fn-text-primary); + white-space: pre-wrap; + overflow-x: auto; +} + /* ── Spinner animation ── */ @keyframes spin { diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx index d807479f..2b8b7bb2 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx @@ -2,11 +2,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Collapse } from "antd"; import { + BookOpen, CheckCircle, RefreshCw, XCircle, AlertTriangle, Power, + Sparkles, } from "lucide-react"; import { updateApi, @@ -22,6 +24,126 @@ import { import { TabPanelHeader } from "./TabPanelHeader"; import styles from "./UpdateConfig.module.less"; +/** Shell snippets shown in the manual upgrade guide (commands are locale-agnostic). */ +const UPGRADE_GUIDE_CODE = { + installerUnix: `curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash`, + installerWin: `irm https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.ps1 | iex`, + cli: `octop update +# or non-interactive: +octop update --yes`, + pip: `pip install -U octop +# optional extras, e.g. browser automation: +# pip install -U "octop[browser]"`, + source: `cd Octop +git pull +make build-frontend +# or: cd dashboard && npm ci && npm run build && cd .. +pip install -e . +# with dev deps: make install / pip install -e ".[dev]"`, + docker: `# Compose (from repo root; rebuild + recreate) +docker compose -f docker/docker-compose.yml up -d --build + +# or rebuild the image then run with a persistent data volume +bash docker/docker_build.sh +docker run -d \\ + --name octop \\ + -p 8088:8088 \\ + -v octop-data:/data/.octop \\ + -e HOME=/data \\ + octop:latest`, + restart: `# system service (systemd / launchd / Windows service) +octop service restart + +# foreground process — stop the old process, then: +octop run`, +} as const; + +type GuideMethodKey = + | "ui" + | "installer" + | "cli" + | "pip" + | "source" + | "docker" + | "restart"; + +const GUIDE_METHOD_ORDER: GuideMethodKey[] = [ + "ui", + "installer", + "cli", + "pip", + "source", + "docker", + "restart", +]; + +function codeFor(key: GuideMethodKey): string | null { + switch (key) { + case "installer": + return [ + `# macOS / Linux`, + UPGRADE_GUIDE_CODE.installerUnix, + ``, + `# Windows (PowerShell)`, + UPGRADE_GUIDE_CODE.installerWin, + ].join("\n"); + case "cli": + return UPGRADE_GUIDE_CODE.cli; + case "pip": + return UPGRADE_GUIDE_CODE.pip; + case "source": + return UPGRADE_GUIDE_CODE.source; + case "docker": + return UPGRADE_GUIDE_CODE.docker; + case "restart": + return UPGRADE_GUIDE_CODE.restart; + default: + return null; + } +} + +function UpgradeGuide() { + const { t } = useTranslation(); + + return ( +
+
+ + + +

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

+
+

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

+

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

+
+ {GUIDE_METHOD_ORDER.map((key) => { + const code = codeFor(key); + return ( +
+

+ {t(`advancedSettings.update.guide.${key}.title`)} +

+

+ {t(`advancedSettings.update.guide.${key}.body`)} +

+ {code &&
{code}
} +
+ ); + })} +
+
+ ); +} + export default function UpdateConfig() { const { t } = useTranslation(); const [status, setStatus] = useState(null); @@ -140,187 +262,203 @@ export default function UpdateConfig() { description={t("advancedSettings.update.description")} /> - {/* Version info cards */} -
-
- - {t("advancedSettings.update.currentVersion")} - - - {status?.current_version ?? "—"} - -
-
- - {t("advancedSettings.update.latestVersion")} - - - {status?.latest_version ?? ( - - {t("advancedSettings.update.notChecked")} - - )} - - {status?.has_update && ( - - {t("advancedSettings.update.updateAvailable")} +
+ {/* In-place version check & upgrade */} +
+
+ + - )} -
-
+

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

+
+

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

- {/* Release notes collapse — only shown when an update is available and notes exist */} - {status?.has_update && status.release_notes && ( - - -
- ), - }, - ]} - /> - )} +
+
+ + {t("advancedSettings.update.currentVersion")} + + + {status?.current_version ?? "—"} + +
+
+ + {t("advancedSettings.update.latestVersion")} + + + {status?.latest_version ?? ( + + {t("advancedSettings.update.notChecked")} + + )} + + {status?.has_update && ( + + {t("advancedSettings.update.updateAvailable")} + + )} +
+
- {/* Editable install warning */} - {status?.is_editable && ( -
- - {t("advancedSettings.update.editableHint")} -
- )} + {status?.is_editable && ( +
+ + {t("advancedSettings.update.editableHint")} +
+ )} - {/* Network error */} - {status?.error && ( -
- - {status.error} -
- )} + {status?.error && ( +
+ + {status.error} +
+ )} - {/* Action buttons */} -
- +
+ - {isServiceMode && ( - - )} + {isServiceMode && ( + + )} - {status?.has_update && !status.is_editable && !upgradeFinished && ( - - )} -
+ {status?.has_update && !status.is_editable && !upgradeFinished && ( + + )} +
- {/* Upgrade progress */} - {progress && ( -
- {progress.status === "running" && ( - <> -
- - {stageLabel(progress.stage)} -
-
-
-
- + {status?.has_update && status.release_notes && ( + , + }, + ]} + /> )} - {progress.status === "complete" && ( - <> -
- - - {t("advancedSettings.update.upgradeSuccess")} - {progress.new_version && ` → v${progress.new_version}`} - -
+ {progress && ( +
+ {progress.status === "running" && ( + <> +
+ + {stageLabel(progress.stage)} +
+
+
+
+ + )} - {/* Restart section */} - {restartPhase === "idle" && - (isServiceMode ? ( -
- -
-

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

- -
+ {progress.status === "complete" && ( + <> +
+ + + {t("advancedSettings.update.upgradeSuccess")} + {progress.new_version && ` → v${progress.new_version}`} +
- ) : ( -
- -
-

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

-
- octop restart - / - octop run + + {restartPhase === "idle" && + (isServiceMode ? ( +
+ +
+

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

+ +
-
-
- ))} - - )} + ) : ( +
+ +
+

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

+
+ octop service restart + / + octop run +
+
+
+ ))} + + )} - {progress.status === "error" && ( -
- -
-

- {t("advancedSettings.update.upgradeFailed")}: {progress.error} -

-

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

-
+ {progress.status === "error" && ( +
+ +
+

+ {t("advancedSettings.update.upgradeFailed")}:{" "} + {progress.error} +

+

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

+
+
+ )}
)} -
- )} + + + +
); } diff --git a/dashboard/src/pages/Settings/AdvancedSettings/index.tsx b/dashboard/src/pages/Settings/AdvancedSettings/index.tsx index f317f03a..721cf47a 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/index.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/index.tsx @@ -31,7 +31,11 @@ type TabKey = | "updates"; const TABS: { key: TabKey; labelKey: string; icon: ReactNode }[] = [ - { key: "env-vars", labelKey: "nav.environments", icon: }, + { + key: "env-vars", + labelKey: "nav.environments", + icon: , + }, { key: "search", labelKey: "nav.search", icon: }, { key: "voice", labelKey: "nav.voice", icon: }, { @@ -41,7 +45,11 @@ const TABS: { key: TabKey; labelKey: string; icon: ReactNode }[] = [ }, { key: "backup", labelKey: "nav.backupRestore", icon: }, { key: "https", labelKey: "nav.https", icon: }, - { key: "updates", labelKey: "nav.checkUpdates", icon: }, + { + key: "updates", + labelKey: "nav.checkUpdates", + icon: , + }, ]; function parseTab(raw: string | null): TabKey { diff --git a/dashboard/src/pages/Settings/Embedding/index.tsx b/dashboard/src/pages/Settings/Embedding/index.tsx index 00b641af..ed71a38b 100644 --- a/dashboard/src/pages/Settings/Embedding/index.tsx +++ b/dashboard/src/pages/Settings/Embedding/index.tsx @@ -1,14 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { - Select, - Input, - Button, - Spin, - Progress, - Alert, - Modal, -} from "antd"; +import { Select, Input, Button, Spin, Progress, Alert, Modal } from "antd"; import { message } from "@/utils/antdMessage"; import { Download, CheckCircle, AlertCircle } from "lucide-react"; diff --git a/dashboard/src/pages/Settings/HttpsSettings/index.module.less b/dashboard/src/pages/Settings/HttpsSettings/index.module.less new file mode 100644 index 00000000..2ff49e1a --- /dev/null +++ b/dashboard/src/pages/Settings/HttpsSettings/index.module.less @@ -0,0 +1,125 @@ +.container { + width: 100%; + min-width: 0; + max-width: 880px; +} + +.statusStack { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.panel { + min-width: 0; + border: 1px solid var(--fn-border-primary); + border-radius: 12px; + background: var( + --fn-bg-elevated, + var(--fn-bg-secondary, rgba(0, 0, 0, 0.02)) + ); + padding: 20px 22px; +} + +.steps { + margin-bottom: 22px; + padding-bottom: 4px; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + max-width: 520px; +} + +.fieldLabel { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.stagingRow { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 18px; + max-width: 640px; +} + +.stagingText { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--fn-text-secondary); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 4px; +} + +.checks { + margin-top: 20px; + padding-top: 18px; + border-top: 1px solid var(--fn-border-primary); +} + +.checksTitle { + margin: 0 0 12px; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.restartBlock { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; +} + +.restartBlock p { + margin: 0; +} + +.commandBlock { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.commandSep { + color: var(--fn-text-tertiary); + font-size: 12px; +} + +.commandBlock code { + font-family: var(--fn-font-mono, "SFMono-Regular", Consolas, monospace); + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--fn-text-primary) 10%, transparent); +} + +.loading { + display: flex; + justify-content: center; + padding: 48px 0; +} + +.pass { + color: #16a34a; + font-weight: 600; +} + +.fail { + color: #dc2626; + font-weight: 600; +} diff --git a/dashboard/src/pages/Settings/HttpsSettings/index.tsx b/dashboard/src/pages/Settings/HttpsSettings/index.tsx index e1d2d309..578c0689 100644 --- a/dashboard/src/pages/Settings/HttpsSettings/index.tsx +++ b/dashboard/src/pages/Settings/HttpsSettings/index.tsx @@ -1,15 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { - Alert, - Button, - Input, - Space, - Spin, - Steps, - Switch, - Table, - Typography, -} from "antd"; +import { Alert, Button, Input, Spin, Steps, Switch, Table } from "antd"; import { message } from "@/utils/antdMessage"; import { Lock, Power, RefreshCw, ShieldCheck } from "lucide-react"; @@ -22,10 +12,7 @@ import { import { updateApi } from "../../../api/modules/update"; import { useServiceRestartContext } from "../../../context/ServiceRestartContext"; import { TabPanelHeader } from "../AdvancedSettings/TabPanelHeader"; -import updateStyles from "../AdvancedSettings/UpdateConfig.module.less"; -import tabStyles from "../AdvancedSettings/tabContent.module.less"; - -const { Text } = Typography; +import styles from "./index.module.less"; const TASK_STEP_ORDER = [ "idle", @@ -37,11 +24,23 @@ const TASK_STEP_ORDER = [ "active", ] as const; -function taskStepIndex(state: string): number { +/** Map task state → Steps `current` index (items omit idle). */ +function stepsCurrent(state: string): number { const idx = TASK_STEP_ORDER.indexOf( state as (typeof TASK_STEP_ORDER)[number], ); - return idx >= 0 ? idx : 0; + if (idx <= 0) return 0; + // active (last in order) should highlight final step + return Math.min(idx - 1, 5); +} + +function stepsStatus(state: string): "wait" | "process" | "finish" | "error" { + if (state === "failed") return "error"; + if (state === "active") return "finish"; + if (["preflight", "challenging", "issuing", "installing"].includes(state)) { + return "process"; + } + return "wait"; } export function HttpsSettingsPanel() { @@ -185,7 +184,11 @@ export function HttpsSettingsPanel() { key: "ok", width: 100, render: (_: unknown, row: PreflightCheck) => - row.ok ? t("tls.pass") : t("tls.fail"), + row.ok ? ( + {t("tls.pass")} + ) : ( + {t("tls.fail")} + ), }, { title: t("tls.checkMessage"), @@ -195,7 +198,7 @@ export function HttpsSettingsPanel() { ]; return ( - <> +
} title={t("tls.title")} @@ -203,150 +206,143 @@ export function HttpsSettingsPanel() { /> {loading && !status ? ( - +
+ +
) : ( <> - {tlsEnabled && status?.tls.expires_at && ( - } - message={t("tls.activeCert", { - expires: status.tls.expires_at, - httpPort: status.tls.http_port, - })} - style={{ marginBottom: 16 }} - /> - )} +
+ {tlsEnabled && status?.tls.expires_at && ( + } + message={t("tls.activeCert", { + expires: status.tls.expires_at, + httpPort: status.tls.http_port, + })} + /> + )} - {taskState === "restart_required" && ( - -

{t("tls.restartRequiredHint")}

- -
- ) : ( -
-

- {t("tls.restartRequiredHint")} -

-
- octop restart - / - octop run + {taskState === "restart_required" && ( + +

{t("tls.restartRequiredHint")}

+ + +
-
- ) - } - style={{ marginBottom: 16 }} - /> - )} + ) : ( +
+

{t("tls.restartRequiredHint")}

+
+ octop service restart + / + octop run +
+
+ ) + } + /> + )} - {taskState === "failed" && status?.task.error && ( - - )} + {taskState === "failed" && status?.task.error && ( + + )} - {!eligible && !tlsEnabled && ( - - )} + {!eligible && !tlsEnabled && ( + + )} - {status?.tls.dual_listeners && ( - - )} + {status?.tls.dual_listeners && ( + + )} +
- +
+ -
- -
- {t("tls.domainLabel")} - setDomain(e.target.value)} - placeholder={t("tls.domainPlaceholder")} - disabled={formLocked} - style={{ marginTop: 8 }} - /> -
+
+

{t("tls.domainLabel")}

+ setDomain(e.target.value)} + placeholder={t("tls.domainPlaceholder")} + disabled={formLocked} + /> +
- - - {t("tls.stagingHint")} - +
+ +

{t("tls.stagingHint")}

+
- - - - - +
+ + + +
- {checks.length > 0 && ( + {checks.length > 0 && ( +
+

{t("tls.checksTitle")}

- )} - + + )} )} - + ); } diff --git a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx index 369c80bf..05a7eeb6 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx @@ -5,15 +5,7 @@ * Connectivity tests still hit the live provider endpoint. */ import { useState } from "react"; -import { - Button, - Form, - Input, - InputNumber, - Modal, - Select, - Switch, -} from "antd"; +import { Button, Form, Input, InputNumber, Modal, Select, Switch } from "antd"; import { message } from "@/utils/antdMessage"; import { diff --git a/dashboard/src/pages/Settings/Observability/index.tsx b/dashboard/src/pages/Settings/Observability/index.tsx index a4330f4f..cf788050 100644 --- a/dashboard/src/pages/Settings/Observability/index.tsx +++ b/dashboard/src/pages/Settings/Observability/index.tsx @@ -1,13 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { - Alert, - Button, - Form, - Input, - Space, - Switch, - Typography, -} from "antd"; +import { Alert, Button, Form, Input, Space, Switch, Typography } from "antd"; import { message } from "@/utils/antdMessage"; import { Activity, CheckCircle2, RefreshCw } from "lucide-react"; diff --git a/dashboard/src/pages/Settings/SearchConfig/index.test.tsx b/dashboard/src/pages/Settings/SearchConfig/index.test.tsx index 38e05eae..d743f693 100644 --- a/dashboard/src/pages/Settings/SearchConfig/index.test.tsx +++ b/dashboard/src/pages/Settings/SearchConfig/index.test.tsx @@ -37,9 +37,7 @@ describe("", () => { render(); await waitFor(() => expect(api.listEnvs).toHaveBeenCalled()); - expect( - screen.getByText("当前搜索源:内置搜索"), - ).toBeInTheDocument(); + expect(screen.getByText("当前搜索源:内置搜索")).toBeInTheDocument(); }); it("shows the configured provider as the current search source", async () => { diff --git a/dashboard/src/pages/Settings/SearchConfig/index.tsx b/dashboard/src/pages/Settings/SearchConfig/index.tsx index 6c800dd8..cb744aa9 100644 --- a/dashboard/src/pages/Settings/SearchConfig/index.tsx +++ b/dashboard/src/pages/Settings/SearchConfig/index.tsx @@ -13,10 +13,7 @@ import { import { useTranslation } from "react-i18next"; import { envsApi } from "../../../api/modules/env"; import api from "../../../api"; -import { - customProviderLogo, - getProviderLogo, -} from "../../../assets/providers"; +import { customProviderLogo, getProviderLogo } from "../../../assets/providers"; import { apiErrorMessage } from "../../../utils/apiError"; import { TabPanelHeader } from "../AdvancedSettings/TabPanelHeader"; import styles from "./index.module.less"; @@ -430,9 +427,7 @@ export default function SearchConfigPage() { -

- {t(provider.descriptionKey)} -

+

{t(provider.descriptionKey)}

-
+
{description || t("skillPackages.noDescription")}
diff --git a/dashboard/src/pages/SkillPackages/index.module.less b/dashboard/src/pages/SkillPackages/index.module.less index 06c96364..3cf10ca0 100644 --- a/dashboard/src/pages/SkillPackages/index.module.less +++ b/dashboard/src/pages/SkillPackages/index.module.less @@ -73,14 +73,22 @@ background 0.15s ease; &:hover { - border-color: color-mix(in srgb, var(--fn-color-brand) 45%, var(--fn-border-secondary)) !important; + border-color: color-mix( + in srgb, + var(--fn-color-brand) 45%, + var(--fn-border-secondary) + ) !important; box-shadow: var(--fn-shadow-sm); transform: translateY(-1px); } } .active { - border-color: color-mix(in srgb, var(--fn-color-brand) 65%, var(--fn-border-secondary)) !important; + border-color: color-mix( + in srgb, + var(--fn-color-brand) 65%, + var(--fn-border-secondary) + ) !important; box-shadow: 0 0 0 1px color-mix(in srgb, var(--fn-color-brand) 30%, transparent), 0 8px 20px color-mix(in srgb, var(--fn-color-brand) 10%, transparent); diff --git a/dashboard/src/pages/SkillPackages/index.tsx b/dashboard/src/pages/SkillPackages/index.tsx index d0d18c5a..3b800ff5 100644 --- a/dashboard/src/pages/SkillPackages/index.tsx +++ b/dashboard/src/pages/SkillPackages/index.tsx @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, +} from "react"; import { Button, Drawer, @@ -128,7 +134,9 @@ export default function SkillPackagesPage() { const rows = await skillPackagesApi.list(); setPackages(rows); setSelected((current) => - current && !rows.some((row) => row.id === current.id) ? null : current, + current && !rows.some((row) => row.id === current.id) + ? null + : current, ); setSelectedId((currentId) => currentId && !rows.some((row) => row.id === currentId) @@ -179,7 +187,13 @@ export default function SkillPackagesPage() { }, [loadPackages]); useEffect(() => { - if (isMobile || loading || detailLoading || selectedId || packages.length === 0) { + if ( + isMobile || + loading || + detailLoading || + selectedId || + packages.length === 0 + ) { return; } void loadDetail(packages[0].id); @@ -463,9 +477,9 @@ export default function SkillPackagesPage() { fill >
- {detailLoading ? ( -
- -
- ) : null} - {!selected && !detailLoading ? ( - } - description={t("skillPackages.selectPackage")} - /> - ) : !selected ? null : ( - <> -
-
-
- {isMobile ? ( - - ) : null} - - {selected.name} - -
- {canMutate ? ( -
- - void deletePackage()} - > - - -
- ) : null} -
- - {selected.description || t("skillPackages.noDescription")} - +
+ {detailLoading ? ( +
+
- -
-
- - {t("skills.totalCount", { count: skills.length })} - -
- - setViewMode(value === "table" ? "table" : "card") - } - options={[ - { - value: "card", - label: ( - - - {t("experts.viewCard")} - - ), - }, - { - value: "table", - label: ( - - - {t("experts.viewTable")} - - ), - }, - ]} - /> - - - - {canMutate ? ( - <> + ) : null} + {!selected && !detailLoading ? ( + } + description={t("skillPackages.selectPackage")} + /> + ) : !selected ? null : ( + <> +
+
+
+ {isMobile ? ( -
+ {canMutate ? ( +
+ + {t("common.edit")} + + void deletePackage()} + > + + +
+ ) : null} +
+ + {selected.description || t("skillPackages.noDescription")} + +
+ +
+
+ + {t("skills.totalCount", { count: skills.length })} + +
+ + setViewMode(value === "table" ? "table" : "card") + } + options={[ + { + value: "card", + label: ( + + + {t("experts.viewCard")} + + ), + }, + { + value: "table", + label: ( + + + {t("experts.viewTable")} + + ), + }, + ]} + /> + - - ) : null} + + {canMutate ? ( + <> + + + + + ) : null} +
+
+
+ {skillsContent}
-
{skillsContent}
-
- - )} -
+ + )} + ) : null}
@@ -770,7 +791,9 @@ export default function SkillPackagesPage() { label: ( {iconForName(name, 18)} - {t(`experts.iconLabels.${name}`, { defaultValue: name })} + + {t(`experts.iconLabels.${name}`, { defaultValue: name })} + ), }))} diff --git a/dashboard/src/utils/chatStreamError.test.ts b/dashboard/src/utils/chatStreamError.test.ts index 65a14fc2..f1e4a086 100644 --- a/dashboard/src/utils/chatStreamError.test.ts +++ b/dashboard/src/utils/chatStreamError.test.ts @@ -20,9 +20,9 @@ describe("classifyChatStreamError", () => { }); it("classifies rate limit and auth errors", () => { - expect(classifyChatStreamError("Error code: 429 - rate_limit_exceeded")).toBe( - "stream_errors.rate_limit", - ); + expect( + classifyChatStreamError("Error code: 429 - rate_limit_exceeded"), + ).toBe("stream_errors.rate_limit"); expect( classifyChatStreamError("Error code: 401 - Incorrect API key provided"), ).toBe("stream_errors.auth"); @@ -34,7 +34,8 @@ describe("classifyChatStreamError", () => { }); it("formats known failures through i18n", () => { - const msg = "No streaming chunk received for 30.0s (model=x, chunks_received=1)"; + const msg = + "No streaming chunk received for 30.0s (model=x, chunks_received=1)"; expect(formatChatStreamError(msg, t)).toBe( "translated:stream_errors.stream_stall", ); diff --git a/docs/assets/qrcode.png b/docs/assets/qrcode.png index 4adc64bcf2c0792e4ac5e1521f5a1d56df83752b..9dcc97b3a8b1ef71b8da678962f35844a8129545 100644 GIT binary patch literal 16752 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 literal 22485 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 HarnessAgentConfig: name=_memory_namespace(row.agent_id), workspace_dir=workspace_dir, # Memory aux LLM (extraction / promotion) needs a concrete ref; fall - # back to the first usable model — the same one AUTO chat routing - # resolves to — so promotion works whenever chat does. Per-turn AUTO - # routing is unaffected: the gateway resolves models via + # back to the same resolution AUTO chat routing uses (active model + # first, else first usable) — so promotion works whenever chat does. + # Per-turn AUTO routing is unaffected: the gateway resolves models via # ``resolve_explicit_default_model`` directly. default_model=( self._providers.resolve_explicit_default_model(row, cfg) - or self._providers.resolve_first_model_ref() + or self.resolve_fallback_model_ref() ), system_prompt=system_prompt, memory=memory, diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index fb8f4512..34fa5496 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -466,6 +466,47 @@ def test_build_harness_config_auto_expert_falls_back_to_first_model( assert cfg.providers == [] +def test_build_harness_config_uses_active_model_before_first( + manager: AgentManager, +) -> None: + """AUTO expert default_model follows the global active model when usable.""" + _seed_test_provider(manager) + manager._repos.provider_repo.create( + name="second-openai", + kind="openai", + base_url="https://api.example.com/v1", + api_key="sk-test", + models_json=json.dumps( + [{"id": "gpt-5", "name": "gpt-5", "enabled": True}], + ), + ) + manager._repos.settings_repo.set_active_model("second-openai", "gpt-5") + cfg = manager._build_harness_config(_row(default_model=None)) + assert cfg.default_model == "second-openai/gpt-5" + + +def test_build_harness_config_active_model_ignored_when_unusable( + manager: AgentManager, +) -> None: + """Active model pointing at a disabled/removed provider falls back to first.""" + _seed_test_provider(manager) + manager._repos.settings_repo.set_active_model("deleted-provider", "gone") + cfg = manager._build_harness_config(_row(default_model=None)) + assert cfg.default_model == "test-openai/gpt-4o-mini" + + +def test_build_harness_config_explicit_default_wins_over_active( + manager: AgentManager, +) -> None: + """Pinned expert default takes precedence over the global active model.""" + _seed_test_provider(manager) + manager._repos.settings_repo.set_active_model("test-openai", "gpt-4o-mini") + cfg = manager._build_harness_config( + _row(config_json=json.dumps({"default_model": "test-openai/gpt-4o-mini"})), + ) + assert cfg.default_model == "test-openai/gpt-4o-mini" + + def test_build_harness_config_no_providers_leaves_default_model_unset( manager: AgentManager, ) -> None: From 8d0503079f5dd1e66b2b0320e0ed405c46d1cd0b Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Wed, 5 Aug 2026 21:45:11 +0800 Subject: [PATCH 2/2] chore: release 0.9.19 (#171) --- .env.example | 13 + CHANGELOG.md | 18 +- README.md | 2 +- dashboard/src/App.tsx | 3 + dashboard/src/api/modules/octopThreads.ts | 2 + dashboard/src/api/request.ts | 36 +- .../src/api/request.unauthorized.test.ts | 71 ++ .../components/AgentProfileDrawer.test.tsx | 99 ++ .../src/components/AgentProfileDrawer.tsx | 419 +++++--- .../BrowserViewer/index.module.less | 76 -- .../src/components/BrowserViewer/index.tsx | 67 +- .../ChatBrowserPanel.module.less | 21 +- .../BrowserWorkspace/index.module.less | 98 -- .../components/ChromeTabBar/index.module.less | 111 ++ .../src/components/ChromeTabBar/index.tsx | 108 ++ .../components/ErrorBoundary/index.test.tsx | 50 + .../src/components/ErrorBoundary/index.tsx | 40 +- .../PwaInstallPrompt/index.module.less | 57 ++ .../src/components/PwaInstallPrompt/index.tsx | 66 +- .../hooks/useUnauthorizedRedirect.test.tsx | 50 + .../src/hooks/useUnauthorizedRedirect.ts | 23 + dashboard/src/layouts/MainLayout/index.tsx | 37 +- dashboard/src/layouts/Sidebar.module.less | 48 + dashboard/src/layouts/Sidebar.tsx | 192 ++-- dashboard/src/locales/en.json | 26 +- dashboard/src/locales/zh.json | 26 +- .../pages/Agent/Channels/ChannelsPanel.tsx | 22 +- .../Channels/components/ChannelDrawer.tsx | 59 +- .../src/pages/Agent/Channels/useChannels.ts | 1 - .../Agent/Workspace/components/FileViewer.tsx | 1 + .../Workspace/components/MediaPreview.tsx | 47 +- .../src/pages/Chat/chatAgentCard.partial.less | 4 +- .../pages/Chat/chatBrowserPanel.partial.less | 65 +- .../src/pages/Chat/chatInputCore.partial.less | 8 +- .../src/pages/Chat/chatMessages.partial.less | 59 +- .../pages/Chat/components/ChatDockPanel.tsx | 53 +- .../ChatDockPanels.keepAlive.test.tsx | 61 ++ .../pages/Chat/components/ChatDockPanels.tsx | 100 +- .../pages/Chat/components/ChatTitleBar.tsx | 18 +- .../Chat/components/FilePanelContent.tsx | 14 +- .../pages/Chat/components/MessageBubble.tsx | 45 +- .../src/pages/Chat/components/MessageList.tsx | 27 +- .../Chat/hooks/chatSidebarDefaultOpen.test.ts | 40 + .../hooks/chatStore.clearMessages.test.ts | 42 + dashboard/src/pages/Chat/hooks/chatStore.ts | 641 +++++++++++- .../pages/Chat/hooks/turnStatusGate.test.ts | 12 + .../src/pages/Chat/hooks/turnStatusGate.ts | 9 + .../pages/Chat/hooks/useAutoScroll.test.ts | 32 + dashboard/src/pages/Chat/hooks/useChat.ts | 72 +- .../pages/Chat/hooks/useChatDockPanel.test.ts | 80 ++ .../src/pages/Chat/hooks/useChatDockPanel.ts | 95 +- dashboard/src/pages/Chat/hooks/useChatSend.ts | 7 +- .../Chat/hooks/useChatSessionActions.test.tsx | 80 ++ .../pages/Chat/hooks/useChatSessionActions.ts | 7 +- .../pages/Chat/hooks/useChatSidebarState.ts | 25 +- .../src/pages/Chat/hooks/useSessions.test.ts | 51 +- dashboard/src/pages/Chat/hooks/useSessions.ts | 46 +- .../src/pages/Chat/hooks/wsResumeGate.test.ts | 203 ++++ .../src/pages/Chat/hooks/wsResumeGate.ts | 99 ++ dashboard/src/pages/Chat/index.module.less | 66 +- dashboard/src/pages/Chat/index.tsx | 103 +- .../src/pages/Chat/utils/dockFilePath.test.ts | 45 +- .../src/pages/Chat/utils/dockFilePath.ts | 77 +- .../src/pages/Chat/utils/threadTitle.test.ts | 58 ++ dashboard/src/pages/Chat/utils/threadTitle.ts | 34 + .../pages/Control/CronJobs/index.module.less | 38 + .../src/pages/Control/CronJobs/index.tsx | 159 +-- .../src/pages/Control/CronJobs/useCronJobs.ts | 201 +++- .../Terminal/ensureDefaultSession.test.ts | 31 + .../pages/Control/Terminal/index.module.less | 121 +-- .../src/pages/Control/Terminal/index.tsx | 172 ++-- .../Control/Terminal/useTerminal.testUtils.ts | 5 + .../src/pages/Control/Terminal/useTerminal.ts | 956 ++++++++++-------- .../Experts/components/AgentBackendFields.tsx | 9 + .../components/CreateFromExpertDrawer.tsx | 17 + .../Experts/components/EditAgentDrawer.tsx | 163 +-- .../Experts/components/ExpertMarketTab.tsx | 34 +- .../components/RootDirSelect.expand.test.tsx | 154 +++ .../components/RootDirSelect.module.less | 57 ++ .../Experts/components/RootDirSelect.test.tsx | 258 +++++ .../Experts/components/RootDirSelect.tsx | 335 ++++-- .../agentBackendForm.ensureBwrap.test.ts | 19 + .../Experts/components/agentBackendForm.ts | 44 + .../Experts/components/rootDirTree.test.ts | 183 ++++ .../pages/Experts/components/rootDirTree.ts | 127 +++ dashboard/src/pages/Experts/index.module.less | 31 + .../src/pages/Login/SlideCaptcha.test.tsx | 85 ++ dashboard/src/pages/Login/SlideCaptcha.tsx | 246 +++++ dashboard/src/pages/Login/index.tsx | 20 +- dashboard/src/routes/controlAdminPath.test.ts | 26 + dashboard/src/routes/index.tsx | 22 +- .../src/utils/reloadOnStaleChunk.test.ts | 44 + dashboard/src/utils/reloadOnStaleChunk.ts | 19 + dashboard/src/utils/toolMediaBlocks.test.ts | 45 + dashboard/src/utils/toolMediaBlocks.ts | 113 ++- dashboard/src/utils/workspaceIoPath.test.ts | 42 + dashboard/src/utils/workspaceIoPath.ts | 87 ++ docker/README.md | 4 +- docker/README_CN.md | 4 +- docker/docker-compose.yml | 13 + docs/agent-backend-file-io.md | 12 + docs/api.md | 6 +- docs/configuration.md | 11 + pyproject.toml | 6 +- scripts/install.sh | 35 + src/octop/__init__.py | 2 +- src/octop/api/common/workspace.py | 14 + src/octop/api/routers/chat/history.py | 8 +- src/octop/api/routers/chat/ws.py | 48 +- src/octop/api/routers/filesystem.py | 61 ++ src/octop/api/routers/subagents.py | 2 +- src/octop/api/routers/workspace.py | 10 +- src/octop/infra/agents/manager.py | 102 +- src/octop/infra/db/migrate.py | 14 + .../003_repair_legacy_thread_titles.pg.sql | 3 + .../003_repair_legacy_thread_titles.sql | 3 + src/octop/infra/db/repos/threads.py | 77 +- .../desktop/scripts/linux/v1.0/install.sh | 3 +- src/octop/infra/gateway/gateway.py | 2 +- src/octop/infra/gateway/process/processor.py | 2 +- .../infra/gateway/slash/handlers/session.py | 5 +- src/octop/infra/gateway/ws/ws_channel.py | 70 +- src/octop/infra/gateway/ws/ws_hub.py | 49 +- src/octop/infra/utils/bwrap.py | 213 ++++ src/octop/infra/utils/host_dirs.py | 54 + tests/integration/test_bwrap_jail.py | 238 +++++ tests/integration/test_chat_ws.py | 171 +++- tests/integration/test_filesystem_api.py | 86 ++ tests/support/bwrap_marks.py | 21 + tests/support/fakes.py | 82 +- tests/unit/agents/test_agent_manager.py | 1 + tests/unit/agents/test_agent_registry.py | 75 +- .../unit/agents/test_agent_skill_packages.py | 102 +- tests/unit/api/test_setup_main_agent.py | 12 +- tests/unit/api/test_workspace_tree_entries.py | 26 + tests/unit/db/test_clip_thread_title.py | 88 ++ tests/unit/db/test_db_pool.py | 4 +- tests/unit/db/test_repo_sessions_threads.py | 25 + tests/unit/db/test_skill_package_icons.py | 4 +- tests/unit/db/test_skill_packages_repo.py | 2 +- tests/unit/gateway/test_dashboard_ws.py | 100 +- .../i18n/test_admin_users_expert_naming.py | 18 + tests/unit/infra/utils/test_bwrap.py | 141 +++ tests/unit/infra/utils/test_host_dirs.py | 45 + .../unit/test_docker_compose_database_env.py | 29 + uv.lock | 19 +- 146 files changed, 8410 insertions(+), 1842 deletions(-) create mode 100644 dashboard/src/api/request.unauthorized.test.ts create mode 100644 dashboard/src/components/AgentProfileDrawer.test.tsx create mode 100644 dashboard/src/components/ChromeTabBar/index.module.less create mode 100644 dashboard/src/components/ChromeTabBar/index.tsx create mode 100644 dashboard/src/components/ErrorBoundary/index.test.tsx create mode 100644 dashboard/src/hooks/useUnauthorizedRedirect.test.tsx create mode 100644 dashboard/src/hooks/useUnauthorizedRedirect.ts create mode 100644 dashboard/src/pages/Chat/components/ChatDockPanels.keepAlive.test.tsx create mode 100644 dashboard/src/pages/Chat/hooks/chatSidebarDefaultOpen.test.ts create mode 100644 dashboard/src/pages/Chat/hooks/chatStore.clearMessages.test.ts create mode 100644 dashboard/src/pages/Chat/hooks/turnStatusGate.test.ts create mode 100644 dashboard/src/pages/Chat/hooks/turnStatusGate.ts create mode 100644 dashboard/src/pages/Chat/hooks/useChatSessionActions.test.tsx create mode 100644 dashboard/src/pages/Chat/hooks/wsResumeGate.test.ts create mode 100644 dashboard/src/pages/Chat/hooks/wsResumeGate.ts create mode 100644 dashboard/src/pages/Chat/utils/threadTitle.test.ts create mode 100644 dashboard/src/pages/Chat/utils/threadTitle.ts create mode 100644 dashboard/src/pages/Control/Terminal/ensureDefaultSession.test.ts create mode 100644 dashboard/src/pages/Control/Terminal/useTerminal.testUtils.ts create mode 100644 dashboard/src/pages/Experts/components/RootDirSelect.expand.test.tsx create mode 100644 dashboard/src/pages/Experts/components/RootDirSelect.module.less create mode 100644 dashboard/src/pages/Experts/components/RootDirSelect.test.tsx create mode 100644 dashboard/src/pages/Experts/components/agentBackendForm.ensureBwrap.test.ts create mode 100644 dashboard/src/pages/Experts/components/rootDirTree.test.ts create mode 100644 dashboard/src/pages/Experts/components/rootDirTree.ts create mode 100644 dashboard/src/pages/Login/SlideCaptcha.test.tsx create mode 100644 dashboard/src/pages/Login/SlideCaptcha.tsx create mode 100644 dashboard/src/routes/controlAdminPath.test.ts create mode 100644 dashboard/src/utils/workspaceIoPath.test.ts create mode 100644 dashboard/src/utils/workspaceIoPath.ts create mode 100644 src/octop/infra/db/migrations/003_repair_legacy_thread_titles.pg.sql create mode 100644 src/octop/infra/db/migrations/003_repair_legacy_thread_titles.sql create mode 100644 src/octop/infra/utils/bwrap.py create mode 100644 tests/integration/test_bwrap_jail.py create mode 100644 tests/support/bwrap_marks.py create mode 100644 tests/unit/api/test_workspace_tree_entries.py create mode 100644 tests/unit/db/test_clip_thread_title.py create mode 100644 tests/unit/i18n/test_admin_users_expert_naming.py create mode 100644 tests/unit/infra/utils/test_bwrap.py create mode 100644 tests/unit/test_docker_compose_database_env.py diff --git a/.env.example b/.env.example index 2a9e7eb9..44a17559 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,19 @@ OCTOP_ADMIN_USERNAME=admin OCTOP_DEFAULT_PASSWORD=octop OCTOP_ADMIN_DISPLAY_NAME=Admin +# Control-plane database (optional — default is SQLite under ~/.octop) +# For Docker Compose, also list these under `environment:` in docker-compose.yml +# (docker/.env only interpolates; it does not auto-inject into the container). +# Bare-metal / container data volume: same keys may live in ~/.octop/env. +# OCTOP_DATABASE_DRIVER=postgresql +# OCTOP_DATABASE_URL=postgresql://octop:octop@127.0.0.1:5432/octop +# OCTOP_DATABASE_HOST=127.0.0.1 +# OCTOP_DATABASE_PORT=5432 +# OCTOP_DATABASE_NAME=octop +# OCTOP_DATABASE_USER=octop +# OCTOP_DATABASE_PASSWORD= +# OCTOP_DATABASE_SQLITE_PATH=octop.db + # Common LLM provider credentials (optional — can configure in dashboard) OPENAI_API_KEY= DASHSCOPE_API_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index 82dd1788..dcef07bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,24 @@ ## [Unreleased] +## [0.9.19] - 2026-08-05 + ### 新增 -- 高级设置「更新」页提供按安装方式升级说明与一键检查升级双栏布局 -- 高级设置 HTTPS 页优化签发状态与预检结果展示 +- 登录页滑动验证控件;侧栏与 Agent 资料抽屉 UI 优化 (#170) +- 聊天历史 API 返回 `turn_active`,重连客户端可 re-subscribe WebSocket 恢复流式输出 (#168, #157) +- Workbench 与聊天 Dock 共用同一 terminal 会话;旧式硬切会话标题迁移为带省略号的裁剪标题 (#157) +- 局部 `root_dir` 下 Linux bubblewrap execute jail(`POST /api/filesystem/ensure-bwrap`、仪表盘 root 目录树 mkdir/rename)(#167) +- 虚拟工作区路径 I/O:host 绝对路径经 `file://` 与 `BackendWorkspace` failback 对齐 (#167) +- 高级设置「更新」页提供按安装方式升级说明与一键检查升级双栏布局;HTTPS 页优化签发状态与预检展示 (#143) + +### 修复 +- 401 会话过期时通过 React Router 跳转登录,避免整页 reload 导致 lazy chunk 白屏 (#169) ### 变更 -- `make all` 先执行前后端 `format-all`(Ruff + Prettier);pre-commit 在 format 后回写已暂存文件并构建 dashboard +- `make all` 先执行前后端 `format-all`(Ruff + Prettier);pre-commit 在 format 后回写已暂存文件并构建 dashboard (#143) +- harness runtime 诊断日志写入 `~/.octop/logs`(与 `octop.log` 并排),不再落到各 agent workspace 的 `logs/`;行内带 `[agent=…]` +- 依赖 `orcakit-harness-agent>=0.9.19`、`harness-gateway>=0.9.1`(scoped root execute jail) +- 企业微信客户群二维码与文档有效期更新至 2026-08-08 (#149) ## [0.9.18] - 2026-08-02 diff --git a/README.md b/README.md index 07432b92..26e45e3c 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/src/App.tsx b/dashboard/src/App.tsx index e734e7a1..70a4ae12 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -13,6 +13,7 @@ import { ThemeProvider, useTheme } from "./context/ThemeContext"; import { AgentProvider } from "./context/AgentContext"; import { VoiceOutputProvider } from "./context/VoiceOutputContext"; import { useIsMobile } from "./hooks/useIsMobile"; +import { useUnauthorizedRedirect } from "./hooks/useUnauthorizedRedirect"; import "./styles/theme-vars.css"; import "./styles/layout.css"; import "./styles/form-override.css"; @@ -29,6 +30,8 @@ function ThemedApp() { const { t } = useTranslation(); const isMobile = useIsMobile(); + useUnauthorizedRedirect(); + // Set document title based on current language useEffect(() => { document.title = t("app.pageTitle"); diff --git a/dashboard/src/api/modules/octopThreads.ts b/dashboard/src/api/modules/octopThreads.ts index ab10a84f..ae7ce93b 100644 --- a/dashboard/src/api/modules/octopThreads.ts +++ b/dashboard/src/api/modules/octopThreads.ts @@ -26,6 +26,8 @@ export interface OctopThreadHistory { has_more?: boolean; limit?: number; offset?: number; + /** True while a turn is still streaming server-side for this thread. */ + turn_active?: boolean; } export interface OctopThreadPatch { diff --git a/dashboard/src/api/request.ts b/dashboard/src/api/request.ts index 7dd98148..d57507ee 100644 --- a/dashboard/src/api/request.ts +++ b/dashboard/src/api/request.ts @@ -1,8 +1,16 @@ import { getApiUrl } from "./config"; import i18n from "../i18n"; +import { markNavigatingAway } from "../utils/reloadOnStaleChunk"; const AUTH_TOKEN_KEY = "auth_token"; +/** + * Fired when the session is no longer valid. Cancelable: a listener inside the + * React tree calls ``preventDefault()`` to route to /login itself, which keeps + * the SPA alive instead of tearing the document down mid-render. + */ +export const UNAUTHORIZED_EVENT = "octop:unauthorized"; + /** Response header used by the server for JWT sliding renewal. */ export const ACCESS_TOKEN_RESPONSE_HEADER = "X-Octop-Access-Token"; @@ -180,6 +188,27 @@ function buildAuthHeaders(path: string): Record { return headers; } +let _redirectingToLogin = false; + +/** + * Send the user to the login screen exactly once, no matter how many parallel + * requests report an expired session. + */ +function handleUnauthorized(): void { + if (_redirectingToLogin) return; + const path = window.location.pathname; + if (path.startsWith("/setup") || path.startsWith("/login")) return; + _redirectingToLogin = true; + + const takenOver = !window.dispatchEvent( + new CustomEvent(UNAUTHORIZED_EVENT, { cancelable: true }), + ); + if (takenOver) return; + + markNavigatingAway(); + window.location.replace("/login"); +} + /** * Handle 401 responses: clear token, redirect to login, and throw. * Shared by request(), requestBlob(), and requestUpload(). @@ -192,12 +221,7 @@ async function throwIfUnauthorized( return; } clearAuthToken(); - if ( - !window.location.pathname.startsWith("/setup") && - !window.location.pathname.startsWith("/login") - ) { - window.location.href = "/login"; - } + handleUnauthorized(); let message = "Unauthorized"; if (path.startsWith("/setup/")) { try { diff --git a/dashboard/src/api/request.unauthorized.test.ts b/dashboard/src/api/request.unauthorized.test.ts new file mode 100644 index 00000000..e9e72c73 --- /dev/null +++ b/dashboard/src/api/request.unauthorized.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./config", () => ({ + getApiUrl: (path: string) => `/api${path}`, +})); + +vi.mock("../i18n", () => ({ + default: { language: "zh" }, +})); + +function unauthorizedResponse(): Response { + return new Response("{}", { + status: 401, + headers: { "content-type": "application/json" }, + }); +} + +describe("401 handling", () => { + const replace = vi.fn(); + let mod: typeof import("./request"); + + beforeEach(async () => { + vi.resetModules(); + localStorage.clear(); + replace.mockClear(); + Object.defineProperty(window, "location", { + configurable: true, + value: { pathname: "/chat", replace }, + }); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(unauthorizedResponse())), + ); + mod = await import("./request"); + mod.setAuthToken("expired-token"); + }); + + it("lets an in-app listener take over the redirect instead of reloading", async () => { + const listener = vi.fn((event: Event) => event.preventDefault()); + window.addEventListener(mod.UNAUTHORIZED_EVENT, listener); + + await expect(mod.request("/agents")).rejects.toThrow(); + + expect(listener).toHaveBeenCalledOnce(); + expect(replace).not.toHaveBeenCalled(); + expect(mod.getAuthToken()).toBe(""); + + window.removeEventListener(mod.UNAUTHORIZED_EVENT, listener); + }); + + it("falls back to a full-page navigation when nothing handles the event", async () => { + await expect(mod.request("/agents")).rejects.toThrow(); + + expect(replace).toHaveBeenCalledWith("/login"); + }); + + it("redirects only once when several requests fail concurrently", async () => { + const listener = vi.fn((event: Event) => event.preventDefault()); + window.addEventListener(mod.UNAUTHORIZED_EVENT, listener); + + await Promise.allSettled([ + mod.request("/agents"), + mod.request("/skills"), + mod.request("/cron"), + ]); + + expect(listener).toHaveBeenCalledOnce(); + + window.removeEventListener(mod.UNAUTHORIZED_EVENT, listener); + }); +}); diff --git a/dashboard/src/components/AgentProfileDrawer.test.tsx b/dashboard/src/components/AgentProfileDrawer.test.tsx new file mode 100644 index 00000000..e080733b --- /dev/null +++ b/dashboard/src/components/AgentProfileDrawer.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { I18nextProvider } from "react-i18next"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import i18n from "../i18n"; +import type { OctopAgent } from "../context/AgentContext"; + +vi.mock("../api/request", () => ({ + request: vi.fn(async (path: string) => { + if (path.endsWith("/skills")) { + return [{ slug: "demo-skill", name: "Demo skill", kind: "workspace" }]; + } + return { name: "Demo agent", description: "Description" }; + }), +})); + +vi.mock("../api/modules/subagents", () => ({ + listAgentSubagents: vi.fn(async () => [ + { + slug: "demo-subagent", + name: "Demo subagent", + description: "Subagent description", + }, + ]), +})); + +vi.mock("../pages/Experts/components/expertFileGroups", () => ({ + fetchConfigMdFiles: vi.fn(async () => ["SOUL.md"]), +})); + +vi.mock("../pages/Agent/Skills/skillDisplayNames", () => ({ + useSkillDisplayName: () => (skill: { name: string }) => skill.name, +})); + +vi.mock("../pages/Experts/components/SkillCatalogDrawer", () => ({ + default: () => null, +})); + +vi.mock("../pages/Experts/components/SubagentCatalogDrawer", () => ({ + default: () => null, +})); + +vi.mock("../pages/Agent/Workspace/components/WorkspaceDrawer", () => ({ + default: () => null, +})); + +import AgentProfileDrawer from "./AgentProfileDrawer"; + +const agent = { + agent_id: "agent-1", + name: "Demo agent", + state: "running", +} as OctopAgent; + +describe("AgentProfileDrawer sections", () => { + const getComputedStyle = window.getComputedStyle; + + beforeAll(() => { + vi.spyOn(window, "getComputedStyle").mockImplementation((element) => + getComputedStyle(element), + ); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it("allows config, skill, and subagent sections to collapse independently", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("SOUL.md")).toBeInTheDocument(); + expect(screen.getByText("Demo skill")).toBeInTheDocument(); + expect(screen.getByText("Demo subagent")).toBeInTheDocument(); + }); + + const configHeader = screen + .getByText("experts.configFiles") + .closest(".ant-collapse-header"); + const skillHeader = screen + .getByText("experts.skillFilesTitle") + .closest(".ant-collapse-header"); + const subagentHeader = screen + .getByText("experts.subagentFilesTitle") + .closest(".ant-collapse-header"); + + expect(configHeader).not.toBeNull(); + expect(skillHeader).not.toBeNull(); + expect(subagentHeader).not.toBeNull(); + + fireEvent.click(configHeader!); + expect(configHeader).toHaveAttribute("aria-expanded", "false"); + expect(skillHeader).toHaveAttribute("aria-expanded", "true"); + expect(subagentHeader).toHaveAttribute("aria-expanded", "true"); + }); +}); diff --git a/dashboard/src/components/AgentProfileDrawer.tsx b/dashboard/src/components/AgentProfileDrawer.tsx index 7977859f..9876d5ae 100644 --- a/dashboard/src/components/AgentProfileDrawer.tsx +++ b/dashboard/src/components/AgentProfileDrawer.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Drawer, Spin, Tag } from "antd"; +import { Collapse, Drawer, Spin, Tag } from "antd"; import { ChevronLeft } from "lucide-react"; import { request } from "../api/request"; import { withFromWorkspace } from "../utils/fromWorkspace"; @@ -15,6 +15,7 @@ import { fetchConfigMdFiles } from "../pages/Experts/components/expertFileGroups import { useSkillDisplayName } from "../pages/Agent/Skills/skillDisplayNames"; import SubagentCatalogDrawer from "../pages/Experts/components/SubagentCatalogDrawer"; import SkillCatalogDrawer from "../pages/Experts/components/SkillCatalogDrawer"; +import WorkspaceDrawer from "../pages/Agent/Workspace/components/WorkspaceDrawer"; import expertStyles from "../pages/Experts/index.module.less"; import styles from "./AgentProfileDrawer.module.less"; @@ -61,6 +62,7 @@ export default function AgentProfileDrawer({ const [fileLoading, setFileLoading] = useState(false); const [skillCatalogOpen, setSkillCatalogOpen] = useState(false); const [subagentCatalogOpen, setSubagentCatalogOpen] = useState(false); + const [workspaceDrawerOpen, setWorkspaceDrawerOpen] = useState(false); const installedSlugs = useMemo( () => new Set(subagents.map((s) => s.slug)), @@ -139,6 +141,7 @@ export default function AgentProfileDrawer({ setFileContent(""); setSkillCatalogOpen(false); setSubagentCatalogOpen(false); + setWorkspaceDrawerOpen(false); } }, [open]); @@ -244,190 +247,268 @@ export default function AgentProfileDrawer({

-
- {t("experts.configFiles", { count: workspaceFiles.length })} -
-
- {filesLoading ? ( -
- -
- ) : workspaceFiles.length === 0 ? ( -
- {t("experts.noWorkspaceFiles")} -
- ) : ( - workspaceFiles.map((file) => { - const basename = displayName(file); - const meta = metaForFile(basename, t); - return ( -
+ +
+ {t("experts.configFiles", { + count: workspaceFiles.length, + })} +
+
+ ), + children: ( +
+ {filesLoading ? (
- {meta.icon} +
-
-
- {meta.label} -
-
{basename}
+ ) : workspaceFiles.length === 0 ? ( +
+ {t("experts.noWorkspaceFiles")}
- - {t("common.view")} - - + ) : ( + workspaceFiles.map((file) => { + const basename = displayName(file); + const meta = metaForFile(basename, t); + return ( +
+ +
+ ); + }) + )}
- ); - }) - )} -
+ ), + }, + ]} + />
-
-
- {t("experts.skillFilesTitle", { count: agentSkills.length })} -
- -
-

- {t("experts.skillFilesHint")} -

-
- {agentSkills.length === 0 ? ( -
- {t("experts.noSkillFiles")} -
- ) : ( - agentSkills.map((skill) => ( -
-
-
-
- {skillDisplayName(skill)} -
- {skill.description ? ( -
- {skill.description} -
- ) : null} + +
+ {t("experts.skillFilesTitle", { + count: agentSkills.length, + })}
- - {skill.enabled === false - ? t("common.disabled") - : t("common.enabled")} - +
-
- )) - )} -
+ ), + children: ( + <> +

+ {t("experts.skillFilesHint")} +

+
+ {agentSkills.length === 0 ? ( +
+ {t("experts.noSkillFiles")} +
+ ) : ( + agentSkills.map((skill) => ( +
+
+
+
+ {skillDisplayName(skill)} +
+ {skill.description ? ( +
+ {skill.description} +
+ ) : null} +
+ + {skill.enabled === false + ? t("common.disabled") + : t("common.enabled")} + +
+
+ )) + )} +
+ + ), + }, + ]} + />
-
-
- {t("experts.subagentFilesTitle", { count: subagents.length })} -
- -
-

- {t("experts.subagentFilesHint")} -

-
- {subagents.length === 0 ? ( -
- {t("experts.noSubagentFiles")} -
- ) : ( - subagents.map((sub) => ( -
-
-
-
- {sub.emoji ? ( - {sub.emoji} - ) : null} - {sub.name} -
- {sub.description ? ( -
- {sub.description} -
- ) : null} + +
+ {t("experts.subagentFilesTitle", { + count: subagents.length, + })}
- { + event.stopPropagation(); + setSubagentCatalogOpen(true); + }} > - {sub.slug} - + {t("common.viewMore")} +
-
- )) - )} -
+ ), + children: ( + <> +

+ {t("experts.subagentFilesHint")} +

+
+ {subagents.length === 0 ? ( +
+ {t("experts.noSubagentFiles")} +
+ ) : ( + subagents.map((sub) => ( +
+
+
+
+ {sub.emoji ? ( + + {sub.emoji} + + ) : null} + {sub.name} +
+ {sub.description ? ( +
+ {sub.description} +
+ ) : null} +
+ + {sub.slug} + +
+
+ )) + )} +
+ + ), + }, + ]} + />
); @@ -477,6 +558,11 @@ export default function AgentProfileDrawer({ onClose={() => setSubagentCatalogOpen(false)} onInstalled={() => void reloadSubagents()} /> + setWorkspaceDrawerOpen(false)} + /> ); } @@ -516,6 +602,11 @@ export default function AgentProfileDrawer({ onClose={() => setSubagentCatalogOpen(false)} onInstalled={() => void reloadSubagents()} /> + setWorkspaceDrawerOpen(false)} + /> ); } diff --git a/dashboard/src/components/BrowserViewer/index.module.less b/dashboard/src/components/BrowserViewer/index.module.less index e8070bc1..d5ccba28 100644 --- a/dashboard/src/components/BrowserViewer/index.module.less +++ b/dashboard/src/components/BrowserViewer/index.module.less @@ -9,82 +9,6 @@ background: var(--fn-bg-primary); } -// ---------- Tab bar ---------- -.tabBar { - display: flex; - align-items: center; - gap: 2px; - background: var(--fn-bg-secondary); - border-bottom: 1px solid var(--fn-border-secondary); - padding: 4px 4px 0; - overflow-x: auto; - scrollbar-width: none; -} - -.tab { - display: flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - border-radius: 4px 4px 0 0; - cursor: pointer; - white-space: nowrap; - max-width: 200px; - font-size: 12px; - color: var(--fn-text-secondary); - border-top: 2px solid transparent; - user-select: none; - - &:hover { - color: var(--fn-text-primary); - } -} - -.tabActive { - background: var(--fn-bg-primary); - border-top: 2px solid var(--fn-color-brand, #635bff); - color: var(--fn-text-primary); -} - -.tabLabel { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -.tabClose { - display: flex; - align-items: center; - opacity: 0.5; - border-radius: 3px; - padding: 1px 2px; - flex-shrink: 0; - - &:hover { - opacity: 1; - background: var(--fn-border-secondary); - } -} - -.tabNew { - display: flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - cursor: pointer; - color: var(--fn-text-tertiary); - padding: 4px 6px; - border-radius: 4px; - flex-shrink: 0; - - &:hover { - color: var(--fn-text-primary); - background: var(--fn-bg-primary); - } -} - // ---------- Address bar ---------- .addressBar { display: flex; diff --git a/dashboard/src/components/BrowserViewer/index.tsx b/dashboard/src/components/BrowserViewer/index.tsx index 553dd496..de6452fe 100644 --- a/dashboard/src/components/BrowserViewer/index.tsx +++ b/dashboard/src/components/BrowserViewer/index.tsx @@ -13,10 +13,8 @@ import { ArrowLeft, ArrowRight, Globe, - Plus, RotateCcw, Star, - X, AlertTriangle, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -28,6 +26,7 @@ import type { import { useBrowserCanvasInteraction } from "../../hooks/useBrowserCanvasInteraction"; import { paintBase64JpegToCanvas } from "../../utils/browserCanvas"; import { normalizeUrl } from "../../utils/normalizeUrl"; +import { ChromeTabBar } from "../ChromeTabBar"; import StreamConnectingIndicator from "../StreamConnectingIndicator"; import styles from "./index.module.less"; @@ -182,45 +181,35 @@ export const BrowserViewer = forwardRef< [handleGo], ); + const activeKey = + tabs.find((tab) => tab.active)?.id != null + ? String(tabs.find((tab) => tab.active)!.id) + : tabs[0] + ? String(tabs[0].id) + : undefined; + return (
- {/* Tab bar */} -
- {tabs.map((tab) => ( - -
switchTab(tab.id)} - > - - - {tab.title || tab.url || tab.id} - - {tabs.length > 1 && ( - { - e.stopPropagation(); - closeTab(tab.id); - }} - title={t("browserViewer.closeTab")} - > - - - )} -
-
- ))} - -
newTab()} - title={t("browserViewer.newTab")} - > - -
-
-
+ ({ + key: String(tab.id), + label: tab.title || tab.url || tab.id, + leading: , + tooltip: tab.url, + closable: tabs.length > 1, + }))} + activeKey={activeKey} + onChange={(key) => { + const match = tabs.find((tab) => String(tab.id) === key); + if (match) switchTab(match.id); + }} + onClose={(key) => { + const match = tabs.find((tab) => String(tab.id) === key); + if (match) closeTab(match.id); + }} + onNewTab={() => newTab()} + newTabTitle={t("browserViewer.newTab")} + /> {/* Address bar */}
diff --git a/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less b/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less index c4738462..2b96a808 100644 --- a/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less +++ b/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less @@ -139,7 +139,7 @@ padding: 0 12px 0 16px; border-bottom: none; background: var(--fn-bg-primary); - color: var(--fn-text-primary); + color: var(--chat-title-color, var(--fn-text-primary)); overflow: hidden; z-index: 2; cursor: grab; @@ -170,8 +170,16 @@ max-width: none; overflow: hidden; font-size: 14px; - font-weight: 500; - color: var(--fn-text-primary); + font-weight: 400; + line-height: 1.3; + /* Same token as chat title + selected dock tab. */ + color: var(--chat-title-color, var(--fn-text-primary)); + + :global(svg) { + color: inherit; + stroke: currentColor; + flex-shrink: 0; + } /* File switch Select; App prefixCls="octop", keep ant-* fallback. */ :global(.octop-select .octop-select-selector), @@ -185,6 +193,13 @@ background: transparent !important; box-shadow: none !important; padding-inline: 0 22px !important; + color: inherit !important; + } + + :global(.octop-select-selection-item), + :global(.ant-select-selection-item) { + color: inherit !important; + font-weight: 400 !important; } :global(.octop-select-sm), diff --git a/dashboard/src/components/BrowserWorkspace/index.module.less b/dashboard/src/components/BrowserWorkspace/index.module.less index d88caf49..33e4eefb 100644 --- a/dashboard/src/components/BrowserWorkspace/index.module.less +++ b/dashboard/src/components/BrowserWorkspace/index.module.less @@ -352,104 +352,6 @@ } } -/* Tab bar for multi-tab support */ -.tabBar { - display: flex; - align-items: stretch; - background: var(--fn-bg-secondary, #f5f5f5); - border-bottom: 1px solid var(--fn-border-secondary, #e0e0e0); - flex-shrink: 0; - overflow-x: auto; - min-height: 32px; - - &::-webkit-scrollbar { - height: 2px; - } - - &::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); - border-radius: 1px; - } -} - -.tab { - display: flex; - align-items: center; - gap: 4px; - padding: 4px 12px; - font-size: 12px; - color: var(--fn-text-tertiary, #888); - border-right: 1px solid var(--fn-border-secondary, #e0e0e0); - cursor: pointer; - white-space: nowrap; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - transition: - background 0.15s, - color 0.15s; - flex-shrink: 0; - - &:hover { - background: var(--fn-bg-primary, #fff); - color: var(--fn-text-primary, #333); - } -} - -.tabActive { - background: var(--fn-bg-primary, #fff); - color: var(--fn-text-primary, #333); - font-weight: 500; - border-bottom: 2px solid #1890ff; -} - -.tabLabel { - overflow: hidden; - text-overflow: ellipsis; -} - -.tabClose { - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border-radius: 3px; - font-size: 13px; - line-height: 1; - color: var(--fn-text-quaternary, #aaa); - cursor: pointer; - flex-shrink: 0; - transition: - background 0.15s, - color 0.15s; - - &:hover { - background: rgba(0, 0, 0, 0.1); - color: var(--fn-text-primary, #333); - } -} - -.tabNew { - display: flex; - align-items: center; - justify-content: center; - padding: 4px 10px; - font-size: 16px; - font-weight: 300; - color: var(--fn-text-quaternary, #aaa); - cursor: pointer; - flex-shrink: 0; - transition: - background 0.15s, - color 0.15s; - - &:hover { - background: var(--fn-bg-primary, #fff); - color: var(--fn-text-primary, #333); - } -} - /* Popup overlay backdrop */ .popupOverlay { position: fixed; diff --git a/dashboard/src/components/ChromeTabBar/index.module.less b/dashboard/src/components/ChromeTabBar/index.module.less new file mode 100644 index 00000000..fcd4aaac --- /dev/null +++ b/dashboard/src/components/ChromeTabBar/index.module.less @@ -0,0 +1,111 @@ +/* Shared tokens for chrome-style session tabs (browser + terminal). */ +:global(:root) { + --browser-chrome-tab-font-size: 12px; + --browser-chrome-tab-line-height: 16px; + --browser-chrome-tab-pad: 4px 10px; + --browser-chrome-tab-radius: 4px 4px 0 0; + --browser-chrome-tab-gap: 2px; + --browser-chrome-tab-bar-pad: 4px 4px 0; + --browser-chrome-tab-accent: var(--fn-color-brand, #635bff); +} + +.tabBar { + display: flex; + align-items: center; + gap: var(--browser-chrome-tab-gap); + background: var(--fn-bg-secondary); + border-bottom: 1px solid var(--fn-border-secondary); + padding: var(--browser-chrome-tab-bar-pad); + flex-shrink: 0; + min-width: 0; +} + +.tabsScroll { + display: flex; + align-items: center; + gap: var(--browser-chrome-tab-gap); + flex: 1; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.tab { + display: flex; + align-items: center; + gap: 4px; + padding: var(--browser-chrome-tab-pad); + border-radius: var(--browser-chrome-tab-radius); + cursor: pointer; + white-space: nowrap; + max-width: 200px; + font-size: var(--browser-chrome-tab-font-size); + line-height: var(--browser-chrome-tab-line-height); + color: var(--fn-text-secondary); + border-top: 2px solid transparent; + user-select: none; + flex-shrink: 0; + box-sizing: border-box; + + &:hover { + color: var(--fn-text-primary); + } +} + +.tabActive { + background: var(--fn-bg-primary); + border-top: 2px solid var(--browser-chrome-tab-accent); + color: var(--fn-text-primary); +} + +.tabLabel { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.tabClose { + display: flex; + align-items: center; + opacity: 0.5; + border-radius: 3px; + padding: 1px 2px; + flex-shrink: 0; + line-height: 1; + + &:hover { + opacity: 1; + background: var(--fn-border-secondary); + } +} + +.tabNew { + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + color: var(--fn-text-tertiary); + padding: 4px 6px; + border-radius: 4px; + flex-shrink: 0; + + &:hover { + color: var(--fn-text-primary); + background: var(--fn-bg-primary); + } +} + +.trailing { + display: flex; + align-items: center; + flex-shrink: 0; + padding: 0 2px 2px 0; + gap: 4px; +} diff --git a/dashboard/src/components/ChromeTabBar/index.tsx b/dashboard/src/components/ChromeTabBar/index.tsx new file mode 100644 index 00000000..2d7e542e --- /dev/null +++ b/dashboard/src/components/ChromeTabBar/index.tsx @@ -0,0 +1,108 @@ +import type { MouseEvent, ReactNode } from "react"; +import { Tooltip } from "antd"; +import { Plus, X } from "lucide-react"; +import styles from "./index.module.less"; + +export type ChromeTabItem = { + key: string; + /** Primary label (text or custom node). Truncated via CSS when long. */ + label: ReactNode; + /** Optional leading icon / status badge. */ + leading?: ReactNode; + /** Tooltip on hover (e.g. full URL). */ + tooltip?: ReactNode; + closable?: boolean; +}; + +export type ChromeTabBarProps = { + tabs: ChromeTabItem[]; + activeKey?: string; + onChange: (key: string) => void; + onClose?: (key: string, e: MouseEvent) => void; + onNewTab?: () => void; + newTabTitle?: string; + /** Right-side actions (theme, AI panel, …). */ + trailing?: ReactNode; + className?: string; +}; + +/** + * Chrome-style session tab bar shared by the remote browser viewer and + * terminal workbench so tab chips stay pixel-aligned. + */ +export function ChromeTabBar({ + tabs, + activeKey, + onChange, + onClose, + onNewTab, + newTabTitle, + trailing, + className, +}: ChromeTabBarProps) { + return ( +
+
+ {tabs.map((tab) => { + const selected = tab.key === activeKey; + const node = ( +
onChange(tab.key)} + > + {tab.leading} + {tab.label} + {tab.closable && onClose ? ( + { + e.stopPropagation(); + onClose(tab.key, e); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + onClose(tab.key, e as unknown as MouseEvent); + } + }} + > + + + ) : null} +
+ ); + + if (tab.tooltip != null && tab.tooltip !== "") { + return ( + + {node} + + ); + } + return {node}; + })} + {onNewTab ? ( + + + + ) : null} +
+ {trailing ?
{trailing}
: null} +
+ ); +} diff --git a/dashboard/src/components/ErrorBoundary/index.test.tsx b/dashboard/src/components/ErrorBoundary/index.test.tsx new file mode 100644 index 00000000..b8d46c8f --- /dev/null +++ b/dashboard/src/components/ErrorBoundary/index.test.tsx @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import GlobalErrorBoundary from "."; + +const { tryReloadOnStaleChunk } = vi.hoisted(() => ({ + tryReloadOnStaleChunk: vi.fn(), +})); + +vi.mock("../../utils/reloadOnStaleChunk", async (importOriginal) => { + const actual = await importOriginal< + typeof import("../../utils/reloadOnStaleChunk") + >(); + return { ...actual, tryReloadOnStaleChunk }; +}); + +function Boom() { + throw new Error("Failed to fetch dynamically imported module: /assets/a.js"); +} + +function renderBoundary() { + vi.spyOn(console, "error").mockImplementation(() => {}); + return render( + + + , + ); +} + +describe("GlobalErrorBoundary chunk failures", () => { + beforeEach(() => { + vi.restoreAllMocks(); + tryReloadOnStaleChunk.mockReset(); + }); + + it("renders nothing while a recovery reload is under way", () => { + tryReloadOnStaleChunk.mockReturnValue(true); + + const { container } = renderBoundary(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("offers a way out when no recovery reload will happen", () => { + tryReloadOnStaleChunk.mockReturnValue(false); + + renderBoundary(); + + expect(screen.getByRole("button", { name: /reload/i })).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/components/ErrorBoundary/index.tsx b/dashboard/src/components/ErrorBoundary/index.tsx index ed26f332..61766262 100644 --- a/dashboard/src/components/ErrorBoundary/index.tsx +++ b/dashboard/src/components/ErrorBoundary/index.tsx @@ -13,6 +13,8 @@ interface State { hasError: boolean; error: Error | null; retryCount: number; + /** ``null`` until componentDidCatch has decided whether a reload is coming. */ + chunkReloading: boolean | null; } const MAX_RETRIES = 3; @@ -22,14 +24,23 @@ const MAX_RETRIES = 3; * and displays a friendly fallback UI with retry / home actions. */ export default class GlobalErrorBoundary extends Component { - state: State = { hasError: false, error: null, retryCount: 0 }; + state: State = { + hasError: false, + error: null, + retryCount: 0, + chunkReloading: null, + }; static getDerivedStateFromError(error: Error): Partial { - return { hasError: true, error }; + return { hasError: true, error, chunkReloading: null }; } componentDidCatch(error: Error, info: React.ErrorInfo) { - if (tryReloadOnStaleChunk(error)) return; + if (tryReloadOnStaleChunk(error)) { + this.setState({ chunkReloading: true }); + return; + } + this.setState({ chunkReloading: false }); console.error("[GlobalErrorBoundary]", error, info.componentStack); } @@ -38,6 +49,7 @@ export default class GlobalErrorBoundary extends Component { this.setState((prev) => ({ hasError: false, error: null, + chunkReloading: null, retryCount: prev.retryCount + 1, })); }; @@ -46,13 +58,18 @@ export default class GlobalErrorBoundary extends Component { window.location.href = "/chat"; }; + handleReload = () => { + window.location.reload(); + }; + render() { if (this.state.hasError) { + const isChunkError = isChunkLoadError(this.state.error); // Stale chunk → silent soft reload; avoid flashing the error page. - if (isChunkLoadError(this.state.error)) { + if (isChunkError && this.state.chunkReloading !== false) { return null; } - const canRetry = this.state.retryCount < MAX_RETRIES; + const canRetry = !isChunkError && this.state.retryCount < MAX_RETRIES; return (
{ > + {isChunkError && ( + + )} {canRetry && ( + ); + return ( <> - - + + {chatFloat ? ( + {button} + ) : ( + button + )} {ios && showIosGuide && } diff --git a/dashboard/src/hooks/useUnauthorizedRedirect.test.tsx b/dashboard/src/hooks/useUnauthorizedRedirect.test.tsx new file mode 100644 index 00000000..f1c67576 --- /dev/null +++ b/dashboard/src/hooks/useUnauthorizedRedirect.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { UNAUTHORIZED_EVENT } from "../api/request"; +import { useUnauthorizedRedirect } from "./useUnauthorizedRedirect"; + +function Harness() { + useUnauthorizedRedirect(); + return ( + + chat screen
} /> + login screen
} /> + + ); +} + +function dispatchUnauthorized(): Event { + const event = new CustomEvent(UNAUTHORIZED_EVENT, { cancelable: true }); + act(() => { + window.dispatchEvent(event); + }); + return event; +} + +describe("useUnauthorizedRedirect", () => { + it("routes to /login inside the SPA and cancels the hard navigation", () => { + render( + + + , + ); + expect(screen.getByText("chat screen")).toBeInTheDocument(); + + const event = dispatchUnauthorized(); + + expect(event.defaultPrevented).toBe(true); + expect(screen.getByText("login screen")).toBeInTheDocument(); + }); + + it("stops handling the event after unmount", () => { + const { unmount } = render( + + + , + ); + unmount(); + + expect(dispatchUnauthorized().defaultPrevented).toBe(false); + }); +}); diff --git a/dashboard/src/hooks/useUnauthorizedRedirect.ts b/dashboard/src/hooks/useUnauthorizedRedirect.ts new file mode 100644 index 00000000..73ed31f2 --- /dev/null +++ b/dashboard/src/hooks/useUnauthorizedRedirect.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { UNAUTHORIZED_EVENT } from "../api/request"; + +/** + * Route an expired session to /login through the router. + * + * Claiming the event (``preventDefault``) keeps ``request.ts`` from doing a + * full-page navigation, which would abort every in-flight lazy chunk and leave + * the user staring at a blank page. + */ +export function useUnauthorizedRedirect(): void { + const navigate = useNavigate(); + + useEffect(() => { + const handler = (event: Event) => { + event.preventDefault(); + navigate("/login", { replace: true }); + }; + window.addEventListener(UNAUTHORIZED_EVENT, handler); + return () => window.removeEventListener(UNAUTHORIZED_EVENT, handler); + }, [navigate]); +} diff --git a/dashboard/src/layouts/MainLayout/index.tsx b/dashboard/src/layouts/MainLayout/index.tsx index 80a61ca2..6b501d16 100644 --- a/dashboard/src/layouts/MainLayout/index.tsx +++ b/dashboard/src/layouts/MainLayout/index.tsx @@ -14,6 +14,7 @@ import { MOBILE_FULLSCREEN_PATHS, SELF_HEADER_PATHS, isWorkbenchPath, + isControlAdminPath, } from "../../routes"; import { CHAT_HISTORY_RAIL_ID, isChatPath } from "../chatHistoryRail"; import { useIsMobile } from "../../hooks/useIsMobile"; @@ -158,7 +159,7 @@ export default function MainLayout() { {routeConfigs.map((rc) => { let el = rc.useWrapper ? : rc.element; - if (rc.path.startsWith("/admin/")) { + if (rc.path.startsWith("/admin/") || isControlAdminPath(rc.path)) { el = {el}; } return ; @@ -294,22 +295,24 @@ export default function MainLayout() { flexDirection: "column", }} > - - -
- } - > - - + + + +
+ } + > + + +
)} diff --git a/dashboard/src/layouts/Sidebar.module.less b/dashboard/src/layouts/Sidebar.module.less index 9180f77d..4637c9c2 100644 --- a/dashboard/src/layouts/Sidebar.module.less +++ b/dashboard/src/layouts/Sidebar.module.less @@ -58,6 +58,54 @@ overflow: hidden; } +.navItemRow { + display: flex; + align-items: stretch; + width: 100%; + margin-bottom: 2px; + border-radius: var(--fn-radius-md); + transition: background var(--fn-transition-fast); +} + +.navItemMain { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + padding: 0 12px; + height: 40px; + border: none; + border-radius: var(--fn-radius-md); + background: transparent; + cursor: pointer; + text-align: left; + transition: color var(--fn-transition-fast); +} + +.chatRailExpandBtn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 28px; + margin: 6px 6px 6px 0; + border: none; + border-radius: var(--fn-radius-md); + background: transparent; + color: var(--fn-text-tertiary); + cursor: pointer; + transition: + background var(--fn-transition-fast), + color var(--fn-transition-fast); + + &:hover { + background: color-mix(in srgb, var(--fn-text-primary) 8%, transparent); + color: var(--fn-text-primary); + } +} + .navUpdateBadge { font-size: 9px; font-weight: 600; diff --git a/dashboard/src/layouts/Sidebar.tsx b/dashboard/src/layouts/Sidebar.tsx index fac40a63..7e9b25dc 100644 --- a/dashboard/src/layouts/Sidebar.tsx +++ b/dashboard/src/layouts/Sidebar.tsx @@ -10,6 +10,7 @@ import { Monitor, MessageSquareText, Timer, + ArrowRightLeft, SlidersHorizontal, X, Waypoints, @@ -33,6 +34,8 @@ import { useUpdateStatus } from "../hooks/useUpdateStatus"; import { authApi } from "../api/modules/auth"; import type { OctopUser } from "../api/modules/auth"; import { prefetchRoute } from "../routes/prefetch"; +import { useChatSidebarOpen } from "../pages/Chat/hooks/useChatSidebarState"; +import { EXPAND_CHAT_RAIL_EVENT } from "../pages/Chat/components/ChatSidebarPanel"; import styles from "./Sidebar.module.less"; import { typeSize } from "../utils/mobileTypeScale"; @@ -152,7 +155,7 @@ function buildNavSections(role: "admin" | "user" | null): NavSection[] { ], }, { - groupKey: "nav.control", + groupKey: "nav.settings", items: [ { key: "personalization", @@ -178,6 +181,14 @@ function buildNavSections(role: "admin" | "user" | null): NavSection[] { icon: , labelKey: "nav.skillPackages", }, + ], + }, + ]; + + if (role === "admin") { + sections.push({ + groupKey: "nav.control", + items: [ { key: "workbench", path: "/workbench", @@ -197,10 +208,7 @@ function buildNavSections(role: "admin" | "user" | null): NavSection[] { labelKey: "nav.acp", }, ], - }, - ]; - - if (role === "admin") { + }); sections.push({ groupKey: "nav.admin", items: [ @@ -258,6 +266,8 @@ function NavItemButton({ active, isMobile, onNavigate, + onExpandChatRail, + showChatRailExpand, role, hasUpdate, t, @@ -266,110 +276,129 @@ function NavItemButton({ active: boolean; isMobile?: boolean; onNavigate: (path: string) => void; + onExpandChatRail?: () => void; + showChatRailExpand?: boolean; role: "admin" | "user" | null; hasUpdate: boolean; t: TFunction<"translation", undefined>; }) { + const showExpand = Boolean( + showChatRailExpand && item.key === "chat" && onExpandChatRail, + ); + return ( - + + {item.icon} + + + {t(item.labelKey)} + {item.key === "admin-advanced" && role === "admin" && hasUpdate ? ( + + {t("nav.newVersionBadge", "有新版本")} + + ) : null} + {item.badge && ( + + {item.badge} + + )} + + + {showExpand ? ( + + ) : null} +
); } function NavList({ selectedKey, onNavigate, + onExpandChatRail, + showChatRailExpand, isMobile, isGroupCollapsed, toggleGroup, }: { selectedKey: string; onNavigate: (path: string) => void; + onExpandChatRail?: () => void; + showChatRailExpand?: boolean; isMobile?: boolean; isGroupCollapsed: (groupKey: string) => boolean; toggleGroup: (groupKey: string) => void; @@ -406,6 +435,8 @@ function NavList({ active={selectedKey === item.key} isMobile={isMobile} onNavigate={onNavigate} + onExpandChatRail={onExpandChatRail} + showChatRailExpand={showChatRailExpand} role={role} hasUpdate={hasUpdate} t={t} @@ -446,6 +477,8 @@ function NavList({ active={selectedKey === item.key} isMobile={isMobile} onNavigate={onNavigate} + onExpandChatRail={onExpandChatRail} + showChatRailExpand={showChatRailExpand} role={role} hasUpdate={hasUpdate} t={t} @@ -477,6 +510,8 @@ export default function Sidebar({ selectedKey, ); const [user, setUser] = useState(null); + const [chatSidebarOpen, setChatSidebarOpen] = useChatSidebarOpen(); + const showChatRailExpand = !chatSidebarOpen; useEffect(() => { authApi @@ -500,6 +535,15 @@ export default function Sidebar({ if (isMobile) onToggle(); }; + const handleExpandChatRail = useCallback(() => { + window.dispatchEvent(new Event(EXPAND_CHAT_RAIL_EVENT)); + setChatSidebarOpen(true); + if (!window.location.pathname.startsWith("/chat")) { + navigate("/chat"); + } + if (isMobile) onToggle(); + }, [isMobile, navigate, onToggle, setChatSidebarOpen]); + const brandInner = ( <> (); + +function hasRecentQrAutoSave(key: string): boolean { + const at = qrAutoSavedAt.get(key); + if (at == null) return false; + if (Date.now() - at > QR_AUTOSAVE_TTL_MS) { + qrAutoSavedAt.delete(key); + return false; + } + return true; +} + +function markQrAutoSave(key: string) { + qrAutoSavedAt.set(key, Date.now()); + if (qrAutoSavedAt.size <= 64) return; + const cutoff = Date.now() - QR_AUTOSAVE_TTL_MS; + for (const [k, t] of qrAutoSavedAt) { + if (t < cutoff) qrAutoSavedAt.delete(k); + } +} + +function clearQrAutoSave(key: string) { + qrAutoSavedAt.delete(key); +} + export interface ChannelFormValues { kind: ChannelKey; name?: string; @@ -495,8 +522,13 @@ export function ChannelDrawer({ kind: ChannelKey, name: string, config: Record, + /** When set, overrides the form enabled switch (QR bind always enables). */ + enabledOverride?: boolean, ): Promise => { - const enabled = form.getFieldValue("enabled") ?? false; + const enabled = + enabledOverride !== undefined + ? enabledOverride + : form.getFieldValue("enabled") ?? false; const ok = await onSubmit(kind, name, config, enabled); if (ok) clearFormDraft(draftScope); return ok; @@ -504,7 +536,7 @@ export function ChannelDrawer({ [form, onSubmit, draftScope], ); - // Auto-save when QR quick-config completes + // Auto-save when QR quick-config completes — channel is usable immediately. useEffect(() => { if (isEdit || saving || autoSaveTriggeredRef.current) return; @@ -514,14 +546,17 @@ export function ChannelDrawer({ name: string; config: Record; } | null = null; + let dedupeKey: string | null = null; if (s.phase === "wecom_success") { + dedupeKey = `wecom:${s.botId}:${s.secret}`; payload = { kind: "wecom", name: "wecom", config: mergeDisplayConfig({ bot_id: s.botId, secret: s.secret }), }; } else if (s.phase === "weixin_success") { + dedupeKey = `weixin:${s.accountId}:${s.token}`; payload = { kind: "weixin", name: "weixin", @@ -540,6 +575,7 @@ export function ChannelDrawer({ }), }; } else if (s.phase === "feishu_done") { + dedupeKey = `feishu:${s.appId}:${s.appSecret}`; payload = { kind: "feishu", name: "feishu", @@ -549,6 +585,7 @@ export function ChannelDrawer({ }), }; } else if (s.phase === "yuanbao_done") { + dedupeKey = `yuanbao:${s.appKey}:${s.appSecret}`; payload = { kind: "yuanbao", name: "yuanbao", @@ -561,19 +598,25 @@ export function ChannelDrawer({ }; } - if (!payload) return; + if (!payload || !dedupeKey) return; + if (hasRecentQrAutoSave(dedupeKey)) return; + markQrAutoSave(dedupeKey); autoSaveTriggeredRef.current = true; setAutoSaveFailed(false); - void submitChannel(payload.kind, payload.name, payload.config).then( + // QR bind success → enable channel so messages start flowing without a second toggle. + void submitChannel(payload.kind, payload.name, payload.config, true).then( (ok) => { if (!ok) { autoSaveTriggeredRef.current = false; + clearQrAutoSave(dedupeKey); setAutoSaveFailed(true); + } else { + form.setFieldValue("enabled", true); } }, ); - }, [qrState, isEdit, saving, submitChannel, mergeDisplayConfig]); + }, [qrState, isEdit, saving, submitChannel, mergeDisplayConfig, form]); const showFooterSave = configMode === "manual" || isEdit || !supportsQuickConfig; @@ -677,6 +720,7 @@ export function ChannelDrawer({ "wecom", "wecom", mergeDisplayConfig({ bot_id: s.botId, secret: s.secret }), + true, ); return (
@@ -780,7 +824,8 @@ export function ChannelDrawer({ token: s.token, base_url: s.baseUrl || "https://ilinkai.weixin.qq.com", }); - const retrySave = () => submitChannel("weixin", "weixin", weixinConfig); + const retrySave = () => + submitChannel("weixin", "weixin", weixinConfig, true); return (
@@ -1030,6 +1076,7 @@ export function ChannelDrawer({ api_domain: YUANBAO_DEFAULT_API_DOMAIN, ws_url: YUANBAO_DEFAULT_WS_URL, }), + true, ); return (
diff --git a/dashboard/src/pages/Agent/Channels/useChannels.ts b/dashboard/src/pages/Agent/Channels/useChannels.ts index 5e09c9f1..6ff418df 100644 --- a/dashboard/src/pages/Agent/Channels/useChannels.ts +++ b/dashboard/src/pages/Agent/Channels/useChannels.ts @@ -98,7 +98,6 @@ export function useChannels(agentId: string | null) { }, ); setChannels((prev) => [...prev, created]); - message.success(t("channels.createdSuccess")); return created; } catch (error) { console.error("[Channels] Failed to create channel:", error); diff --git a/dashboard/src/pages/Agent/Workspace/components/FileViewer.tsx b/dashboard/src/pages/Agent/Workspace/components/FileViewer.tsx index a9aa6d54..f192e4f2 100644 --- a/dashboard/src/pages/Agent/Workspace/components/FileViewer.tsx +++ b/dashboard/src/pages/Agent/Workspace/components/FileViewer.tsx @@ -70,6 +70,7 @@ export default function FileViewer({ path={path} kind={mediaKind} refreshToken={refreshToken} + fromWorkspace={fromWorkspace} /> ); } diff --git a/dashboard/src/pages/Agent/Workspace/components/MediaPreview.tsx b/dashboard/src/pages/Agent/Workspace/components/MediaPreview.tsx index 95c4b0e5..e2d4a445 100644 --- a/dashboard/src/pages/Agent/Workspace/components/MediaPreview.tsx +++ b/dashboard/src/pages/Agent/Workspace/components/MediaPreview.tsx @@ -3,7 +3,10 @@ import { Image } from "antd"; import { useTranslation } from "react-i18next"; import { requestBlob } from "../../../../api/request"; import { isNotFoundApiError } from "../../../../utils/apiError"; -import { asImageBlob } from "../../../../utils/toolMediaBlocks"; +import { + asImageBlob, + toMediaPreviewSource, +} from "../../../../utils/toolMediaBlocks"; import type { MediaKind } from "../utils/mediaKind"; import styles from "../index.module.less"; @@ -35,22 +38,17 @@ function useWorkspaceBlob( filename: string, toBlob: (blob: Blob, filename: string) => Blob, refreshToken = 0, + fromWorkspace = true, ) { const [src, setSrc] = useState(""); const objectUrlRef = useRef(undefined); const apiPath = useMemo(() => { - // Pass the path shape through unchanged: absolute stays absolute (as file:// - // for the query string), relative stays relative. - const source = path.startsWith("file://") - ? path - : path.startsWith("/") - ? `file://${path}` - : path.replace(/^\/+/, ""); + const source = toMediaPreviewSource(path, { agentId, fromWorkspace }); return `/agents/${encodeURIComponent( agentId, )}/media/preview?${new URLSearchParams({ source }).toString()}`; - }, [agentId, path]); + }, [agentId, path, fromWorkspace]); useLayoutEffect(() => { let cancelled = false; @@ -100,11 +98,13 @@ function WorkspaceImage({ path, filename, refreshToken = 0, + fromWorkspace = true, }: { agentId: string; path: string; filename: string; refreshToken?: number; + fromWorkspace?: boolean; }) { const { t } = useTranslation(); const src = useWorkspaceBlob( @@ -113,6 +113,7 @@ function WorkspaceImage({ filename, asImageBlob, refreshToken, + fromWorkspace, ); if (src === "missing") { @@ -145,11 +146,13 @@ function WorkspaceVideo({ path, filename, refreshToken = 0, + fromWorkspace = true, }: { agentId: string; path: string; filename: string; refreshToken?: number; + fromWorkspace?: boolean; }) { const { t } = useTranslation(); const toBlob = useMemo( @@ -159,7 +162,14 @@ function WorkspaceVideo({ : new Blob([blob], { type: guessVideoMime(name) }), [], ); - const src = useWorkspaceBlob(agentId, path, filename, toBlob, refreshToken); + const src = useWorkspaceBlob( + agentId, + path, + filename, + toBlob, + refreshToken, + fromWorkspace, + ); if (src === "missing") { return ( @@ -197,11 +207,13 @@ function WorkspaceAudio({ path, filename, refreshToken = 0, + fromWorkspace = true, }: { agentId: string; path: string; filename: string; refreshToken?: number; + fromWorkspace?: boolean; }) { const { t } = useTranslation(); const toBlob = useMemo( @@ -211,7 +223,14 @@ function WorkspaceAudio({ : new Blob([blob], { type: guessAudioMime(name) }), [], ); - const src = useWorkspaceBlob(agentId, path, filename, toBlob, refreshToken); + const src = useWorkspaceBlob( + agentId, + path, + filename, + toBlob, + refreshToken, + fromWorkspace, + ); if (src === "missing") { return ( @@ -248,11 +267,14 @@ export default function MediaPreview({ path, kind, refreshToken = 0, + fromWorkspace = true, }: { agentId: string; path: string; kind: MediaKind; refreshToken?: number; + /** Workspace tree paths use leading ``/`` as workspace-relative keys. */ + fromWorkspace?: boolean; }) { const filename = path.split("/").filter(Boolean).pop() || path; @@ -264,6 +286,7 @@ export default function MediaPreview({ path={path} filename={filename} refreshToken={refreshToken} + fromWorkspace={fromWorkspace} /> ); case "video": @@ -273,6 +296,7 @@ export default function MediaPreview({ path={path} filename={filename} refreshToken={refreshToken} + fromWorkspace={fromWorkspace} /> ); case "audio": @@ -282,6 +306,7 @@ export default function MediaPreview({ path={path} filename={filename} refreshToken={refreshToken} + fromWorkspace={fromWorkspace} /> ); default: diff --git a/dashboard/src/pages/Chat/chatAgentCard.partial.less b/dashboard/src/pages/Chat/chatAgentCard.partial.less index fb1296f1..f7af9cd7 100644 --- a/dashboard/src/pages/Chat/chatAgentCard.partial.less +++ b/dashboard/src/pages/Chat/chatAgentCard.partial.less @@ -282,8 +282,8 @@ .agentRowName { font-size: 13px; - font-weight: 500; - color: var(--fn-text-primary); + font-weight: 400; + color: var(--fn-text-secondary); line-height: 1.35; white-space: nowrap; overflow: hidden; diff --git a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less index 00d1545e..534a8e3f 100644 --- a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less +++ b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less @@ -1,9 +1,15 @@ /* Browser panel layout, status button, resizer — imported by index.module.less */ -/* When the bottom browser panel is visible, the chat area and browser - share vertical space. chatContent must shrink so the browser panel - (which has flex-shrink: 0) keeps its requested height. */ -.chatMainWithBottomPanel { +/* When the bottom dock is open, chatPage becomes a column: chatMain + dock. + chatContent must shrink so the dock keeps its requested height. */ +.chatPageWithBottomDock { + flex-direction: column; + + .chatMain { + flex: 1; + min-height: 0; + } + .chatContent { flex: 1; min-height: 0; @@ -17,6 +23,20 @@ } } +/* Legacy alias — prefer chatPageWithBottomDock on the page shell. */ +.chatMainWithBottomPanel { + .chatContent { + flex: 1; + min-height: 0; + overflow: hidden; + } + + .chatInput { + padding-top: 6px; + padding-bottom: 10px; + } +} + .chatFloatActions { position: absolute; right: 14px; @@ -71,6 +91,10 @@ position: relative; } +.terminalFloatBtn { + .chat-floating-circle-btn(); +} + .filePanelBody { flex: 1; min-height: 0; @@ -90,7 +114,8 @@ .dockTitleLeadingIcon { flex-shrink: 0; - color: var(--fn-text-tertiary); + color: var(--chat-title-color, var(--fn-text-primary)); + opacity: 1; } .dockTabBar { @@ -114,10 +139,17 @@ border-radius: 6px; color: var(--fn-text-secondary); flex-shrink: 0; + + :global(svg) { + color: inherit; + stroke: currentColor; + flex-shrink: 0; + } } .dockTabActive { - color: var(--fn-text-primary); + /* Same ink as chat title / dock toolbar title. */ + color: var(--chat-title-color, var(--fn-text-primary)); background: var(--fn-bg-secondary, rgba(0, 0, 0, 0.04)); } @@ -132,7 +164,8 @@ background: transparent; color: inherit; font-size: 14px; - font-weight: 500; + font-weight: 400; + line-height: 1.3; cursor: pointer; padding: 0; overflow: hidden; @@ -141,6 +174,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: inherit; } } @@ -179,6 +213,14 @@ overflow: hidden; } +.dockTerminalLoading { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; +} + .dockFileList { flex: 1; min-height: 0; @@ -383,8 +425,8 @@ text-overflow: ellipsis; white-space: nowrap; font-size: 16px; - font-weight: 500; - color: var(--fn-text-primary); + font-weight: 400; + color: var(--chat-title-color, var(--fn-text-primary)); } /* App ConfigProvider uses prefixCls="octop"; keep ant-* as fallback. */ @@ -542,6 +584,11 @@ width: 32px; height: 32px; } + + .userMsgActions .msgCopyBtn, + .userMsgActions .msgActionBtn { + opacity: 1; + } } /* ---------- User message edit area ---------- */ diff --git a/dashboard/src/pages/Chat/chatInputCore.partial.less b/dashboard/src/pages/Chat/chatInputCore.partial.less index 602c8bec..e43ca89d 100644 --- a/dashboard/src/pages/Chat/chatInputCore.partial.less +++ b/dashboard/src/pages/Chat/chatInputCore.partial.less @@ -1,7 +1,7 @@ /* Chat input shell */ /* ---------- Chat input ---------- */ .runUsageBar { - max-width: 960px; + max-width: var(--chat-column-max, 960px); width: calc(100% - 48px); margin: 0 auto 6px; padding: 0 2px; @@ -36,7 +36,7 @@ } .aiDisclaimer { - max-width: 960px; + max-width: var(--chat-column-max, 960px); margin: 8px auto 0; padding: 0; font-size: 10px; @@ -46,7 +46,7 @@ } .inputWrapper { - max-width: 960px; + max-width: var(--chat-column-max, 960px); min-width: 280px; margin: 0 auto; display: flex; @@ -502,7 +502,7 @@ /* ---------- Queued messages (while streaming) ---------- */ .queuedMessages { - max-width: 960px; + max-width: var(--chat-column-max, 960px); width: calc(100% - 48px); margin: 0 auto 8px; display: flex; diff --git a/dashboard/src/pages/Chat/chatMessages.partial.less b/dashboard/src/pages/Chat/chatMessages.partial.less index 204f14c0..b166dc89 100644 --- a/dashboard/src/pages/Chat/chatMessages.partial.less +++ b/dashboard/src/pages/Chat/chatMessages.partial.less @@ -125,10 +125,10 @@ .messageListInner { overflow-anchor: none; - max-width: 960px; + max-width: var(--chat-column-max, 960px); min-width: 280px; margin: 0 auto; - padding: 0 24px; + padding: 0 var(--chat-column-pad-x, 24px); @media (max-width: 767px) { padding: 0 12px; @@ -968,15 +968,13 @@ /* ---------- Message copy button ---------- */ -/* User message: copy button on the left-top of the bubble */ +/* User message: actions under the bubble (easier hit targets than side icons). */ .userMsgRow { display: flex; align-items: flex-start; + justify-content: flex-end; gap: 6px; - - &:hover .msgCopyBtn { - opacity: 1; - } + width: 100%; } .userMsgColumn { @@ -985,6 +983,52 @@ align-items: flex-end; min-width: 0; flex: 0 1 auto; + max-width: 100%; +} + +.userMsgActions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + margin-top: 2px; + /* Stable 32px hit row so copy/edit stay easy to aim at. */ + min-height: 32px; + padding: 0; + + .msgCopyBtn, + .msgActionBtn { + width: 32px; + height: 32px; + margin-left: 0; + /* Invisible until hover/focus, but still receive clicks if aimed. */ + opacity: 0; + pointer-events: auto; + } + + &:hover .msgCopyBtn, + &:hover .msgActionBtn, + &:focus-within .msgCopyBtn, + &:focus-within .msgActionBtn { + opacity: 1; + } + + .msgCopyBtn:hover, + .msgActionBtn:hover { + opacity: 1; + color: var(--fn-text-primary); + background: var(--fn-bg-hover); + } +} + +/* Reveal when the user bubble is hovered/focused, not only the tiny buttons. */ +.userMsgColumn:hover .userMsgActions .msgCopyBtn, +.userMsgColumn:hover .userMsgActions .msgActionBtn, +.userMsgColumn:focus-within .userMsgActions .msgCopyBtn, +.userMsgColumn:focus-within .userMsgActions .msgActionBtn, +.messageBubble:hover .userMsgActions .msgCopyBtn, +.messageBubble:hover .userMsgActions .msgActionBtn { + opacity: 1; } .msgCopyBtn { @@ -1014,5 +1058,6 @@ &:focus-visible { outline: 2px solid var(--fn-color-brand); outline-offset: 2px; + opacity: 1; } } diff --git a/dashboard/src/pages/Chat/components/ChatDockPanel.tsx b/dashboard/src/pages/Chat/components/ChatDockPanel.tsx index 5a561b2f..fd8ad064 100644 --- a/dashboard/src/pages/Chat/components/ChatDockPanel.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockPanel.tsx @@ -1,4 +1,6 @@ import React, { + lazy, + Suspense, useCallback, useEffect, useMemo, @@ -6,8 +8,15 @@ import React, { useState, type ReactNode, } from "react"; -import { Tooltip } from "antd"; -import { FilePen, FolderOpen, Globe, RefreshCw, X } from "lucide-react"; +import { Spin, Tooltip } from "antd"; +import { + FilePen, + FolderOpen, + Globe, + RefreshCw, + Terminal, + X, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import BrowserWorkspace, { type PanelMode, @@ -21,6 +30,8 @@ import styles from "../index.module.less"; import ChatDockFileList from "./ChatDockFileList"; import FilePanelContent from "./FilePanelContent"; +const TerminalPage = lazy(() => import("../../Control/Terminal")); + interface ChatDockPanelProps { mode: PanelMode; onModeChange: (mode: PanelMode) => void; @@ -34,10 +45,16 @@ interface ChatDockPanelProps { onCloseTab: (id: DockTabId) => void; onOpenFile: (path: string) => void; browserEnvironment?: DisplayEnvironment; + /** + * False while the chat dock shell is closed but keep-alive mounted. + * Mirrors Workbench ``isVisible`` so terminal does not treat hide as a + * fresh first visit. + */ + surfaceVisible?: boolean; } /** - * Tabbed dock shell: file list + per-file viewers + browser. + * Tabbed dock shell: file list + per-file viewers + browser + terminal. * Bodies stay mounted after first open so streams / editors survive tab switches. */ const ChatDockPanel: React.FC = ({ @@ -53,11 +70,15 @@ const ChatDockPanel: React.FC = ({ onCloseTab, onOpenFile, browserEnvironment = "desktop", + surfaceVisible = true, }) => { const { t } = useTranslation(); const [browserMounted, setBrowserMounted] = useState( openTabs.some((tab) => tab.kind === "browser"), ); + const [terminalMounted, setTerminalMounted] = useState( + openTabs.some((tab) => tab.kind === "terminal"), + ); const [mountedFilePaths, setMountedFilePaths] = useState(() => openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), ); @@ -71,7 +92,9 @@ const ChatDockPanel: React.FC = ({ useEffect(() => { const hasBrowser = openTabs.some((tab) => tab.kind === "browser"); + const hasTerminal = openTabs.some((tab) => tab.kind === "terminal"); setBrowserMounted(hasBrowser); + setTerminalMounted(hasTerminal); const openFilePaths = new Set( openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), ); @@ -127,6 +150,7 @@ const ChatDockPanel: React.FC = ({ const sessionId = resolveBrowserProfile(); const activeTab = openTabs.find((tab) => tab.id === activeTabId) ?? openTabs[0] ?? null; + const terminalVisible = surfaceVisible && activeTab?.kind === "terminal"; const tabBar = (
@@ -143,6 +167,11 @@ const ChatDockPanel: React.FC = ({ {t("chat.remoteBrowserTitle", "远程浏览器")} + ) : tab.kind === "terminal" ? ( + <> + + {t("chat.dockTerminalTitle", "终端")} + ) : ( <> @@ -265,6 +294,24 @@ const ChatDockPanel: React.FC = ({ />
)} + + {terminalMounted && ( +
+ + +
+ } + > + + +
+ )}
); diff --git a/dashboard/src/pages/Chat/components/ChatDockPanels.keepAlive.test.tsx b/dashboard/src/pages/Chat/components/ChatDockPanels.keepAlive.test.tsx new file mode 100644 index 00000000..8b5127fe --- /dev/null +++ b/dashboard/src/pages/Chat/components/ChatDockPanels.keepAlive.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ChatDockPanels from "./ChatDockPanels"; + +vi.mock("./ChatDockPanel", () => ({ + default: () =>
dock-panel
, +})); + +const baseProps = { + isMobile: false, + dockMode: "right" as const, + isResizing: false, + panelSizes: { rightWidth: 560, bottomHeight: 380 }, + agentId: "agent-a", + filePaths: [] as string[], + openTabs: [{ id: "terminal" as const, kind: "terminal" as const }], + activeTabId: "terminal" as const, + onSelectTab: vi.fn(), + onCloseTab: vi.fn(), + onOpenFile: vi.fn(), + browserEnvironment: "host" as const, + onModeChange: vi.fn(), + onClose: vi.fn(), + onResizeStart: vi.fn(), +}; + +describe("ChatDockPanels keep-alive", () => { + it("keeps the dock panel mounted when closed so terminal state survives", () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId("chat-dock-panel")).toBeTruthy(); + + rerender(); + expect(screen.getByTestId("chat-dock-panel")).toBeTruthy(); + }); + + it("does not remount when switching layout mode", () => { + const { rerender } = render( + , + ); + const first = screen.getByTestId("chat-dock-panel"); + + rerender( + , + ); + expect(screen.getByTestId("chat-dock-panel")).toBe(first); + }); + + it("does not mount an empty dock when there are no tabs", () => { + render( + , + ); + expect(screen.queryByTestId("chat-dock-panel")).toBeNull(); + }); +}); diff --git a/dashboard/src/pages/Chat/components/ChatDockPanels.tsx b/dashboard/src/pages/Chat/components/ChatDockPanels.tsx index 87a292f1..26ce7dbd 100644 --- a/dashboard/src/pages/Chat/components/ChatDockPanels.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockPanels.tsx @@ -24,14 +24,17 @@ interface ChatDockPanelsProps { e: React.PointerEvent, direction: "horizontal" | "vertical", ) => void; - /** When true, render only the bottom-dock slot (inside chatMain). */ - slot: "bottom" | "side"; } /** - * Shared chat dock host: one tabbed shell for file list / files / browser. - * ``slot="bottom"`` renders inside ``chatMain``; ``slot="side"`` covers - * right + popup. Floating browser / file buttons live in ChatPage. + * Single chat dock host (file list / files / browser / terminal). + * + * One React tree for all layout modes so switching bottom ↔ right ↔ popup + * does not remount TerminalPage / BrowserWorkspace. Placement is CSS/flex on + * the chat page; popup geometry stays inside ChatDockPanelShell. + * + * Like Workbench keep-alive: once tabs exist, the panel stays mounted while + * the dock is closed (display:none) so sessions are reused. */ export default function ChatDockPanels({ isMobile, @@ -50,32 +53,26 @@ export default function ChatDockPanels({ onModeChange, onClose, onResizeStart, - slot, }: ChatDockPanelsProps) { - const showBottom = slot === "bottom" && dockOpen && dockMode === "bottom"; - const showSide = - slot === "side" && - dockOpen && - !isMobile && - (dockMode === "right" || dockMode === "popup"); - const showMobilePopup = - slot === "side" && isMobile && dockOpen && dockMode === "popup"; + const keepAlive = openTabs.length > 0; + const visible = dockOpen && keepAlive; + + if (!keepAlive) { + return null; + } + + const effectiveMode: PanelMode = + dockMode === "right" && isMobile ? "popup" : dockMode; const panel = ( ); + const showBottomResizer = visible && effectiveMode === "bottom"; + const showSideResizer = visible && effectiveMode === "right" && !isMobile; + return ( <> - {showBottom && ( - <> -
onResizeStart(e, "vertical")} - > -
-
- {panel} - + {showBottomResizer && ( +
onResizeStart(e, "vertical")} + > +
+
)} - {(showSide || showMobilePopup) && ( - <> - {dockMode === "right" && !isMobile && ( -
onResizeStart(e, "horizontal")} - > -
-
- )} - {panel} - + {showSideResizer && ( +
onResizeStart(e, "horizontal")} + > +
+
)} + +
+ {panel} +
); } diff --git a/dashboard/src/pages/Chat/components/ChatTitleBar.tsx b/dashboard/src/pages/Chat/components/ChatTitleBar.tsx index f563e9d1..3a9b6bd5 100644 --- a/dashboard/src/pages/Chat/components/ChatTitleBar.tsx +++ b/dashboard/src/pages/Chat/components/ChatTitleBar.tsx @@ -85,7 +85,7 @@ export default function ChatTitleBar({
{isEditing ? ( @@ -107,15 +107,15 @@ export default function ChatTitleBar({

{title} - {session.pinned ? ( - - ) : null}

+ {session.pinned ? ( + + ) : null} ); @@ -661,22 +667,6 @@ function MessageBubble({
{isUser ? (
- {!isEditing && message.content && ( - - )} - {!isEditing && onEditUserMessage && ( - - )} {isEditing ? (