From 1c2e524b280d5f720a3e48560672f93a9c55cad5 Mon Sep 17 00:00:00 2001 From: Rocky Kwok Date: Tue, 23 Jun 2026 10:56:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20iframe=E9=A1=B5=E9=9D=A2=E5=AD=90?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E4=B8=8D=E8=B7=B3=E5=87=BA=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 102 ++++++++++ .../workspace/agents/agent-selector.tsx | 115 +++++++---- frontend-web/src/pages/IframeChatPage.tsx | 185 +++++++++++------- .../src/pages/iframe-chat-agent.test.ts | 35 ++++ frontend-web/src/pages/iframe-chat-agent.ts | 19 ++ .../pages/iframe-chat-mode-buttons.test.ts | 43 ++++ .../src/pages/iframe-chat-mode-buttons.ts | 66 +++++++ 7 files changed, 460 insertions(+), 105 deletions(-) create mode 100644 AGENTS.md create mode 100644 frontend-web/src/pages/iframe-chat-agent.test.ts create mode 100644 frontend-web/src/pages/iframe-chat-agent.ts create mode 100644 frontend-web/src/pages/iframe-chat-mode-buttons.test.ts create mode 100644 frontend-web/src/pages/iframe-chat-mode-buttons.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..69d4c914 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,102 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI super agent platform. The repo contains two sub-projects: + +- `offline-backend-20260512/backend/` — Python backend (FastAPI Gateway + LangGraph agent runtime) +- `frontend-web/` — Vite + React 19 frontend (TypeScript, pnpm) + +Managed together via shell scripts in `scripts/`. + +## Commands + +### Full Application (from repo root) + +```bash +./scripts/start-all.sh # Start backend + frontend as background daemons +./scripts/stop-all.sh # Stop both +./scripts/restart-all.sh # Restart both +./scripts/status.sh # Check running status +``` + +Logs and PID files are written to `.runtime/logs/` and `.runtime/pids/`. + +### Backend Only (from `offline-backend-20260512/backend/`) + +```bash +make install # Install Python dependencies (uv) +make dev # Run Gateway API with hot-reload on port 8001 +make gateway # Run Gateway API without reload +make test # Run all tests: PYTHONPATH=. uv run pytest tests/ -v +make lint # Lint with ruff +make format # Format with ruff +``` + +Run a single test file: +```bash +PYTHONPATH=. uv run pytest tests/test_.py -v +``` + +### Frontend Only (from `frontend-web/`) + +```bash +pnpm install # Install dependencies +pnpm dev # Start Vite dev server on port 5174 +pnpm build # Production build +pnpm typecheck # TypeScript type checking (tsc --noEmit) +``` + +## Service Ports + +| Service | Port | Notes | +|---------|------|-------| +| Backend (Gateway API) | 8001 | FastAPI + LangGraph runtime | +| Frontend (Vite) | 5174 | React SPA | + +Frontend connects to backend via env vars `VITE_BACKEND_BASE_URL` and `VITE_LANGGRAPH_BASE_URL` (injected by `scripts/start-frontend.sh`). + +## Architecture + +### Backend + +The backend (`offline-backend-20260512/backend/`) is split into two layers: + +- **`packages/harness/deerflow/`** — publishable agent framework (`deerflow.*` imports): LangGraph lead agent, middleware chain, sandbox, tools, MCP, memory, models, skills. **Never imports from `app.*`.** +- **`app/`** — application layer (`app.*` imports): FastAPI Gateway routers, IM channel integrations (Feishu, Slack, Telegram, DingTalk). + +The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph entry point. It wraps a middleware chain (~18 middlewares assembled in strict order) around LLM calls, handling: thread isolation, sandbox lifecycle, uploads, memory, plan mode, vision, subagent delegation, and loop detection. + +**Middleware order matters** — middlewares are appended in a fixed sequence; `ClarificationMiddleware` must always be last. See `offline-backend-20260512/backend/AGENTS.md` for the full ordered list. + +**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI). + +### Frontend + +The frontend (`frontend-web/`) is a React 19 + Vite SPA: + +- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` +- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) +- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). +- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). +- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. +- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 +- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. +- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` +- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE +- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` +- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) +- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. +- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。 + +### Configuration + +Backend config lives in `offline-backend-20260512/backend/config.yaml`. Values starting with `$` resolve as environment variables (e.g., `$OPENAI_API_KEY`). MCP servers and skills are configured in `extensions_config.json` in the same directory. + +## Detailed Backend Reference + +The backend has its own comprehensive documentation at: +- `offline-backend-20260512/backend/AGENTS.md` — architecture, middleware chain, all API routes, config schema, sandbox/memory/subagent systems +- `offline-backend-20260512/backend/README.md` — setup guide, technology stack diff --git a/frontend-web/src/components/workspace/agents/agent-selector.tsx b/frontend-web/src/components/workspace/agents/agent-selector.tsx index cf4d1f1a..a3539beb 100644 --- a/frontend-web/src/components/workspace/agents/agent-selector.tsx +++ b/frontend-web/src/components/workspace/agents/agent-selector.tsx @@ -32,6 +32,10 @@ interface AgentSelectorProps { disabled?: boolean; /** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */ compact?: boolean; + /** 图标模式:用于 iframe 等底栏场景,仅显示机器人图标和可选清空按钮。 */ + triggerVariant?: "default" | "icon"; + iconButtonClassName?: string; + iconActiveClassName?: string; } /** @@ -48,6 +52,9 @@ export function AgentSelector({ onAgentChange, disabled, compact = false, + triggerVariant = "default", + iconButtonClassName, + iconActiveClassName, }: AgentSelectorProps) { const [open, setOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false); @@ -79,48 +86,84 @@ export function AgentSelector({ }; const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent"; + const iconMode = triggerVariant === "icon"; return ( <> - - - - {triggerLabel} - - {selectedAgent && ( - { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onAgentChange(null); - } - }} - > - + {iconMode ? ( + + ) : ( + + + + {triggerLabel} - )} - + {selectedAgent && ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onAgentChange(null); + } + }} + > + + + )} + + )} 选择 Agent diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx index 21422426..ea526b4d 100644 --- a/frontend-web/src/pages/IframeChatPage.tsx +++ b/frontend-web/src/pages/IframeChatPage.tsx @@ -5,8 +5,8 @@ import { BrainCircuitIcon, FileTextIcon, GraduationCapIcon, + ShrinkIcon, } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; @@ -46,6 +46,8 @@ import { type BrainyChatSubmitMessage, parseBrainyChatSubmitMessage, } from "./iframe-chat-submit"; +import { resolveIframeAssistantId } from "./iframe-chat-agent"; +import { buildIframeModeButtons } from "./iframe-chat-mode-buttons"; type BrainyChatStatus = "started" | "finished"; type BrainyAuthTokenMessage = { @@ -93,7 +95,6 @@ function postBrainyAuthToken(accessToken: string) { export default function IframeChatPage() { const routeBase = useRouteBase(); - const router = useRouter(); const { t } = useI18n(); const [hasStartedConversation, setHasStartedConversation] = useState(false); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = @@ -152,11 +153,19 @@ export default function IframeChatPage() { }, [selectedAgentId, selectorAgents, selectorLoading]); const { showNotification } = useNotification(); + const activeAssistantId = resolveIframeAssistantId({ + selectedAgentId, + threadMetadataAgentId: null, + }); const [thread, sendMessage, isUploading] = useThreadStream({ threadId: isNewThread ? undefined : threadId, - context: settings.context, + context: { + ...settings.context, + ...(activeAssistantId ? { agent_id: activeAssistantId } : {}), + }, isMock, + assistantId: activeAssistantId, onStart: (createdThreadId) => { setThreadId(createdThreadId); setIsNewThread(false); @@ -193,6 +202,22 @@ export default function IframeChatPage() { }, }); + const threadMetadataAgentId = (() => { + const metadata = (thread as { metadata?: Record | null }) + .metadata; + return typeof metadata?.agent_id === "string" ? metadata.agent_id : null; + })(); + + useEffect(() => { + const resolvedAgentId = resolveIframeAssistantId({ + selectedAgentId, + threadMetadataAgentId, + }); + if (resolvedAgentId && resolvedAgentId !== selectedAgentId) { + setSelectedAgentId(resolvedAgentId); + } + }, [selectedAgentId, threadMetadataAgentId]); + const writingArtifactPath = useMemo(() => { const isMarkdown = (path: string | null | undefined) => !!path && /\.(md|markdown)(?:$|[?#])/i.test(path); @@ -210,31 +235,15 @@ export default function IframeChatPage() { externalContext?: Record, options?: { additionalKwargs?: Record }, ) => { - if ( - selectedAgentId && - isNewThread && - !hasStartedConversation && - message.text && - (!message.files || message.files.length === 0) - ) { - try { - sessionStorage.setItem( - "pendingAgentMessage", - JSON.stringify({ agentId: selectedAgentId, text: message.text }), - ); - } catch { - // sessionStorage may be unavailable in private mode; fall through. - } - router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`); - return; - } - setHasStartedConversation(true); window.dispatchEvent( new CustomEvent("workspace:sandbox-resume-autoscroll"), ); const extraContext: Record = {}; + if (activeAssistantId) { + extraContext.agent_id = activeAssistantId; + } if (settings.context.writing_mode && writingArtifactPath) { extraContext.writing_artifact_path = writingArtifactPath; } @@ -250,10 +259,8 @@ export default function IframeChatPage() { ); }, [ + activeAssistantId, hasStartedConversation, - isNewThread, - router, - selectedAgentId, sendMessage, settings.context.writing_mode, threadId, @@ -277,6 +284,8 @@ export default function IframeChatPage() { const ragModeLocked = isThreadRagModeLocked(threadId); const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; + const contextCompressionEnabled = + localSettings.display.contextCompressionEnabled ?? false; const proModeEnabled = settings.context.mode === "pro"; const toggleWritingMode = useCallback(() => { @@ -301,6 +310,12 @@ export default function IframeChatPage() { }); }, [ragModeEnabled, ragModeLocked, setLocalSettings]); + const toggleContextCompression = useCallback(() => { + setLocalSettings("display", { + contextCompressionEnabled: !contextCompressionEnabled, + }); + }, [contextCompressionEnabled, setLocalSettings]); + const toggleProMode = useCallback(() => { setSettings("context", { mode: proModeEnabled ? "flash" : "pro", @@ -333,28 +348,29 @@ export default function IframeChatPage() { const data = event.data; if (data?.source !== "brainy-extension") return; if (data?.type === "brainy:chat-interrupt") { - void handleInterrupt(data); + void handleInterrupt(data as BrainyChatInterruptMessage); return; } if (data?.type !== "brainy:chat-submit") return; + const submitMessage = data as BrainyChatSubmitMessage; const { submitText, files, iframeContext, additionalKwargs } = - parseBrainyChatSubmitMessage(data); + parseBrainyChatSubmitMessage(submitMessage); console.log("收到插件消息", { - text: data.payload?.text, - prompt: data.payload?.prompt, - pageContext: data.payload?.pageContext, - screenshot: data.payload?.screenshot, - model: data.payload?.model, - requestId: data.requestId, + text: submitMessage.payload?.text, + prompt: submitMessage.payload?.prompt, + pageContext: submitMessage.payload?.pageContext, + screenshot: submitMessage.payload?.screenshot, + model: submitMessage.payload?.model, + requestId: submitMessage.requestId, filesCount: files.length, iframeContext, }); if (!submitText && files.length === 0) return; - pendingRequestIdRef.current = data.requestId ?? null; + pendingRequestIdRef.current = submitMessage.requestId ?? null; handleSubmit( { text: submitText, files }, @@ -371,41 +387,59 @@ export default function IframeChatPage() { const isLandingState = isNewThread && !hasStartedConversation; const bottomBarHeightClass = "pb-24"; - const modeButtons = [ - { - key: "writing", - label: "写作", - icon: FileTextIcon, - active: !!settings.context.writing_mode, - onClick: toggleWritingMode, - activeClassName: "text-amber-600 hover:text-amber-700", - }, - { - key: "memory", - label: "记忆", - icon: BrainCircuitIcon, - active: settings.context.memory_injection_enabled ?? true, - onClick: toggleMemoryInjection, - activeClassName: "text-blue-600 hover:text-blue-700", - }, - { - key: "references", - label: "参考文献", - icon: BookMarkedIcon, - active: ragModeEnabled, - onClick: toggleRagMode, - disabled: ragModeLocked, - activeClassName: "text-emerald-600 hover:text-emerald-700", - }, - { - key: "pro", - label: "专业问答", - icon: GraduationCapIcon, - active: proModeEnabled, - onClick: toggleProMode, - activeClassName: "text-violet-600 hover:text-violet-700", - }, - ] as const; + const modeButtons = buildIframeModeButtons({ + agentSelected: !!selectedAgentId, + writingModeEnabled: !!settings.context.writing_mode, + memoryInjectionEnabled: Boolean( + settings.context.memory_injection_enabled ?? true, + ), + ragModeEnabled, + ragModeLocked, + contextCompressionEnabled, + proModeEnabled, + }).map((button) => { + switch (button.key) { + case "agent": + return button; + case "writing": + return { + ...button, + icon: FileTextIcon, + onClick: toggleWritingMode, + activeClassName: "text-amber-600 hover:text-amber-700", + }; + case "memory": + return { + ...button, + icon: BrainCircuitIcon, + onClick: toggleMemoryInjection, + activeClassName: "text-blue-600 hover:text-blue-700", + }; + case "references": + return { + ...button, + icon: BookMarkedIcon, + onClick: toggleRagMode, + activeClassName: "text-emerald-600 hover:text-emerald-700", + }; + case "contextCompression": + return { + ...button, + icon: ShrinkIcon, + onClick: toggleContextCompression, + activeClassName: "text-violet-600 hover:text-violet-700", + }; + case "pro": + return { + ...button, + icon: GraduationCapIcon, + onClick: toggleProMode, + activeClassName: "text-fuchsia-600 hover:text-fuchsia-700", + }; + default: + return button; + } + }); return ( @@ -496,6 +530,19 @@ export default function IframeChatPage() {
{modeButtons.map((button) => { + if (button.key === "agent") { + return ( + + ); + } const Icon = button.icon; return ( diff --git a/frontend-web/src/pages/iframe-chat-agent.test.ts b/frontend-web/src/pages/iframe-chat-agent.test.ts new file mode 100644 index 00000000..abeb1fb7 --- /dev/null +++ b/frontend-web/src/pages/iframe-chat-agent.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// @ts-ignore Node's test runner resolves the local .ts module directly. +import { resolveIframeAssistantId } from "./iframe-chat-agent.ts"; + +test("prefers the currently selected iframe agent", () => { + assert.equal( + resolveIframeAssistantId({ + selectedAgentId: "roundtable-structure", + threadMetadataAgentId: "lead_agent", + }), + "roundtable-structure", + ); +}); + +test("reuses the thread-bound agent when reopening an iframe thread", () => { + assert.equal( + resolveIframeAssistantId({ + selectedAgentId: null, + threadMetadataAgentId: "roundtable-structure", + }), + "roundtable-structure", + ); +}); + +test("falls back to lead-agent mode when no agent is selected or bound", () => { + assert.equal( + resolveIframeAssistantId({ + selectedAgentId: null, + threadMetadataAgentId: null, + }), + undefined, + ); +}); diff --git a/frontend-web/src/pages/iframe-chat-agent.ts b/frontend-web/src/pages/iframe-chat-agent.ts new file mode 100644 index 00000000..f10fdfa1 --- /dev/null +++ b/frontend-web/src/pages/iframe-chat-agent.ts @@ -0,0 +1,19 @@ +type ResolveIframeAssistantIdInput = { + selectedAgentId: string | null; + threadMetadataAgentId: string | null; +}; + +function normalizeAgentId(agentId: string | null | undefined): string | undefined { + const trimmed = agentId?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveIframeAssistantId({ + selectedAgentId, + threadMetadataAgentId, +}: ResolveIframeAssistantIdInput): string | undefined { + return ( + normalizeAgentId(selectedAgentId) ?? + normalizeAgentId(threadMetadataAgentId) + ); +} diff --git a/frontend-web/src/pages/iframe-chat-mode-buttons.test.ts b/frontend-web/src/pages/iframe-chat-mode-buttons.test.ts new file mode 100644 index 00000000..6e33a752 --- /dev/null +++ b/frontend-web/src/pages/iframe-chat-mode-buttons.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// @ts-ignore Node's test runner resolves the local .ts module directly. +import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts"; + +test("includes the context compression toggle in the iframe chat toolbar", () => { + const buttons = buildIframeModeButtons({ + agentSelected: false, + writingModeEnabled: false, + memoryInjectionEnabled: true, + ragModeEnabled: false, + ragModeLocked: false, + contextCompressionEnabled: false, + proModeEnabled: false, + }); + + assert.deepEqual( + buttons.map((button) => button.key), + [ + "writing", + "memory", + "references", + "contextCompression", + "pro", + "agent", + ], + ); +}); + +test("returns only concrete toolbar button configs", () => { + const buttons = buildIframeModeButtons({ + agentSelected: true, + writingModeEnabled: false, + memoryInjectionEnabled: true, + ragModeEnabled: false, + ragModeLocked: false, + contextCompressionEnabled: false, + proModeEnabled: false, + }); + + assert.equal(buttons.every((button) => typeof button.key === "string"), true); +}); diff --git a/frontend-web/src/pages/iframe-chat-mode-buttons.ts b/frontend-web/src/pages/iframe-chat-mode-buttons.ts new file mode 100644 index 00000000..47f3edd4 --- /dev/null +++ b/frontend-web/src/pages/iframe-chat-mode-buttons.ts @@ -0,0 +1,66 @@ +export type IframeModeButtonState = { + key: + | "agent" + | "writing" + | "memory" + | "references" + | "contextCompression" + | "pro"; + label: string; + active: boolean; + disabled?: boolean; +}; + +type BuildIframeModeButtonsInput = { + agentSelected: boolean; + writingModeEnabled: boolean; + memoryInjectionEnabled: boolean; + ragModeEnabled: boolean; + ragModeLocked: boolean; + contextCompressionEnabled: boolean; + proModeEnabled: boolean; +}; + +export function buildIframeModeButtons({ + agentSelected, + writingModeEnabled, + memoryInjectionEnabled, + ragModeEnabled, + ragModeLocked, + contextCompressionEnabled, + proModeEnabled, +}: BuildIframeModeButtonsInput): IframeModeButtonState[] { + return [ + { + key: "writing", + label: "写作", + active: writingModeEnabled, + }, + { + key: "memory", + label: "记忆", + active: memoryInjectionEnabled, + }, + { + key: "references", + label: "参考文献", + active: ragModeEnabled, + disabled: ragModeLocked, + }, + { + key: "contextCompression", + label: "上下文压缩", + active: contextCompressionEnabled, + }, + { + key: "pro", + label: "专业问答", + active: proModeEnabled, + }, + { + key: "agent", + label: "智能体", + active: agentSelected, + }, + ]; +}