diff --git a/AGENTS.md b/AGENTS.md index 81ab06d6..ff3dad8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **舆情分析智能体(前端虚拟智能体)**: 智能体管理页新增「舆情分析」类型 tab,固定一张虚拟智能体卡片(名称/简介可编辑,存 localStorage)。对话页 `pages/SentimentAgentChatPage.tsx`(路由 `agents/sentiment-analysis/chats/:thread_id`)复用普通问答布局 + `MessageList`,**输入框只有发送按钮**;问答直连外部 AG-UI 网关(POST SSE),不经过 LangGraph,会话仅存 sessionStorage。接口地址 + Basic Auth 在 `runtime-config.js` 配置(`VITE_SENTIMENT_AGENT_API_URL` / `_AUTH_USERNAME` / `_AUTH_PASSWORD`),核心模块 `src/core/sentiment-agent/`。 - **敏感词管理 (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`. +- **按钮管理 (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). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route, `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). **`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/` diff --git a/CLAUDE.md b/CLAUDE.md index a62332da..e184f138 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. **账号级「登录口令」(per-account password — 临时开通)**: the same page also lets an admin assign one specific account an individual **plaintext** password (`users.login_password` column, migration `20260625_04`, nullable; auto-added on startup by `_ensure_orm_columns_sync`). Once set, **that account's** username login (`POST /api/v1/auth/login/username`) **requires** `?password=` to exactly match it and that personal password **takes precedence over** the global shared-password gate (`username_login.require_password`); accounts without one keep today's shared-gate / passwordless behavior. The user then logs in via the **existing** frontend URL `#/login/<用户名>?password=xxxx` (LoginPage already forwards `?password=`; `<用户名>` = the account's email `@`-prefix). **Plaintext is intentional** — the password rides in the URL query anyway, so storing it readably lets the admin re-copy the full login link from the page; it is exposed only on the **admin-only** `GET /api/v1/auth/users` (`UserListItem.login_password`) and the new admin-only `PUT /api/v1/auth/users/{id}/login-password` (set non-empty / clear with null·empty; doesn't touch `token_version`). The whitelist page renders a per-user 「登录口令」 column with a set/修改 editor + a live 「复制登录链接」 button (`useSetUserLoginPassword` in `core/auth/hooks.ts`, `setUserLoginPassword` in `core/auth/api.ts`). Backend gate helper `_lookup_user_by_username` (lookup-only, no auto-register) in `routers/auth.py`; tests `tests/test_login_personal_password.py`. - **敏感词管理 (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, openMode, loginParams, appendAuth, authTokenParam, authNameParam, 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). **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`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. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 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`. +- **按钮管理 (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, openMode, loginParams, appendAuth, authTokenParam, authNameParam, 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). **`host`(宿主路由)** asks the embedding parent to navigate: `runTaskButton` sends `window.parent.postMessage({ type:'HOST_NAVIGATE', path, queryParams:{ [idParam]: Number(taskId) } }, hostOrigin)` — `target` is the host route (e.g. `/task/item-aiceh/step-analysis-audience`), `appendTaskId`/`idParam` become numeric `queryParams` (task id only), origin from `runtime-config.js` `VITE_HOST_NAVIGATE_ORIGIN` (else ancestorOrigins/referrer; never `*`). Independent-tab clicks toast and do nothing. **`url` buttons support two independent, coexisting login-append mechanisms**: (1) **`loginParams` (追加登录参数, like 应用管理/Light App Center)** — a list of `{key, source, customValue}` resolved at click time: `runTaskButton` reads `userInfo` (via `readUserInfo` from `strategy-components/lib/light-app`, embed-isolated through `authStorage`) and appends `?{key}={value}` for each (`source` = a userInfo field such as `accessToken`/`yUserId`, or `other` → literal `customValue`); empty list = nothing appended. Reuses `LOGIN_SOURCE_OPTIONS` from light-app; stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). (2) **`appendAuth` (追加登录态)** — appends `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. Stored as columns `task_buttons.{append_auth, auth_token_param, auth_name_param}` (migration `20260625_02`). The admin page renders both editors for `url` buttons (loginParams 虚线框 + 追加登录态 switch). **`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. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 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`. - **实时并发监控 (Concurrency / System-Pressure Monitor, admin-only)**: a live panel mounted at the **top of the admin 排行榜 page** (`components/workspace/admin/concurrency-monitor.tsx` `ConcurrencyMonitorPanel`, rendered in `pages/AdminUserLeaderboardPage.tsx`) showing **当前并发的大模型调用数 + 列表(用户/智能体/类型/已运行时长)**, each row with a 「停止」button (and a 「全部停止」) that **truly aborts** that run's in-flight model stream via `RunManager.cancel` (`POST /api/admin/active-runs/{run_id}/cancel`) — to relieve pressure when 使用人数过多. Two line charts (minute/hour toggle): **并发量折线图** (peak/avg, `GET /api/admin/active-runs/concurrency`, fed by a lightweight background sampler into `concurrency_samples`) and **大模型出字速度折线图** (tokens/sec, `GET /api/admin/active-runs/token-speed`, aggregated from `llm_call_metrics`) so a dip pinpoints where the bottleneck is. A **「后台采样」Switch** (`system_settings.json → concurrency_monitor.enabled`, **default off / opt-in**, `GET|PUT /api/admin/active-runs/settings`) turns the background sampling on/off **at runtime, no restart** (the live concurrency count + 「停止」 work even while sampling is off — only the 并发量 history chart needs it on); everything is designed to **never impact production** (one tiny INSERT/15s, retention-purged, best-effort). Client/hooks: `core/admin/active-runs.ts` + `core/admin/hooks.ts` (`useActiveRuns`/`useConcurrencySeries`/`useTokenSpeedSeries`/`useCancel*`/`useMonitorSettings`). See the backend CLAUDE.md「Admin Concurrency Monitor」. - **会话冲突 409 中文化**: when a user submits a new question while the previous turn is still streaming, the backend's `RunManager.create_or_reject` rejects with HTTP 409 (`services.py` now returns a **Chinese** detail「当前对话还在生成回答…」). `core/threads/hooks.ts` `onError` detects the conflict (`isActiveRunConflictError`: 409 / "already has an active run" / the Chinese text) and shows a friendly `toast.info` instead of a raw error. - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index 24cc025a..10cc1276 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -58,6 +58,11 @@ window.__CM_RUNTIME_CONFIG__ = { // 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。 VITE_8BF_ROUNDTABLE_BACK_URL: "", + // 任务深链「宿主路由」按钮的 postMessage targetOrigin(HOST_NAVIGATE)。 + // 填宿主系统 origin,例如 https://localhost:4912;生产改成他们线上地址。 + // 留空时尝试用 iframe 的 ancestorOrigins / referrer 自动探测,探测失败则不发送。 + VITE_HOST_NAVIGATE_ORIGIN: "", + // 佳来模型对话接口。 VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt", diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index c8170fd9..0d952f12 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -43,11 +43,11 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor"; import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite"; -import { - useCapabilities as useResearchCapabilities, - useResearchSources, - type DeepResearchSource, -} from "@/strategy-components/api/deep-research"; +import { + useCapabilities as useResearchCapabilities, + useResearchSources, + type DeepResearchSource, +} from "@/strategy-components/api/deep-research"; import { listDurableDocumentRewrites, streamDurableDocumentRewriteJob, @@ -58,17 +58,17 @@ import { publishArtifactToKnowledge } from "@/core/agents/api"; import { useAgent } from "@/core/agents/hooks"; import { apiFetch } from "@/core/api"; import { copyTextToClipboard } from "@/core/clipboard"; -import { - DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, - DOCUMENT_REWRITE_STARTED_EVENT, - DOCUMENT_REWRITE_UNDONE_EVENT, - useDocumentRewritePresentation, -} from "@/core/document-rewrite/context"; -import { - clearArtifactRewriteFallback, - completeArtifactRewriteFallback, - primeArtifactRewriteFallback, -} from "@/core/document-rewrite/local-fallback"; +import { + DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, + DOCUMENT_REWRITE_STARTED_EVENT, + DOCUMENT_REWRITE_UNDONE_EVENT, + useDocumentRewritePresentation, +} from "@/core/document-rewrite/context"; +import { + clearArtifactRewriteFallback, + completeArtifactRewriteFallback, + primeArtifactRewriteFallback, +} from "@/core/document-rewrite/local-fallback"; import { downloadArtifactFile, } from "@/core/artifacts/download"; @@ -111,34 +111,55 @@ import { useArtifacts } from "./context"; import { SpreadsheetPreview } from "./spreadsheet-preview"; import { SkillInstallDialog } from "./skill-install-dialog"; -const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll"; -const RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\]\]|\[\s*来源\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\])/gi; -const TRAILING_RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::][^\]\n]*(?:\]?)|\[\s*来源\s*[::][^\]\n]*)$/i; - -/** Keep durable source ids in Markdown while presenting plain numeric citations. */ -function formatResearchCitationsForPreview( - content: string, - sources: DeepResearchSource[], -): string { - const citationNumbers = new Map( - sources.map((source, index) => [source.id, index + 1]), - ); - let nextCitationNumber = citationNumbers.size + 1; - - return content - .replace(RESEARCH_SOURCE_MARKER_RE, (_marker, legacyId: string, displayId: string) => { - const sourceId = legacyId || displayId; - let citationNumber = citationNumbers.get(sourceId); - if (!citationNumber) { - citationNumber = nextCitationNumber; - nextCitationNumber += 1; - citationNumbers.set(sourceId, citationNumber); - } - return `[${citationNumber}]`; - }) - // Hide a citation token while it is still arriving over the live stream. - .replace(TRAILING_RESEARCH_SOURCE_MARKER_RE, ""); -} +const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll"; +const RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\]\]|\[\s*来源\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\])/gi; +const TRAILING_RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::][^\]\n]*(?:\]?)|\[\s*来源\s*[::][^\]\n]*)$/i; + +/** Keep durable source ids in Markdown while presenting plain numeric citations. */ +function formatResearchCitationsForPreview( + content: string, + sources: DeepResearchSource[], +): string { + const citationNumbers = new Map( + sources.map((source, index) => [source.id, index + 1]), + ); + let nextCitationNumber = citationNumbers.size + 1; + + return preserveMarkdownStandaloneLines( + content + .replace(RESEARCH_SOURCE_MARKER_RE, (_marker, legacyId: string, displayId: string) => { + const sourceId = legacyId || displayId; + let citationNumber = citationNumbers.get(sourceId); + if (!citationNumber) { + citationNumber = nextCitationNumber; + nextCitationNumber += 1; + citationNumbers.set(sourceId, citationNumber); + } + return `[${citationNumber}]`; + }) + // Hide a citation token while it is still arriving over the live stream. + .replace(TRAILING_RESEARCH_SOURCE_MARKER_RE, ""), + ); +} + +/** + * Markdown treats a single newline as a space. Cover fields and chapter + * headings like 「日期:」「第一章」「1.1」must be their own blocks in preview. + */ +function preserveMarkdownStandaloneLines(markdown: string): string { + const standalone = + /^(?:《.+》|(?:日期|类型|密级)\s*[::].+|第[一二三四五六七八九十百千0-9]+章\b.*|\d+\.\d+(?:\.\d+)?\s+\S+)$/; + const lines = markdown.split("\n"); + const output: string[] = []; + for (const line of lines) { + const stripped = line.trim(); + if (stripped && standalone.test(stripped) && output.length > 0 && output[output.length - 1]!.trim()) { + output.push(""); + } + output.push(line); + } + return output.join("\n"); +} async function sha256Content(content: string): Promise { if (!globalThis.crypto?.subtle) return undefined; @@ -324,13 +345,13 @@ export function ArtifactFileDetail({ language === "mermaid" ); }, [language]); - const { - content, - isApplyingEdit, - isLoading: isArtifactContentLoading, - error: artifactContentError, - refetch: refetchArtifactContent, - } = useArtifactContent({ + const { + content, + isApplyingEdit, + isLoading: isArtifactContentLoading, + error: artifactContentError, + refetch: refetchArtifactContent, + } = useArtifactContent({ threadId, // editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟 // URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容 @@ -370,19 +391,19 @@ export function ArtifactFileDetail({ const documentRewriteActive = documentRewrite.isActive; const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true; const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4; - const displayContent = documentRewriteActive - ? documentRewrite.run.draft - : (canEditArtifact ? draftContent : (content ?? "")); - const artifactContentPending = - isCodeFile && - !useWriteFileStreaming && - content === undefined && - isArtifactContentLoading; - const artifactContentUnavailable = - isCodeFile && - !useWriteFileStreaming && - content === undefined && - !isArtifactContentLoading; + const displayContent = documentRewriteActive + ? documentRewrite.run.draft + : (canEditArtifact ? draftContent : (content ?? "")); + const artifactContentPending = + isCodeFile && + !useWriteFileStreaming && + content === undefined && + isArtifactContentLoading; + const artifactContentUnavailable = + isCodeFile && + !useWriteFileStreaming && + content === undefined && + !isArtifactContentLoading; const isAutoPreviewLanguage = language === "html" || language === "markdown" || language === "mermaid"; const isPreviewStreaming = @@ -438,12 +459,12 @@ export function ArtifactFileDetail({ if ( !canEditArtifact || !threadId || - !filepath || - isMock || - useWriteFileStreaming || - documentRewriteActive || - content === undefined || - artifactContentError + !filepath || + isMock || + useWriteFileStreaming || + documentRewriteActive || + content === undefined || + artifactContentError ) { return; } @@ -523,13 +544,13 @@ export function ArtifactFileDetail({ case "commit_started": rewritePresentation.update(presentationId, { stage: "committing" }); break; - case "committed": - rewritePresentation.update(presentationId, { - rewrittenContent: event.content, - versionId: event.versionId ?? null, - committedHash: event.committedHash, - }); - break; + case "committed": + rewritePresentation.update(presentationId, { + rewrittenContent: event.content, + versionId: event.versionId ?? null, + committedHash: event.committedHash, + }); + break; case "done": rewritePresentation.update(presentationId, { stage: "completed", @@ -542,9 +563,9 @@ export function ArtifactFileDetail({ case "cancelled": rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message }); break; - case "error": - console.error("AI 全文改写历史恢复失败:", event.message); - break; + case "error": + console.error("AI 全文改写历史恢复失败:", event.message); + break; default: break; } @@ -557,15 +578,15 @@ export function ArtifactFileDetail({ if (disposed || !latest) return; // Restore prior terminal rewrites into the ordinary message timeline in // chronological order. The newest job is handled below because it may - // still be active and needs its SSE stream resumed. - for (const historical of jobs.slice(1).reverse()) { - const historicalResult = historical.result; - const historicalCommittedHash = typeof historicalResult.committedHash === "string" - ? historicalResult.committedHash - : null; - const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object" - ? historicalResult.summary as DocumentRewriteSummary - : null; + // still be active and needs its SSE stream resumed. + for (const historical of jobs.slice(1).reverse()) { + const historicalResult = historical.result; + const historicalCommittedHash = typeof historicalResult.committedHash === "string" + ? historicalResult.committedHash + : null; + const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object" + ? historicalResult.summary as DocumentRewriteSummary + : null; const historicalPresentationId = rewritePresentation?.create({ threadId, source: { kind: "artifact", id: threadId, path: filepath }, @@ -576,9 +597,9 @@ export function ArtifactFileDetail({ generateImages: historical.request.generateImages, maxGeneratedImages: historical.request.maxGeneratedImages, originalContent: historical.request.originalContent, - rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null, - committedHash: historicalCommittedHash, - requirementsPlan: typeof historicalResult.requirementsPlan === "string" + rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null, + committedHash: historicalCommittedHash, + requirementsPlan: typeof historicalResult.requirementsPlan === "string" ? historicalResult.requirementsPlan : "", }); @@ -589,7 +610,7 @@ export function ArtifactFileDetail({ : historical.status === "cancelled" ? "cancelled" : "error", summary: historicalSummary, warnings: historicalSummary?.validationWarnings ?? [], - versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null, + versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null, error: historicalResult.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : historical.status === "cancelled" @@ -598,28 +619,28 @@ export function ArtifactFileDetail({ }); } const result = latest.result; - const summary = result.summary && typeof result.summary === "object" - ? result.summary as DocumentRewriteSummary - : null; - const originalContent = latest.request.originalContent || draftContent; - const rewrittenContent = typeof result.content === "string" ? result.content : null; - const versionSnapshotFailed = result.versionSnapshotFailed === true; - const committedHash = typeof result.committedHash === "string" ? result.committedHash : null; - if (!versionSnapshotFailed) { - clearArtifactRewriteFallback(threadId, filepath); - } - const localFallbackAvailable = - versionSnapshotFailed && - latest.status === "completed" && - result.status !== "conflict" && - committedHash !== null && - completeArtifactRewriteFallback({ - threadId, - path: filepath, - originalContent, - committedHash, - }); - const presentationId = rewritePresentation?.create({ + const summary = result.summary && typeof result.summary === "object" + ? result.summary as DocumentRewriteSummary + : null; + const originalContent = latest.request.originalContent || draftContent; + const rewrittenContent = typeof result.content === "string" ? result.content : null; + const versionSnapshotFailed = result.versionSnapshotFailed === true; + const committedHash = typeof result.committedHash === "string" ? result.committedHash : null; + if (!versionSnapshotFailed) { + clearArtifactRewriteFallback(threadId, filepath); + } + const localFallbackAvailable = + versionSnapshotFailed && + latest.status === "completed" && + result.status !== "conflict" && + committedHash !== null && + completeArtifactRewriteFallback({ + threadId, + path: filepath, + originalContent, + committedHash, + }); + const presentationId = rewritePresentation?.create({ threadId, source: { kind: "artifact", id: threadId, path: filepath }, fileName: getFileName(filepath), @@ -628,10 +649,10 @@ export function ArtifactFileDetail({ modelName: latest.request.modelName, generateImages: latest.request.generateImages, maxGeneratedImages: latest.request.maxGeneratedImages, - originalContent, - rewrittenContent, - committedHash, - localFallbackAvailable, + originalContent, + rewrittenContent, + committedHash, + localFallbackAvailable, requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "", }); if (!presentationId || !rewritePresentation) return; @@ -642,19 +663,19 @@ export function ArtifactFileDetail({ stage: result.status === "conflict" ? "conflict" : "completed", summary, warnings: summary?.validationWarnings ?? [], - versionId: typeof result.versionId === "string" ? result.versionId : null, - committedHash, - localFallbackAvailable, + versionId: typeof result.versionId === "string" ? result.versionId : null, + committedHash, + localFallbackAvailable, error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null, }); - if (rewrittenContent && result.status !== "conflict") { - setDraftContent(rewrittenContent); - setHasLocalEdits(versionSnapshotFailed); - if (versionSnapshotFailed) { - console.error( - "AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。", - ); - } + if (rewrittenContent && result.status !== "conflict") { + setDraftContent(rewrittenContent); + setHasLocalEdits(versionSnapshotFailed); + if (versionSnapshotFailed) { + console.error( + "AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。", + ); + } queryClient.setQueriesData( { queryKey: ["artifact", filepath, threadId, isMock] }, { content: rewrittenContent, url: undefined }, @@ -688,28 +709,28 @@ export function ArtifactFileDetail({ signal, ), onEvent: (event) => updatePresentation(presentationId, event), - onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => { - setDraftContent(rewritten); - setHasLocalEdits(versionSnapshotFailed); - const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({ - threadId, - path: filepath, - originalContent, - committedHash, - }); - if (!versionSnapshotFailed) { - clearArtifactRewriteFallback(threadId, filepath); - } - if (versionSnapshotFailed) { - console.error( - "AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。", - ); - } - rewritePresentation.update(presentationId, { - rewrittenContent: rewritten, - committedHash, - localFallbackAvailable, - }); + onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => { + setDraftContent(rewritten); + setHasLocalEdits(versionSnapshotFailed); + const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({ + threadId, + path: filepath, + originalContent, + committedHash, + }); + if (!versionSnapshotFailed) { + clearArtifactRewriteFallback(threadId, filepath); + } + if (versionSnapshotFailed) { + console.error( + "AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。", + ); + } + rewritePresentation.update(presentationId, { + rewrittenContent: rewritten, + committedHash, + localFallbackAvailable, + }); queryClient.setQueriesData( { queryKey: ["artifact", filepath, threadId, isMock] }, { content: rewritten, url: undefined }, @@ -724,11 +745,11 @@ export function ArtifactFileDetail({ return () => { disposed = true; }; - }, [ - artifactContentError, - canEditArtifact, - content, - documentRewrite, + }, [ + artifactContentError, + canEditArtifact, + content, + documentRewrite, documentRewriteActive, draftContent, filepath, @@ -780,29 +801,29 @@ export function ArtifactFileDetail({ rewritePresentation.update(presentationId, { thinkingText: thinking }); }, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]); - useEffect(() => { - const handleLocalRewriteUndoGuard = (event: Event) => { - const detail = (event as CustomEvent<{ - source?: { kind?: string; id?: string; path?: string }; - rewrittenContent?: string | null; - unchanged?: boolean; - }>).detail; - if ( - detail?.source?.kind !== "artifact" || - detail.source.id !== threadId || - detail.source.path !== filepath || - typeof detail.rewrittenContent !== "string" - ) { - return; - } - detail.unchanged = draftContent === detail.rewrittenContent; - }; - window.addEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard); - return () => window.removeEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard); - }, [draftContent, filepath, threadId]); - - useEffect(() => { - const handleRewriteUndo = (event: Event) => { + useEffect(() => { + const handleLocalRewriteUndoGuard = (event: Event) => { + const detail = (event as CustomEvent<{ + source?: { kind?: string; id?: string; path?: string }; + rewrittenContent?: string | null; + unchanged?: boolean; + }>).detail; + if ( + detail?.source?.kind !== "artifact" || + detail.source.id !== threadId || + detail.source.path !== filepath || + typeof detail.rewrittenContent !== "string" + ) { + return; + } + detail.unchanged = draftContent === detail.rewrittenContent; + }; + window.addEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard); + return () => window.removeEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard); + }, [draftContent, filepath, threadId]); + + useEffect(() => { + const handleRewriteUndo = (event: Event) => { const detail = (event as CustomEvent<{ source?: { kind?: string; id?: string; path?: string }; content?: string; @@ -1028,11 +1049,11 @@ export function ArtifactFileDetail({ }); setHasLocalEdits(false); toast.success("Markdown 已保存"); - } catch (error) { - console.error("Failed to save artifact:", error); - // Keep the draft and leave the button enabled. The caller can retry when - // the artifact endpoint recovers; this must never reset visible text. - setHasLocalEdits(true); + } catch (error) { + console.error("Failed to save artifact:", error); + // Keep the draft and leave the button enabled. The caller can retry when + // the artifact endpoint recovers; this must never reset visible text. + setHasLocalEdits(true); } }, [ canEditArtifact, @@ -1046,47 +1067,48 @@ export function ArtifactFileDetail({ const handleStartDocumentRewrite = useCallback( async (input: { - instruction: string; - style: "formal_analysis" | "professional" | "concise" | "readable"; - modelName: string | null; - reportOutline: string; - generateImages: boolean; + instruction: string; + style: "formal_analysis" | "professional" | "concise" | "readable"; + modelName: string | null; + reportOutline: string; + structureMode?: "fixed" | "adaptive"; + generateImages: boolean; maxGeneratedImages: number; }) => { - if (!canEditArtifact) { - toast.error("当前文件暂不支持全文改写。"); - return; - } - setDocumentRewriteDialogOpen(false); - setAllowAutoViewSwitch(false); - setAutoScrollEnabled(true); - setViewMode("code"); - - // Do not make users manually save before using AI rewrite. Persist their - // current draft first so the rewrite starts from exactly what they see, - // and the optimistic hash remains valid on the server. - if (hasLocalEdits) { - try { - await updateArtifact.mutateAsync({ - filepath, - threadId, - isMock, - content: draftContent, - }); - setHasLocalEdits(false); - } catch (error) { - console.error("Failed to save Markdown before AI rewrite:", error); - setHasLocalEdits(true); - return; - } - } - - primeArtifactRewriteFallback({ - threadId, - path: filepath, - originalContent: draftContent, - }); - const expectedHash = await sha256Content(draftContent); + if (!canEditArtifact) { + toast.error("当前文件暂不支持全文改写。"); + return; + } + setDocumentRewriteDialogOpen(false); + setAllowAutoViewSwitch(false); + setAutoScrollEnabled(true); + setViewMode("code"); + + // Do not make users manually save before using AI rewrite. Persist their + // current draft first so the rewrite starts from exactly what they see, + // and the optimistic hash remains valid on the server. + if (hasLocalEdits) { + try { + await updateArtifact.mutateAsync({ + filepath, + threadId, + isMock, + content: draftContent, + }); + setHasLocalEdits(false); + } catch (error) { + console.error("Failed to save Markdown before AI rewrite:", error); + setHasLocalEdits(true); + return; + } + } + + primeArtifactRewriteFallback({ + threadId, + path: filepath, + originalContent: draftContent, + }); + const expectedHash = await sha256Content(draftContent); const presentationId = rewritePresentation?.create({ threadId, source: { kind: "artifact", id: threadId, path: filepath }, @@ -1097,22 +1119,23 @@ export function ArtifactFileDetail({ generateImages: input.generateImages, maxGeneratedImages: input.maxGeneratedImages, originalContent: draftContent, - }); - setDocumentRewritePresentationId(presentationId ?? null); - window.dispatchEvent( - new CustomEvent(DOCUMENT_REWRITE_STARTED_EVENT, { - detail: { threadId }, - }), - ); - void documentRewrite.start( + }); + setDocumentRewritePresentationId(presentationId ?? null); + window.dispatchEvent( + new CustomEvent(DOCUMENT_REWRITE_STARTED_EVENT, { + detail: { threadId }, + }), + ); + void documentRewrite.start( { threadId, path: filepath, - instruction: input.instruction, - style: input.style, - modelName: input.modelName ?? undefined, - reportOutline: input.reportOutline || undefined, - generateImages: input.generateImages, + instruction: input.instruction, + style: input.style, + modelName: input.modelName ?? undefined, + reportOutline: input.reportOutline || undefined, + structureMode: input.structureMode, + generateImages: input.generateImages, maxGeneratedImages: input.maxGeneratedImages, expectedHash, }, @@ -1188,11 +1211,11 @@ export function ArtifactFileDetail({ case "commit_started": rewritePresentation.update(presentationId, { stage: "committing" }); break; - case "committed": - rewritePresentation.update(presentationId, { - versionId: event.versionId ?? null, - committedHash: event.committedHash, - }); + case "committed": + rewritePresentation.update(presentationId, { + versionId: event.versionId ?? null, + committedHash: event.committedHash, + }); break; case "done": rewritePresentation.update(presentationId, { @@ -1206,33 +1229,33 @@ export function ArtifactFileDetail({ error: event.message, }); break; - case "error": - // The sandbox retains the visible draft and exposes manual - // save instead of showing an error state to the user. - console.error("AI 全文改写接口失败:", event.message); - break; + case "error": + // The sandbox retains the visible draft and exposes manual + // save instead of showing an error state to the user. + console.error("AI 全文改写接口失败:", event.message); + break; default: break; } }, - onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => { - setDraftContent(rewritten); - setHasLocalEdits(versionSnapshotFailed); - const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({ - threadId, - path: filepath, - originalContent: draftContent, - committedHash, - }); - if (!versionSnapshotFailed) { - clearArtifactRewriteFallback(threadId, filepath); - } - if (presentationId && rewritePresentation) { - rewritePresentation.update(presentationId, { - rewrittenContent: rewritten, - committedHash, - localFallbackAvailable, - }); + onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => { + setDraftContent(rewritten); + setHasLocalEdits(versionSnapshotFailed); + const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({ + threadId, + path: filepath, + originalContent: draftContent, + committedHash, + }); + if (!versionSnapshotFailed) { + clearArtifactRewriteFallback(threadId, filepath); + } + if (presentationId && rewritePresentation) { + rewritePresentation.update(presentationId, { + rewrittenContent: rewritten, + committedHash, + localFallbackAvailable, + }); } queryClient.setQueriesData( { @@ -1240,51 +1263,51 @@ export function ArtifactFileDetail({ }, { content: rewritten, url: undefined }, ); - if (versionSnapshotFailed) { - console.error( - "AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。", - ); - } else { - toast.success("Markdown 全文改写完成,已安全替换原文件。"); - } - }, - onFailure: ({ content, error }) => { - // The rewrite transport can fail after the server has already - // streamed text (or before the first token on a 404). Keep that - // visible content in the sandbox, make Save actionable, and leave - // diagnostics to the developer console only. - const retainedContent = content || draftContent; - setDraftContent(retainedContent); - setHasLocalEdits(true); - queryClient.setQueriesData( - { - queryKey: ["artifact", filepath, threadId, isMock], - }, - { content: retainedContent, url: undefined }, - ); - if (presentationId && rewritePresentation) { - rewritePresentation.update(presentationId, { - stage: "completed", - rewrittenContent: retainedContent, - error: null, - }); - } - console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error); - }, - }, - ); + if (versionSnapshotFailed) { + console.error( + "AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。", + ); + } else { + toast.success("Markdown 全文改写完成,已安全替换原文件。"); + } + }, + onFailure: ({ content, error }) => { + // The rewrite transport can fail after the server has already + // streamed text (or before the first token on a 404). Keep that + // visible content in the sandbox, make Save actionable, and leave + // diagnostics to the developer console only. + const retainedContent = content || draftContent; + setDraftContent(retainedContent); + setHasLocalEdits(true); + queryClient.setQueriesData( + { + queryKey: ["artifact", filepath, threadId, isMock], + }, + { content: retainedContent, url: undefined }, + ); + if (presentationId && rewritePresentation) { + rewritePresentation.update(presentationId, { + stage: "completed", + rewrittenContent: retainedContent, + error: null, + }); + } + console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error); + }, + }, + ); }, [ - canEditArtifact, - draftContent, - documentRewrite, - filepath, - hasLocalEdits, - isMock, - queryClient, - rewritePresentation, - threadId, - updateArtifact, + canEditArtifact, + draftContent, + documentRewrite, + filepath, + hasLocalEdits, + isMock, + queryClient, + rewritePresentation, + threadId, + updateArtifact, ], ); @@ -1530,15 +1553,15 @@ export function ArtifactFileDetail({ label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"} tooltip={ documentRewriteActive - ? "正在生成临时草稿,原文件尚未被覆盖" - : hasLocalEdits - ? "将先保存当前 Markdown 修改,再开始改写" - : "使用 AI 重写当前完整 Markdown" + ? "正在生成临时草稿,原文件尚未被覆盖" + : hasLocalEdits + ? "将先保存当前 Markdown 修改,再开始改写" + : "使用 AI 重写当前完整 Markdown" } - // Keep the entry available after a completed rewrite and when - // the user has local edits. Starting with local edits saves - // them automatically in handleStartDocumentRewrite. - onClick={() => setDocumentRewriteDialogOpen(true)} + // Keep the entry available after a completed rewrite and when + // the user has local edits. Starting with local edits saves + // them automatically in handleStartDocumentRewrite. + onClick={() => setDocumentRewriteDialogOpen(true)} /> )} {!useWriteFileStreaming && filepath.endsWith(".skill") && ( @@ -1710,28 +1733,28 @@ export function ArtifactFileDetail({ activeCursor={isApplyingEdit} /> )} - {artifactContentPending && viewMode === "code" && ( -
-
- )} - {artifactContentUnavailable && viewMode === "code" && ( -
-
- )} - {isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && ( + {artifactContentPending && viewMode === "code" && ( +
+
+ )} + {artifactContentUnavailable && viewMode === "code" && ( +
+
+ )} + {isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
(); - const [mermaidSvg, setMermaidSvg] = useState(null); - const [mermaidError, setMermaidError] = useState(null); - const researchSourcesQuery = useResearchSources( - language === "markdown" ? (researchSessionId ?? null) : null, - ); - const previewContent = useMemo( - () => researchSessionId - ? formatResearchCitationsForPreview(content ?? "", researchSourcesQuery.data?.items ?? []) - : (content ?? ""), - [content, researchSessionId, researchSourcesQuery.data?.items], - ); - const markdownComponents = useMemo(() => { - const anchorResolver = createMarkdownAnchorResolver(previewContent); +}) { + const [htmlPreviewUrl, setHtmlPreviewUrl] = useState(); + const [mermaidSvg, setMermaidSvg] = useState(null); + const [mermaidError, setMermaidError] = useState(null); + const researchSourcesQuery = useResearchSources( + language === "markdown" ? (researchSessionId ?? null) : null, + ); + const previewContent = useMemo( + () => researchSessionId + ? formatResearchCitationsForPreview(content ?? "", researchSourcesQuery.data?.items ?? []) + : preserveMarkdownStandaloneLines(content ?? ""), + [content, researchSessionId, researchSourcesQuery.data?.items], + ); + const markdownComponents = useMemo(() => { + const anchorResolver = createMarkdownAnchorResolver(previewContent); const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { const Tag = `h${level}` as const; return ({ children, id, ...props }: HTMLAttributes) => ( @@ -2016,7 +2039,7 @@ export function ArtifactFilePreview({ ); }, }; - }, [previewContent, publicShareCode, researchSessionId, threadId]); + }, [previewContent, publicShareCode, researchSessionId, threadId]); useEffect(() => { if (language !== "html") { @@ -2105,8 +2128,8 @@ export function ArtifactFilePreview({ ), }} > - {previewContent} - + {previewContent} + {activeCursor && ( )} diff --git a/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx b/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx index 16cee043..23a33a9d 100644 --- a/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx +++ b/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx @@ -23,7 +23,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { ModelNameSelect } from "@/components/workspace/model-name-select"; import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite"; -import { useReportStructures } from "@/strategy-components/api/report-structures"; +import { useReportStructures, REPORT_STRUCTURE_MODE_OPTIONS, normalizeReportStructureMode, type ReportStructureMode } from "@/strategy-components/api/report-structures"; const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__"; const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。"; @@ -34,6 +34,7 @@ export interface DocumentRewriteDialogInput { style: DocumentRewriteStyle; modelName: string | null; reportOutline: string; + structureMode: "fixed" | "adaptive"; generateImages: boolean; maxGeneratedImages: number; } @@ -99,6 +100,7 @@ export function DocumentRewriteDialog({ const [style, setStyle] = useState("formal_analysis"); const [modelName, setModelName] = useState(null); const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE); + const [structureMode, setStructureMode] = useState("adaptive"); const [generateImages, setGenerateImages] = useState(false); const [imageCount, setImageCount] = useState("2"); const reportStructuresQuery = useReportStructures({ status: "enabled" }); @@ -116,6 +118,7 @@ export function DocumentRewriteDialog({ setStyle("formal_analysis"); setModelName(null); setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE); + setStructureMode("adaptive"); setGenerateImages(false); setImageCount("2"); }, [isNewReportGeneration, open]); @@ -130,6 +133,11 @@ export function DocumentRewriteDialog({ )); }, [initialReportStructureId, open]); + useEffect(() => { + if (!selectedReportStructure) return; + setStructureMode(selectedReportStructure.structureMode); + }, [selectedReportStructure?.id, selectedReportStructure?.structureMode]); + const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style); const canStart = instruction.trim().length > 0; const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => { @@ -139,6 +147,7 @@ export function DocumentRewriteDialog({ style, modelName, reportOutline: selectedReportStructure?.content ?? "", + structureMode, generateImages, maxGeneratedImages: Math.min( Math.max(Number(imageCount) || 1, 1), @@ -179,7 +188,7 @@ export function DocumentRewriteDialog({ /> -
+
+
) : null; + } else if (group.type === "assistant:setup-report-structure-approval") { + const message = group.messages[0]; + return message ? ( +
+ + +
+ ) : null; } else if (group.type === "assistant:research-approval") { // 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该 // 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。 @@ -860,6 +881,15 @@ export type SetupWritingApprovalPayload = { outline?: string; }; +export type SetupReportStructureApprovalPayload = { + title?: string; + name?: string; + content?: string; + structure_mode?: string; + retrieval_directions?: string[]; + retrieval_skills?: string[]; +}; + function findPreviousHumanMessageId( messages: Message[], beforeMessageId?: string, @@ -1230,6 +1260,147 @@ function SetupWritingApprovalCard({ ); } +function getSetupReportStructureApprovalPayload( + message: unknown, +): SetupReportStructureApprovalPayload | null { + if (typeof message !== "object" || message === null) { + return null; + } + const additionalKwargs = Reflect.get(message, "additional_kwargs"); + if (typeof additionalKwargs !== "object" || additionalKwargs === null) { + return null; + } + const approval = Reflect.get(additionalKwargs, "setup_report_structure_approval"); + if (typeof approval !== "object" || approval === null) { + return null; + } + return approval as SetupReportStructureApprovalPayload; +} + +const STRUCTURE_MODE_LABELS: Record = { + fixed: "固定大纲", + adaptive: "弹性大纲", +}; + +function SetupReportStructureApprovalCard({ + disabled, + message, + onApprove, + onReject, +}: { + disabled: boolean; + message: unknown; + onApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise; + onReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise; +}) { + const payload = getSetupReportStructureApprovalPayload(message); + const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(null); + + if (!payload) { + return null; + } + + const approvalPayload = payload; + const busy = disabled || submitting !== null; + const directions = approvalPayload.retrieval_directions ?? []; + const skills = approvalPayload.retrieval_skills ?? []; + + async function run(action: "approve" | "reject") { + if (busy) { + return; + } + setSubmitting(action); + try { + if (action === "approve") { + await onApprove?.(approvalPayload); + } else { + await onReject?.(approvalPayload); + } + } finally { + setSubmitting(null); + } + } + + return ( +
+
+
+
报告结构建议
+
确认后将应用到右侧表单,再点保存入库。
+
+ + {approvalPayload.title ? ( +
+
标题
+

{approvalPayload.title}

+
+ ) : null} + + {approvalPayload.name ? ( +
+
描述
+

{approvalPayload.name}

+
+ ) : null} + +
+ {approvalPayload.structure_mode ? ( +
+
结构约束
+

+ {STRUCTURE_MODE_LABELS[approvalPayload.structure_mode] ?? approvalPayload.structure_mode} +

+
+ ) : null} + {directions.length > 0 ? ( +
+
检索方向
+

{directions.join("、")}

+
+ ) : null} + {skills.length > 0 ? ( +
+
检索来源
+

{skills.join("、")}

+
+ ) : null} +
+ + {approvalPayload.content ? ( +
+
报告大纲
+
+              {approvalPayload.content}
+            
+
+ ) : null} +
+ +
+ + +
+
+ ); +} + function getClarificationPayload(message: unknown): ClarificationPayload | null { if (typeof message !== "object" || message === null) { return null; diff --git a/frontend-web/src/core/auth/host-navigate.test.ts b/frontend-web/src/core/auth/host-navigate.test.ts new file mode 100644 index 00000000..291a25a0 --- /dev/null +++ b/frontend-web/src/core/auth/host-navigate.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + HOST_NAVIGATE_TYPE, + buildHostNavigateMessage, + isHostNavigatePath, + parseHostTaskId, + resolveHostNavigateOrigin, +} from "./host-navigate"; + +test("isHostNavigatePath accepts a host route", () => { + assert.equal(isHostNavigatePath("/task/item-aiceh/step-analysis-audience"), true); + assert.equal(isHostNavigatePath(" /task/foo "), true); +}); + +test("isHostNavigatePath rejects urls and protocol-relative paths", () => { + assert.equal(isHostNavigatePath(""), false); + assert.equal(isHostNavigatePath("https://localhost:4912/task"), false); + assert.equal(isHostNavigatePath("//evil.example/task"), false); + assert.equal(isHostNavigatePath("task/foo"), false); + assert.equal(isHostNavigatePath("/task\\foo"), false); +}); + +test("parseHostTaskId returns a number", () => { + assert.equal(parseHostTaskId("108"), 108); + assert.equal(parseHostTaskId(" 108 "), 108); + assert.equal(parseHostTaskId("0108"), 108); +}); + +test("parseHostTaskId rejects non-integers", () => { + assert.equal(parseHostTaskId(""), null); + assert.equal(parseHostTaskId("abc"), null); + assert.equal(parseHostTaskId("108.5"), null); + assert.equal(parseHostTaskId("12e3"), null); + assert.equal(parseHostTaskId(null), null); +}); + +test("resolveHostNavigateOrigin prefers configured origin", () => { + assert.equal( + resolveHostNavigateOrigin({ + configured: "https://localhost:4912", + ancestorOrigin: "https://other.example", + referrer: "https://referrer.example/page", + selfOrigin: "http://localhost:5174", + }), + "https://localhost:4912", + ); +}); + +test("resolveHostNavigateOrigin strips a path from configured origin", () => { + assert.equal( + resolveHostNavigateOrigin({ configured: "https://localhost:4912/app" }), + "https://localhost:4912", + ); +}); + +test("resolveHostNavigateOrigin falls back to ancestorOrigins then referrer", () => { + assert.equal( + resolveHostNavigateOrigin({ + ancestorOrigin: "https://host.example", + referrer: "https://referrer.example/", + selfOrigin: "http://localhost:5174", + }), + "https://host.example", + ); + assert.equal( + resolveHostNavigateOrigin({ + referrer: "https://referrer.example/page", + selfOrigin: "http://localhost:5174", + }), + "https://referrer.example", + ); +}); + +test("resolveHostNavigateOrigin never uses * or same-origin referrer", () => { + assert.equal(resolveHostNavigateOrigin({ configured: "*" }), null); + assert.equal( + resolveHostNavigateOrigin({ + referrer: "http://localhost:5174/page/foo", + selfOrigin: "http://localhost:5174", + }), + null, + ); + assert.equal(resolveHostNavigateOrigin({}), null); +}); + +test("buildHostNavigateMessage matches the host contract", () => { + assert.deepEqual( + buildHostNavigateMessage("/task/item-aiceh/step-analysis-audience", { id: 108 }), + { + type: HOST_NAVIGATE_TYPE, + path: "/task/item-aiceh/step-analysis-audience", + queryParams: { id: 108 }, + }, + ); +}); diff --git a/frontend-web/src/core/auth/host-navigate.ts b/frontend-web/src/core/auth/host-navigate.ts new file mode 100644 index 00000000..27351db7 --- /dev/null +++ b/frontend-web/src/core/auth/host-navigate.ts @@ -0,0 +1,95 @@ +/** + * iframe 嵌入宿主系统时,任务跳转按钮通过 ``window.parent.postMessage`` 让 + * **父页面**改路由(宿主协议 ``HOST_NAVIGATE``)。不改 iframe 自己的地址。 + * + * 消息形状对齐宿主约定: + * ``{ type: 'HOST_NAVIGATE', path, queryParams: { id: number } }`` + * 第二参必须是宿主 origin,禁止 ``'*'``。 + */ + +export const HOST_NAVIGATE_TYPE = "HOST_NAVIGATE"; + +export interface HostNavigateMessage { + type: typeof HOST_NAVIGATE_TYPE; + path: string; + queryParams: Record; +} + +/** 宿主路由:以单个 ``/`` 开头的路径,不含 ``//`` / 反斜杠。 */ +export function isHostNavigatePath(path: string): boolean { + const value = path.trim(); + if (!value || value.length > 2048) return false; + if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false; + return true; +} + +/** 任务 id 转数字。只接受整数字符串(可带正负号),否则返回 null。 */ +export function parseHostTaskId(raw: string | null | undefined): number | null { + const value = (raw ?? "").trim(); + if (!value || !/^-?\d+$/.test(value)) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function normalizeOrigin(value: string | null | undefined): string | null { + const raw = (value ?? "").trim(); + if (!raw || raw === "*") return null; + try { + const url = new URL(raw); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.origin; + } catch { + return null; + } +} + +/** + * 解析 postMessage 的 targetOrigin。 + * 优先级:显式配置 → ``ancestorOrigins[0]`` → 跨源 ``document.referrer``。 + * 解析失败返回 null(调用方应中止,不得退回 ``'*'``)。 + */ +export function resolveHostNavigateOrigin(input: { + configured?: string | null; + ancestorOrigin?: string | null; + referrer?: string | null; + selfOrigin?: string | null; +}): string | null { + const configured = normalizeOrigin(input.configured); + if (configured) return configured; + const ancestor = normalizeOrigin(input.ancestorOrigin); + if (ancestor) return ancestor; + const referrer = normalizeOrigin(input.referrer); + if (!referrer) return null; + const selfOrigin = normalizeOrigin(input.selfOrigin); + if (selfOrigin && referrer === selfOrigin) return null; + return referrer; +} + +export function readAncestorOrigin(): string | null { + if (typeof window === "undefined") return null; + try { + return window.location.ancestorOrigins?.[0] ?? null; + } catch { + return null; + } +} + +export function isEmbeddedInParent(): boolean { + if (typeof window === "undefined") return false; + try { + return window.parent !== window; + } catch { + return true; + } +} + +export function buildHostNavigateMessage( + path: string, + queryParams: Record = {}, +): HostNavigateMessage { + return { + type: HOST_NAVIGATE_TYPE, + path, + queryParams, + }; +} diff --git a/frontend-web/src/core/auth/task-buttons.ts b/frontend-web/src/core/auth/task-buttons.ts index 2cfe8604..4246bb43 100644 --- a/frontend-web/src/core/auth/task-buttons.ts +++ b/frontend-web/src/core/auth/task-buttons.ts @@ -7,9 +7,11 @@ * 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时 * config 进种子,避免写死;一旦保存过,后端即唯一数据源。 * - * 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头 + * 按钮类型(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头 * 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/ - * ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。 + * ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)/ + * ``purpose`` 弹出任务目的树 / ``host`` iframe 内向父页面 ``postMessage({ type:'HOST_NAVIGATE' })`` + * 让宿主系统跳转(见 [[host-navigate]])。 */ import { @@ -25,6 +27,15 @@ import { readLoginToken, readLoginUsername, } from "@/core/auth/login-credentials"; +import { + buildHostNavigateMessage, + isEmbeddedInParent, + isHostNavigatePath, + parseHostTaskId, + readAncestorOrigin, + resolveHostNavigateOrigin, +} from "@/core/auth/host-navigate"; +import { readRuntimeEnv } from "@/env"; /** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */ export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent"; @@ -62,8 +73,8 @@ export const TASK_BUSINESSES: { }, ]; -/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格(无需 target)。 */ -export type TaskButtonLinkType = "url" | "business" | "purpose"; +/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格 / ``host``=宿主路由(postMessage)。 */ +export type TaskButtonLinkType = "url" | "business" | "purpose" | "host"; /** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */ export type TaskButtonIdKind = "task" | "action"; @@ -91,7 +102,7 @@ export interface TaskButton { business: TaskBusiness; label: string; linkType: TaskButtonLinkType; - /** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */ + /** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``);``host``=宿主系统路由(如 ``/task/...``)。 */ target: string; /** ``url`` 类型是否追加 id 查询参数。 */ appendTaskId: boolean; @@ -316,11 +327,46 @@ export interface RunTaskButtonCtx { * 执行一个跳转按钮: * - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。 * - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。 + * - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。 */ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise { const fail = (m: string) => ctx.onError?.(m); // purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。 if (btn.linkType === "purpose") return; + if (btn.linkType === "host") { + const path = btn.target.trim(); + if (!isHostNavigatePath(path)) { + fail(`未配置「${btn.label}」的宿主路由`); + return; + } + if (!isEmbeddedInParent()) { + fail("当前不在嵌入页,无法跳转宿主系统"); + return; + } + const origin = resolveHostNavigateOrigin({ + configured: readRuntimeEnv("VITE_HOST_NAVIGATE_ORIGIN"), + ancestorOrigin: readAncestorOrigin(), + referrer: typeof document !== "undefined" ? document.referrer : null, + selfOrigin: typeof window !== "undefined" ? window.location.origin : null, + }); + if (!origin) { + fail("未配置宿主地址(VITE_HOST_NAVIGATE_ORIGIN),无法安全跳转"); + return; + } + const queryParams: Record = {}; + if (btn.appendTaskId) { + const id = parseHostTaskId(ctx.taskId); + if (id === null) { + fail("任务id不是数字,无法跳转宿主系统"); + return; + } + queryParams[(btn.idParam || "id").trim() || "id"] = id; + } + const message = buildHostNavigateMessage(path, queryParams); + console.info(`[task-button] 「${btn.label}」宿主路由 →`, message, origin); + window.parent.postMessage(message, origin); + return; + } if (btn.linkType === "url") { const base = btn.target.trim(); if (!base) { diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index 86c45cf9..5c943a0f 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -95,25 +95,41 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail { }; } -/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法时返回 null。 */ +/** 详情接口不可用时仍能继续深链跳转:至少带上任务 id,标题/简介可空。 */ +export function stubCopTaskDetail(taskId: string): CopTaskDetail { + return { + id: taskId, + overview: "", + taskDirection: "", + taskName: "", + taskContent: "", + }; +} + +/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法 / 网络失败时返回 null。 */ export async function fetchCopTaskDetail(taskId: string): Promise { if (copTaskDetailMockEnabled()) { return mockCopTaskDetail(taskId); } const base = consumerBaseUrl(); if (!base || !taskId) return null; - // service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。 - const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId }); - const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null; - if (!data || typeof data !== "object") return null; - const d = data as Record; - return { - id: (d.id as string | number | null) ?? null, - overview: typeof d.overview === "string" ? d.overview : "", - taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "", - taskName: typeof d.taskName === "string" ? d.taskName : "", - taskContent: typeof d.taskContent === "string" ? d.taskContent : "", - }; + try { + // service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。 + const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId }); + const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null; + if (!data || typeof data !== "object") return null; + const d = data as Record; + return { + id: (d.id as string | number | null) ?? null, + overview: typeof d.overview === "string" ? d.overview : "", + taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "", + taskName: typeof d.taskName === "string" ? d.taskName : "", + taskContent: typeof d.taskContent === "string" ? d.taskContent : "", + }; + } catch (error) { + console.warn("[task-deeplink] cop-task-detail failed", { taskId, error }); + return null; + } } /** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */ @@ -223,7 +239,12 @@ export async function fetchTaskIdFromAction(actionId: string): Promise { export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise { const parsed = parseGoPath(goPath); if (parsed?.businessCode === XDFX_BUSINESS_CODE) { - return fetchTaskIdFromAction(rawId); + try { + return (await fetchTaskIdFromAction(rawId)) || ""; + } catch (error) { + console.warn("[task-deeplink] action→taskId resolve failed", { rawId, error }); + return ""; + } } return rawId; } @@ -332,10 +353,14 @@ export async function resolveTaskDeeplinkTarget( try { const mappings = await listBusinessMappings(); agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null; - } catch { + } catch (error) { + console.warn("[task-deeplink] business mapping failed", error); agentId = null; } - if (!agentId) return null; + if (!agentId) { + console.warn("[task-deeplink] no agent mapped for", parsed.businessCode); + return null; + } // 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表): // 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定 diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index 635f1dd5..9fb76bb2 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -22,6 +22,8 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {} +interface AssistantSetupReportStructureApprovalGroup extends GenericMessageGroup<"assistant:setup-report-structure-approval"> {} + interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {} interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {} @@ -34,6 +36,7 @@ type MessageGroup = | AssistantClarificationGroup | AssistantSetupAgentApprovalGroup | AssistantSetupWritingApprovalGroup + | AssistantSetupReportStructureApprovalGroup | AssistantResearchApprovalGroup | AssistantSubagentGroup; @@ -58,6 +61,7 @@ export function groupMessages( last.type !== "assistant:clarification" && last.type !== "assistant:setup-agent-approval" && last.type !== "assistant:setup-writing-approval" && + last.type !== "assistant:setup-report-structure-approval" && last.type !== "assistant:research-approval" ) { return last; @@ -103,6 +107,13 @@ export function groupMessages( type: "assistant:setup-writing-approval", messages: [message], }); + } else if (isSetupReportStructureApprovalToolMessage(message)) { + lastOpenGroup()?.messages.push(message); + groups.push({ + id: message.id, + type: "assistant:setup-report-structure-approval", + messages: [message], + }); } else { const open = lastOpenGroup(); if (open) { @@ -419,6 +430,15 @@ export function isSetupWritingApprovalToolMessage(message: Message) { ); } +export function isSetupReportStructureApprovalToolMessage(message: Message) { + return ( + message.type === "tool" && + message.name === "setup_report_structure" && + typeof message.additional_kwargs?.setup_report_structure_approval === "object" && + message.additional_kwargs.setup_report_structure_approval !== null + ); +} + export function hasResearchApproval(message: Message) { return ( message.type === "ai" && diff --git a/frontend-web/src/open-canvas/api/rewrite.ts b/frontend-web/src/open-canvas/api/rewrite.ts index fa73db08..a127b9a3 100644 --- a/frontend-web/src/open-canvas/api/rewrite.ts +++ b/frontend-web/src/open-canvas/api/rewrite.ts @@ -121,6 +121,8 @@ export interface DocumentRewriteRequest { modelName?: string; /** Optional Markdown chapter structure selected from the report-structure library. */ reportOutline?: string; + /** ``fixed`` reproduces the outline; ``adaptive`` keeps the material-driven layout. */ + structureMode?: "fixed" | "adaptive"; /** Generate locally saved illustration assets after the prose rewrite. */ generateImages?: boolean; /** Bounded server-side to 1–4. Ignored unless generateImages is true. */ diff --git a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx index e3652c78..0b45ec4d 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx @@ -165,6 +165,23 @@ export function WritingSetupChat({ [thread, ensureThreadExists, sendMessage, threadId, formCtx], ); + const handleClarificationSubmit = useCallback( + async (text: string) => { + if (thread.isLoading) { + try { + await Promise.resolve(thread.stop()); + } catch { + /* still send the answer */ + } + } + const trimmed = text.trim(); + if (!trimmed) return; + await ensureThreadExists(); + await sendMessage(threadId, { text: trimmed, files: [] }, buildWritingSampleContext(formCtx)); + }, + [thread, ensureThreadExists, sendMessage, threadId, formCtx], + ); + const publishSampleMessage = useCallback( (filename: string, text: string) => { const normalized = text.trim(); @@ -362,7 +379,7 @@ export function WritingSetupChat({ className="size-full" threadId={threadId} thread={displayThread} - onClarificationSubmit={handleChatSubmit} + onClarificationSubmit={handleClarificationSubmit} onSetupWritingApprove={handleApprove} onSetupWritingReject={handleReject} onSetupWritingStart={canStartWriting ? handleStart : undefined} diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index e6a92fcc..f8375a76 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -18,11 +18,13 @@ import { readExternalLoginParams, resolveThemeFromQuery, } from '@/core/auth/external-login'; +import { toast } from 'sonner'; import { fetchCopTaskDetail, persistCopTaskDetail, resolveDeeplinkTaskId, resolveTaskDeeplinkTarget, + stubCopTaskDetail, } from '@/core/auth/task-deeplink'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; @@ -61,6 +63,17 @@ function maskLoginToken(value: string): string { return `${value.slice(0, 6)}...${value.slice(-4)}(${value.length})`; } +/** + * Hash 路由下深链参数可能写在 ``#/login?...``,也可能写在 hash 前的 + * ``?taskId=&goPath=#/login``。两边都读,避免宿主把 query 放在外层 URL 时丢参落到首页。 + */ +function readLoginQuery(searchParams: URLSearchParams, key: string): string { + const fromHash = searchParams.get(key)?.trim() ?? ''; + if (fromHash) return fromHash; + if (typeof window === 'undefined') return ''; + return new URLSearchParams(window.location.search).get(key)?.trim() ?? ''; +} + /** 读取已落地的 userInfo(脱敏 access token 后)供登录后排查。 */ function summarizeStoredUserInfo(): Record | null { try { @@ -206,8 +219,8 @@ export default function LoginPage() { const effectiveGatePassword = configuredRouteLoginPassword || gatePassword; const usesConfiguredRouteLoginPassword = Boolean(configuredRouteLoginPassword); // 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页 - const taskId = searchParams.get('taskId')?.trim() ?? ''; - const goPath = searchParams.get('goPath')?.trim() ?? ''; + const taskId = readLoginQuery(searchParams, 'taskId'); + const goPath = readLoginQuery(searchParams, 'goPath'); // 通用「登录后跳指定页」:?redirect=page/strategy/dashboard-agent(值可不带前导斜杠, // 也可自带 query,如 page/strategy/dashboard-agent?taskId=1580——记得整体 URL 编码)。 // 优先级最高:显式指定即按它跳,先于 taskId/goPath 深链与默认首页。出于安全只接受**站内 @@ -447,26 +460,38 @@ export default function LoginPage() { return; } - // 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页; - // 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。 + // 任务深链:带 taskId + goPath 时直送对应问答/会商页。 + // 任务详情拉取失败不再阻断跳转(本地/外网 consumer 常不可达),用仅含 taskId 的 stub 继续。 + // 真正无法解析(goPath 无法识别 / 单智能体未配置业务映射)才回退默认首页。 if (taskId && goPath) { try { - // xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;其它业务原样。 - const realTaskId = await resolveDeeplinkTaskId(goPath, taskId); + // xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;解析失败回退原始 id。 + const resolvedTaskId = await resolveDeeplinkTaskId(goPath, taskId); + const realTaskId = resolvedTaskId || taskId; if (cancelled) return; - const detail = await fetchCopTaskDetail(realTaskId); + let detail = await fetchCopTaskDetail(realTaskId); if (cancelled) return; - if (detail) { - persistCopTaskDetail(detail); - const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId); - if (cancelled) return; - if (target) { - navigate(target.path, { replace: true, state: target.state }); - return; - } + if (!detail) { + console.warn('[login] cop-task-detail missing, continue with stub', { + goPath, + taskId, + realTaskId, + }); + detail = stubCopTaskDetail(realTaskId); } - } catch { - /* 深链失败不阻断登录,落默认首页 */ + persistCopTaskDetail(detail); + const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId); + if (cancelled) return; + if (target) { + console.info('[login] deeplink →', target.path, { goPath, realTaskId }); + navigate(target.path, { replace: true, state: target.state }); + return; + } + console.warn('[login] deeplink target unresolved', { goPath, realTaskId }); + toast.warning('深链未配置对应智能体或 goPath 无法识别,已进入首页'); + } catch (error) { + console.warn('[login] deeplink failed, fallback home', error); + toast.warning('深链跳转失败,已进入首页'); } } diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 70b7da6a..f8843bb4 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -26,6 +26,7 @@ import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtab import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage"; import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage"; import DeepResearchPage from "./DeepResearchPage"; +import ReportStructureCreatePage from "./deep-research/ReportStructureCreatePage"; import EnterpriseResearchPage from "./EnterpriseResearchPage"; import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage"; import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage"; @@ -118,6 +119,14 @@ export default function StrategyRoutes() { } /> } /> + + + + } + /> } /> {/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */} diff --git a/frontend-web/src/pages/TaskButtonsPage.tsx b/frontend-web/src/pages/TaskButtonsPage.tsx index a4ce58b8..554d18cf 100644 --- a/frontend-web/src/pages/TaskButtonsPage.tsx +++ b/frontend-web/src/pages/TaskButtonsPage.tsx @@ -173,7 +173,7 @@ export default function TaskButtonsPage() {
按钮管理
- 配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序) + 配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)。「宿主路由」用于 iframe 套用时让对方系统跳转。
@@ -265,13 +265,14 @@ export default function TaskButtonsPage() { value={r.linkType} onValueChange={(v) => update(r.id, { linkType: v as TaskButton["linkType"] })} > - + 外部链接 切换业务 目的树弹框 + 宿主路由
@@ -282,6 +283,39 @@ export default function TaskButtonsPage() { 点击弹出当前任务的「任务目的」表格,无需配置目标。 + ) : r.linkType === "host" ? ( + <> + update(r.id, { target: e.target.value })} + placeholder="/task/…(宿主系统路由)" + className="h-8 flex-1 min-w-[16rem]" + title="宿主系统路由,点击后通过 postMessage 让父页面跳转" + /> + + {r.appendTaskId && ( +
+ queryParams. + update(r.id, { idParam: e.target.value })} + placeholder="id" + className="h-8 w-24" + title="发给宿主的参数名(默认 id)" + /> + = 任务id(数字) +
+ )} + + iframe 嵌入时向父页面发送 HOST_NAVIGATE,由宿主系统跳转;独立打开本系统时无效。 + + ) : r.linkType === "url" ? ( <> } /> + + + + + + } + /> >({}); + const [reportCardStructureModeOverrides, setReportCardStructureModeOverrides] = useState>({}); + const [reportCardRetrievalDirectionsOverrides, setReportCardRetrievalDirectionsOverrides] = useState>({}); + const [reportCardRetrievalSkillsOverrides, setReportCardRetrievalSkillsOverrides] = useState>({}); const [classicMode, setClassicMode] = useState(false); const [selectedModel, setSelectedModel] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); @@ -534,6 +543,9 @@ function DeepResearchWorkbenchInner() { const streamJobId = activeJobId ?? lastCompletedJobId; const isFollowUp = session?.status === "completed" && Boolean(session.reportMarkdown); const reportCardOutlineOverride = session ? reportCardOutlineOverrides[session.id] : undefined; + const reportCardStructureModeOverride = session ? reportCardStructureModeOverrides[session.id] : undefined; + const reportCardRetrievalDirectionsOverride = session ? reportCardRetrievalDirectionsOverrides[session.id] : undefined; + const reportCardRetrievalSkillsOverride = session ? reportCardRetrievalSkillsOverrides[session.id] : undefined; const availableModes = useMemo( () => capabilities.data?.modes ?? DEFAULT_AVAILABLE_MODES, [capabilities.data?.modes], @@ -862,12 +874,20 @@ function DeepResearchWorkbenchInner() { // ── collector-agent conversation stream (normal-chat machinery) ─────────── const collectorContext = useMemo( - () => ({ - ...localSettings.context, - model_name: selectedModel ?? localSettings.context.model_name, - agent_id: COLLECTOR_AGENT_ID, - }), - [localSettings.context, selectedModel], + () => { + const skills = normalizeRetrievalSkills( + advancedOptions.retrievalSkills.length > 0 + ? advancedOptions.retrievalSkills + : session?.config?.retrieval_skills, + ); + return { + ...localSettings.context, + model_name: selectedModel ?? localSettings.context.model_name, + agent_id: COLLECTOR_AGENT_ID, + ...(skills.length > 0 ? { extra_skills: skills } : {}), + }; + }, + [advancedOptions.retrievalSkills, localSettings.context, selectedModel, session?.config?.retrieval_skills], ); const [collectorThread, collectorSendMessage] = useThreadStream({ @@ -892,17 +912,37 @@ function DeepResearchWorkbenchInner() { if (!options?.silentStatusPatch && session && session.status !== "collecting") { void actions.updateStatus.mutate({ sessionId: session.id, status: "collecting" }); } + const isFirstTurn = (collectorThread.messages?.length ?? 0) === 0; + const payload = isFirstTurn + ? composeCollectorQueryWithDirections( + trimmed, + advancedOptions.retrievalDirections.length > 0 + ? advancedOptions.retrievalDirections + : session?.config?.retrieval_directions, + advancedOptions.retrievalSkills.length > 0 + ? advancedOptions.retrievalSkills + : session?.config?.retrieval_skills, + ) + : trimmed; try { await collectorSendMessage( threadId, - { text: trimmed, files: [] }, + { text: payload, files: [] }, selectedModel ? { model_name: selectedModel } : undefined, ); } catch (error) { toast.error(error instanceof Error ? error.message : "发送失败,请重试"); } }, - [actions.updateStatus, collectorSendMessage, selectedModel, session], + [ + actions.updateStatus, + advancedOptions.retrievalDirections, + advancedOptions.retrievalSkills, + collectorSendMessage, + collectorThread.messages?.length, + selectedModel, + session, + ], ); const handleClarificationSubmit = useCallback( @@ -1499,6 +1539,7 @@ function DeepResearchWorkbenchInner() { style: "formal_analysis" | "professional" | "concise" | "readable"; modelName: string | null; reportOutline: string; + structureMode?: ReportStructureMode; createNewReport?: boolean; }) => { if (!session) { @@ -1523,6 +1564,7 @@ function DeepResearchWorkbenchInner() { const parentRunSnapshot = run; targetSession = await createResearchReportVariant(session.id, { reportOutline: input.reportOutline || undefined, + structureMode: input.structureMode, }); reportDocumentRewrite.reset(); const toneByStyle = { @@ -1536,6 +1578,7 @@ function DeepResearchWorkbenchInner() { entry: "regenerate", config: { custom_outline: input.reportOutline || undefined, + structure_mode: input.structureMode, report_instruction: input.instruction, tone: toneByStyle[input.style], ...(input.modelName ? { @@ -1603,6 +1646,7 @@ function DeepResearchWorkbenchInner() { style: input.style, modelName: input.modelName ?? undefined, reportOutline: input.reportOutline || undefined, + structureMode: input.structureMode, }, { stream: (signal) => @@ -1614,6 +1658,7 @@ function DeepResearchWorkbenchInner() { style: input.style, modelName: input.modelName ?? undefined, reportOutline: input.reportOutline || undefined, + structureMode: input.structureMode, }, signal, (jobId) => { @@ -1818,16 +1863,48 @@ function DeepResearchWorkbenchInner() { const updateAdvancedOption = useCallback( (key: K, value: AdvancedResearchOptions[K]) => { setAdvancedOptions((current) => ({ ...current, [key]: value }) as AdvancedResearchOptions); - if (key !== "customOutline" || !session?.id) return; - setReportCardOutlineOverrides((current) => ( - current[session.id] === value - ? current - : { ...current, [session.id]: value as string } - )); + if (!session?.id) return; + if (key === "customOutline") { + setReportCardOutlineOverrides((current) => ( + current[session.id] === value + ? current + : { ...current, [session.id]: value as string } + )); + } + if (key === "structureMode") { + setReportCardStructureModeOverrides((current) => ( + current[session.id] === value + ? current + : { ...current, [session.id]: value as ReportStructureMode } + )); + } + if (key === "retrievalDirections") { + const next = normalizeRetrievalDirections(value); + setReportCardRetrievalDirectionsOverrides((current) => { + const prev = current[session.id]; + if (prev && prev.join("\n") === next.join("\n")) return current; + return { ...current, [session.id]: next }; + }); + } + if (key === "retrievalSkills") { + const next = normalizeRetrievalSkills(value); + setReportCardRetrievalSkillsOverrides((current) => { + const prev = current[session.id]; + if (prev && prev.join("\n") === next.join("\n")) return current; + return { ...current, [session.id]: next }; + }); + } }, [session?.id], ); + const applyReportStructure = useCallback((content: string, structureMode: ReportStructureMode, retrievalDirections: string[] = [], retrievalSkills: string[] = []) => { + updateAdvancedOption("customOutline", content); + updateAdvancedOption("structureMode", structureMode); + updateAdvancedOption("retrievalDirections", normalizeRetrievalDirections(retrievalDirections)); + updateAdvancedOption("retrievalSkills", normalizeRetrievalSkills(retrievalSkills)); + }, [updateAdvancedOption]); + const handleDelete = useCallback(async (id: string) => { try { await actions.delete.mutateAsync(id); @@ -1961,6 +2038,9 @@ function DeepResearchWorkbenchInner() { defaultMode={session.mode} defaultOptions={advancedOptionsFromConfig(session.config)} outlineOverride={reportCardOutlineOverride} + structureModeOverride={reportCardStructureModeOverride} + retrievalDirectionsOverride={reportCardRetrievalDirectionsOverride} + retrievalSkillsOverride={reportCardRetrievalSkillsOverride} defaultGenerateImages={session.config?.generate_images === true} modelLabel={modelLabel} materialCount={materialCount} @@ -2223,7 +2303,7 @@ function DeepResearchWorkbenchInner() { className="h-7 px-2 text-xs" disabled={jobWriting} appliedContent={advancedOptions.customOutline} - onApply={(content) => updateAdvancedOption("customOutline", content)} + onApply={applyReportStructure} /> @@ -3197,6 +3277,10 @@ function AdvancedResearchSettingsDialog({ id="research-outline" value={options.customOutline} onChange={(value) => onChange("customOutline", value)} + structureMode={options.structureMode} + onStructureModeChange={(mode) => onChange("structureMode", mode)} + onRetrievalDirectionsChange={(directions) => onChange("retrievalDirections", directions)} + onRetrievalSkillsChange={(skills) => onChange("retrievalSkills", skills)} /> {/* 工作流配置:报告结构库(后台 /api/report-structures),选中卡片即填充上方报告结构 */} @@ -3207,7 +3291,12 @@ function AdvancedResearchSettingsDialog({ onChange("customOutline", content)} + onApply={(content, structureMode, retrievalDirections, retrievalSkills) => { + onChange("customOutline", content); + onChange("structureMode", structureMode); + onChange("retrievalDirections", retrievalDirections); + onChange("retrievalSkills", retrievalSkills); + }} /> diff --git a/frontend-web/src/pages/deep-research/ReportConfigCard.tsx b/frontend-web/src/pages/deep-research/ReportConfigCard.tsx index bc81cf3b..6b410ce2 100644 --- a/frontend-web/src/pages/deep-research/ReportConfigCard.tsx +++ b/frontend-web/src/pages/deep-research/ReportConfigCard.tsx @@ -32,7 +32,15 @@ import { type ResearchMode, type ResearchTone, } from "@/strategy-components/api/deep-research"; -import { useReportStructures } from "@/strategy-components/api/report-structures"; +import { + DEFAULT_REPORT_STRUCTURE_MODE, + normalizeReportStructureMode, + normalizeRetrievalDirections, + normalizeRetrievalSkills, + REPORT_STRUCTURE_MODE_OPTIONS, + type ReportStructureMode, + useReportStructures, +} from "@/strategy-components/api/report-structures"; import { WorkflowConfigButton } from "./WorkflowConfigDialog"; const MODE_LABELS: Record = { @@ -65,6 +73,9 @@ export type AdvancedResearchOptions = { maxGeneratedImages: string; thinkingEnabled: boolean; customOutline: string; + structureMode: ReportStructureMode; + retrievalDirections: string[]; + retrievalSkills: string[]; }; /** @@ -204,10 +215,16 @@ export function ReportStructureSelect({ id, value, onChange, + onStructureModeChange, + onRetrievalDirectionsChange, + onRetrievalSkillsChange, }: { id: string; value: string; onChange: (value: string) => void; + onStructureModeChange?: (mode: ReportStructureMode) => void; + onRetrievalDirectionsChange?: (directions: string[]) => void; + onRetrievalSkillsChange?: (skills: string[]) => void; }) { // 与「工作流配置」共用报告结构库;创建/编辑/启停后 React Query 的失效会 // 自动刷新本下拉,避免维护一份会逐渐过时的前端硬编码模板。 @@ -223,12 +240,20 @@ export function ReportStructureSelect({ // “自定义” is an intentional blank starting point. Saved structures are // always available as one-click scaffolds whenever the user wants them. onChange(""); + onStructureModeChange?.(DEFAULT_REPORT_STRUCTURE_MODE); + onRetrievalDirectionsChange?.([]); + onRetrievalSkillsChange?.([]); return; } if (!next.startsWith(REPORT_STRUCTURE_VALUE_PREFIX)) return; const structureId = next.slice(REPORT_STRUCTURE_VALUE_PREFIX.length); const structure = structures.find((item) => item.id === structureId); - if (structure) onChange(structure.content); + if (structure) { + onChange(structure.content); + onStructureModeChange?.(structure.structureMode); + onRetrievalDirectionsChange?.(structure.retrievalDirections); + onRetrievalSkillsChange?.(structure.retrievalSkills); + } }; return ( @@ -264,23 +289,82 @@ export function ReportStructureSelect({ ); } +export function StructureModeSelect({ + id, + value, + onChange, +}: { + id?: string; + value: ReportStructureMode; + onChange: (value: ReportStructureMode) => void; +}) { + const selected = REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === value) + ?? REPORT_STRUCTURE_MODE_OPTIONS[1]; + return ( +
+ + +

+ {selected.description} +

+
+ ); +} + /** Shared by the initial settings dialog and the write-confirmation card. */ export function ReportOutlineEditor({ id, value, onChange, + structureMode, + onStructureModeChange, + onRetrievalDirectionsChange, + onRetrievalSkillsChange, showStructureSelect = true, }: { id: string; value: string; onChange: (value: string) => void; + structureMode?: ReportStructureMode; + onStructureModeChange?: (mode: ReportStructureMode) => void; + onRetrievalDirectionsChange?: (directions: string[]) => void; + onRetrievalSkillsChange?: (skills: string[]) => void; /** The confirmation card already exposes its picker in the primary form. */ showStructureSelect?: boolean; }) { return (
{showStructureSelect && ( - +
+ + {onStructureModeChange && ( + + )} +
)}