diff --git a/.gitignore b/.gitignore index 6924a39a..7ae1b28c 100644 --- a/.gitignore +++ b/.gitignore @@ -98,11 +98,13 @@ offline-backend-20260512/backend/tests/* !offline-backend-20260512/backend/tests/test_deep_research_event_sink.py !offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py !offline-backend-20260512/backend/tests/test_deep_research_start_job.py -!offline-backend-20260512/backend/tests/test_summarization_human_message.py -!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py -# Independent enterprise-research persistence, HTTP and race regressions. -!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py -deploy +!offline-backend-20260512/backend/tests/test_summarization_human_message.py +!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py +# Independent enterprise-research persistence, HTTP and race regressions. +!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py +# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts). +!offline-backend-20260512/backend/tests/test_position_artifact_cap.py +deploy .history # Cloned third-party repos (local only) 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/agents/agent-chat-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx index 8f75052a..45f39694 100644 --- a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx +++ b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx @@ -72,6 +72,7 @@ import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { bootstrapResumePath, isBootstrapThread, + isHiddenFromRecentChats, isThreadFavorite, isThreadPinned, sortThreadsPinnedFirst, @@ -372,17 +373,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { const [renameValue, setRenameValue] = useState(""); const [shareThreadId, setShareThreadId] = useState(null); - // 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示 - // (带机器人图标、点击回创建页);置顶优先。 + // 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致; + // 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。 const visibleThreads = useMemo( - () => - sortThreadsPinnedFirst( - threads.filter( - (thread) => - thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "roundtable", - ), - ), + () => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))), [threads], ); diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 1d980f6a..fd25317d 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -346,15 +346,22 @@ export function AgentGallery() { 请输入访问密码,验证通过后才能查看其中的智能体。

-
+
{ + event.preventDefault(); + handleSubmitPassword(); + }} + > + setPasswordInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleSubmitPassword(); - }} // This is a one-off access code, not a login credential. Tell // browsers / password managers not to save or autofill it (which // would also stop them from spilling a saved account into the @@ -365,8 +372,8 @@ export function AgentGallery() { data-lpignore="true" data-form-type="other" /> - -
+ + ); } diff --git a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx index de416639..3bdf1204 100644 --- a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx +++ b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useThreadsPage } from "@/core/threads/hooks"; -import { pathOfThread, titleOfThread } from "@/core/threads/utils"; +import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -22,12 +22,9 @@ export function AgentRecentChats({ agentId }: { agentId: string }) { pageSize: RECENT_LIMIT, metadata: { agent_id: agentId }, }); - // 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。 + // 该智能体的会话里同样排除引导 / 系统 / 配置向导会话。 const threads = (data?.threads ?? []).filter( - (th) => - th.metadata?.thread_type !== "bootstrap" && - th.metadata?.thread_type !== "scheduler" && - th.metadata?.thread_type !== "roundtable", + (th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th), ); return ( diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx index 742eeba2..b1bcd45e 100644 --- a/frontend-web/src/components/workspace/appearance-controls.tsx +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -14,11 +14,12 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; +import type { CompactShortcutRouteParam } from "@/core/system-settings/api"; import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks"; import { isHideChromeActive } from "@/core/embed/embed-session"; import { env } from "@/env"; import { cn } from "@/lib/utils"; -import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; +import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app"; /** * 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用, @@ -47,6 +48,34 @@ const triggerToneClass = (tone: AppearanceControlTone) => ? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30" : "text-muted-foreground hover:bg-muted hover:text-foreground"; +function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) { + const pathSegments = params + .filter((param) => param.paramType === "path") + .map((param) => + resolveRouteParamValue({ ...param, paramType: "login" }).trim(), + ) + .filter(Boolean) + .map((value) => encodeURIComponent(value)); + if (pathSegments.length === 0) return url; + + const hashIndex = url.indexOf("#"); + if (hashIndex < 0) { + const [path, query = ""] = url.split("?", 2); + return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`; + } + + const beforeHash = url.slice(0, hashIndex); + const fragment = url.slice(hashIndex + 1); + const [routePath, query = ""] = fragment.split("?", 2); + return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`; +} + +function isQueryShortcutParam( + param: CompactShortcutRouteParam, +): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } { + return param.paramType === "login" || param.paramType === "theme"; +} + export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) { const { data: appearance } = useAppearanceSettings(); const buttons = (appearance?.compact_shortcut_buttons ?? []) @@ -69,9 +98,10 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone } )} title={button.label} onClick={() => { + const routeParams = (button.route_params ?? []).map((param) => ({ ...param })); const url = buildLightAppUrl({ - url: button.url, - routeParams: button.route_params.map((param) => ({ ...param })), + url: appendShortcutPathParams(button.url, routeParams), + routeParams: routeParams.filter(isQueryShortcutParam), }); window.open(url, "_blank", "noopener,noreferrer"); }} 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 a938bb46..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,7 +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 } from "@/strategy-components/api/deep-research"; +import { + useCapabilities as useResearchCapabilities, + useResearchSources, + type DeepResearchSource, +} from "@/strategy-components/api/deep-research"; import { listDurableDocumentRewrites, streamDurableDocumentRewriteJob, @@ -55,9 +59,16 @@ 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 { downloadArtifactFile, } from "@/core/artifacts/download"; @@ -101,6 +112,54 @@ 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 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; @@ -286,7 +345,13 @@ export function ArtifactFileDetail({ language === "mermaid" ); }, [language]); - const { content, isApplyingEdit } = useArtifactContent({ + const { + content, + isApplyingEdit, + isLoading: isArtifactContentLoading, + error: artifactContentError, + refetch: refetchArtifactContent, + } = useArtifactContent({ threadId, // editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟 // URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容 @@ -326,10 +391,19 @@ export function ArtifactFileDetail({ const documentRewriteActive = documentRewrite.isActive; const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true; const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4; - const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits; 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 = @@ -388,7 +462,9 @@ export function ArtifactFileDetail({ !filepath || isMock || useWriteFileStreaming || - documentRewriteActive + documentRewriteActive || + content === undefined || + artifactContentError ) { return; } @@ -472,6 +548,7 @@ export function ArtifactFileDetail({ rewritePresentation.update(presentationId, { rewrittenContent: event.content, versionId: event.versionId ?? null, + committedHash: event.committedHash, }); break; case "done": @@ -487,7 +564,7 @@ export function ArtifactFileDetail({ rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message }); break; case "error": - rewritePresentation.update(presentationId, { stage: "error", error: event.message }); + console.error("AI 全文改写历史恢复失败:", event.message); break; default: break; @@ -504,6 +581,9 @@ export function ArtifactFileDetail({ // 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; @@ -518,6 +598,7 @@ export function ArtifactFileDetail({ maxGeneratedImages: historical.request.maxGeneratedImages, originalContent: historical.request.originalContent, rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null, + committedHash: historicalCommittedHash, requirementsPlan: typeof historicalResult.requirementsPlan === "string" ? historicalResult.requirementsPlan : "", @@ -543,6 +624,22 @@ export function ArtifactFileDetail({ : 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 }, @@ -554,6 +651,8 @@ export function ArtifactFileDetail({ maxGeneratedImages: latest.request.maxGeneratedImages, originalContent, rewrittenContent, + committedHash, + localFallbackAvailable, requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "", }); if (!presentationId || !rewritePresentation) return; @@ -565,11 +664,18 @@ export function ArtifactFileDetail({ summary, warnings: summary?.validationWarnings ?? [], versionId: typeof result.versionId === "string" ? result.versionId : null, + committedHash, + localFallbackAvailable, error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null, }); if (rewrittenContent && result.status !== "conflict") { setDraftContent(rewrittenContent); - setHasLocalEdits(false); + setHasLocalEdits(versionSnapshotFailed); + if (versionSnapshotFailed) { + console.error( + "AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。", + ); + } queryClient.setQueriesData( { queryKey: ["artifact", filepath, threadId, isMock] }, { content: rewrittenContent, url: undefined }, @@ -603,10 +709,28 @@ export function ArtifactFileDetail({ signal, ), onEvent: (event) => updatePresentation(presentationId, event), - onCommitted: (rewritten) => { + onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => { setDraftContent(rewritten); - setHasLocalEdits(false); - rewritePresentation.update(presentationId, { rewrittenContent: 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 }, @@ -622,7 +746,9 @@ export function ArtifactFileDetail({ disposed = true; }; }, [ + artifactContentError, canEditArtifact, + content, documentRewrite, documentRewriteActive, draftContent, @@ -675,6 +801,27 @@ 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) => { const detail = (event as CustomEvent<{ @@ -904,7 +1051,9 @@ export function ArtifactFileDetail({ toast.success("Markdown 已保存"); } catch (error) { console.error("Failed to save artifact:", error); - toast.error(error instanceof Error ? error.message : "Markdown 保存失败"); + // 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, @@ -921,21 +1070,44 @@ export function ArtifactFileDetail({ instruction: string; style: "formal_analysis" | "professional" | "concise" | "readable"; modelName: string | null; + reportOutline: string; + structureMode?: "fixed" | "adaptive"; generateImages: boolean; maxGeneratedImages: number; }) => { - if (!canUseFullDocumentRewrite) { - toast.error( - hasLocalEdits - ? "请先保存当前 Markdown 修改后再进行全文改写。" - : "当前文件暂不支持全文改写。", - ); + 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, @@ -949,14 +1121,21 @@ export function ArtifactFileDetail({ originalContent: draftContent, }); 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, - 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, }, @@ -1035,6 +1214,7 @@ export function ArtifactFileDetail({ case "committed": rewritePresentation.update(presentationId, { versionId: event.versionId ?? null, + committedHash: event.committedHash, }); break; case "done": @@ -1050,20 +1230,32 @@ export function ArtifactFileDetail({ }); break; case "error": - rewritePresentation.update(presentationId, { - stage: "error", - error: event.message, - }); + // 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) => { + onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => { setDraftContent(rewritten); - setHasLocalEdits(false); + 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 }); + rewritePresentation.update(presentationId, { + rewrittenContent: rewritten, + committedHash, + localFallbackAvailable, + }); } queryClient.setQueriesData( { @@ -1071,13 +1263,42 @@ export function ArtifactFileDetail({ }, { content: rewritten, url: undefined }, ); - toast.success("Markdown 全文改写完成,已安全替换原文件。"); + 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); }, }, ); }, [ - canUseFullDocumentRewrite, + canEditArtifact, draftContent, documentRewrite, filepath, @@ -1086,6 +1307,7 @@ export function ArtifactFileDetail({ queryClient, rewritePresentation, threadId, + updateArtifact, ], ); @@ -1333,10 +1555,12 @@ export function ArtifactFileDetail({ documentRewriteActive ? "正在生成临时草稿,原文件尚未被覆盖" : hasLocalEdits - ? "请先保存当前 Markdown 修改" + ? "将先保存当前 Markdown 修改,再开始改写" : "使用 AI 重写当前完整 Markdown" } - disabled={documentRewriteActive || !canUseFullDocumentRewrite} + // 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)} /> )} @@ -1509,7 +1733,28 @@ export function ArtifactFileDetail({ activeCursor={isApplyingEdit} /> )} - {isCodeFile && viewMode === "code" && ( + {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 ?? []) + : preserveMarkdownStandaloneLines(content ?? ""), + [content, researchSessionId, researchSourcesQuery.data?.items], + ); const markdownComponents = useMemo(() => { - const anchorResolver = createMarkdownAnchorResolver(content ?? ""); + const anchorResolver = createMarkdownAnchorResolver(previewContent); const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { const Tag = `h${level}` as const; return ({ children, id, ...props }: HTMLAttributes) => ( @@ -1785,7 +2039,7 @@ export function ArtifactFilePreview({ ); }, }; - }, [content, publicShareCode, researchSessionId, threadId]); + }, [previewContent, publicShareCode, researchSessionId, threadId]); useEffect(() => { if (language !== "html") { @@ -1874,7 +2128,7 @@ export function ArtifactFilePreview({ ), }} > - {content ?? ""} + {previewContent} {activeCursor && ( diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx index fe5116e4..03f3762a 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx @@ -1,5 +1,5 @@ import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useState, type ReactNode } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -29,6 +29,7 @@ export function ArtifactFileList({ threadId, onOpenArtifact, onDownloadArtifact, + fileDescriptions, }: { className?: string; files: string[]; @@ -36,6 +37,8 @@ export function ArtifactFileList({ onOpenArtifact?: (filepath: string) => void; /** Optional host download action for virtual/session-owned artifacts. */ onDownloadArtifact?: (filepath: string) => void | Promise; + /** Optional per-file secondary text, such as the report structure used. */ + fileDescriptions?: Record; }) { const { t } = useI18n(); const { isMock, publicShareCode } = useThread(); @@ -102,10 +105,10 @@ export function ArtifactFileList({
{getFileIcon(file, "size-6")}
- - - {getFileExtensionDisplayName(file)} file - + + + {fileDescriptions?.[file] ?? `${getFileExtensionDisplayName(file)} file`} + {file.endsWith(".skill") && (