diff --git a/CLAUDE.md b/CLAUDE.md index 226b88e3..593c6490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,9 +82,10 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). +- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**. 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 still **self-register** as usual but a regular new account defaults to `approved=False` (待审批); admins are created `approved=True`; existing rows are backfilled `True` so flipping the switch on never locks out existing users. 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` → 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`. - **敏感词管理 (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, 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 also support 追加登录参数 `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 = no extra params (old behavior). Reuses `LOGIN_SOURCE_OPTIONS` from light-app. Stored as JSON column `task_buttons.login_params_json` (`PortableJSON`, migration `20260625_01`). **`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). **`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`. - **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/frontend-web/package.json b/frontend-web/package.json index 1b854aca..5ec6dacc 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build", "build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit", + "build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test", "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network", "preview": "vite preview", "typecheck": "tsc --noEmit" diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index d01d6d6e..dcd6d382 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -11,6 +11,7 @@ import { MousePointerClick, Settings, ShieldAlert, + ShieldCheck, User, Wrench, } from "lucide-react"; @@ -222,6 +223,14 @@ function SecondaryNavSidebar() { 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/login-whitelist")} + > + + 白名单管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")} @@ -508,6 +517,12 @@ function LegacyAccordionSidebar({ 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/login-whitelist")}> + + 白名单管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")}> diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 44721c07..6ddf0f4a 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -138,6 +138,7 @@ export function MessageListItem({ extraRehypePlugins, onEditResubmit, onMessageDeleted, + turnTime, }: { className?: string; fitRowToContent?: boolean; @@ -154,6 +155,13 @@ export function MessageListItem({ extraRehypePlugins?: ReturnType; onEditResubmit?: (text: string) => void; onMessageDeleted?: (deletedIds: string[]) => void; + /** + * Durable ask time of the turn this message belongs to (the preceding + * question's `client_ts`). Used to time-stamp the AI answer, which carries no + * timestamp of its own — without it the answer would fall back to the current + * wall clock at render time and appear to "drift" to now on every reload. + */ + turnTime?: string; }) { const isHuman = message.type === 'human'; const messageMarkdown = @@ -182,6 +190,7 @@ export function MessageListItem({ extraRehypePlugins={extraRehypePlugins} onEditResubmit={onEditResubmit} onMessageDeleted={onMessageDeleted} + turnTime={turnTime} /> {/* Human messages carry their own inline copy/edit toolbar inside @@ -390,6 +399,7 @@ function MessageContent_({ extraRehypePlugins, onEditResubmit, onMessageDeleted, + turnTime, }: { className?: string; message: Message; @@ -400,6 +410,7 @@ function MessageContent_({ extraRehypePlugins?: ReturnType; onEditResubmit?: (text: string) => void; onMessageDeleted?: (deletedIds: string[]) => void; + turnTime?: string; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); @@ -456,9 +467,15 @@ function MessageContent_({ [threadId, resolveArtifact], ); + // Question carries its own `client_ts` (round-trips through the checkpoint). + // The AI answer has no timestamp of its own, so it inherits the turn's ask + // time (its question's `client_ts`, threaded down as `turnTime`). This is + // durable and turn-accurate, and — unlike the old per-id `now` fallback — + // never drifts to the current clock when an unfinished answer is reloaded. + const ownAskTime = message.id ? askTimes[message.id] : undefined; const messageTime = formatAskedTime( message.additional_kwargs?.client_ts ?? - (message.id ? askTimes[message.id] : undefined), + (isHuman ? ownAskTime : (turnTime ?? ownAskTime)), ); const rawContent = extractContentFromMessage(message); diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 95c5668c..aa833058 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { rememberAskTimes } from "@/core/chat-timestamps"; +import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps"; import { useI18n } from "@/core/i18n/hooks"; import { extractContentFromMessage, @@ -167,15 +167,24 @@ export function MessageList({ [allMessages, hiddenMessageIds], ); - // Stamp a wall-clock time onto every question/answer the first time it is - // seen, in a single batched commit. Questions sent this session already carry - // an accurate time (see useThreadStream); this also covers assistant answers - // and messages loaded from history. First-write-wins keeps the earliest time. + const askTimes = useChatAskTimes(); + + // Stamp a wall-clock time onto every *question* the first time it is seen, in + // a single batched commit. Questions sent this session already carry an + // accurate `client_ts` (see useThreadStream) and are skipped; this only backs + // up legacy questions loaded from history that predate `client_ts`. + // First-write-wins keeps the earliest time. + // + // AI answers are deliberately NOT stamped here: they carry no timestamp of + // their own, so a `now` stamp would be the *load* time, not the answer time — + // and for an unfinished/interrupted turn that re-stamps to the current clock + // on every reload. Instead the answer inherits its question's durable time + // via `turnTimes` below. useEffect(() => { const now = new Date().toISOString(); const entries: Record = {}; for (const message of messages) { - if (message.type !== "human" && message.type !== "ai") continue; + if (message.type !== "human") continue; if (!message.id || message.id.startsWith("opt-")) continue; if (typeof message.additional_kwargs?.client_ts === "string") continue; entries[message.id] = now; @@ -183,6 +192,24 @@ export function MessageList({ if (Object.keys(entries).length > 0) rememberAskTimes(entries); }, [messages]); + // Durable ask time per message id, used to time-stamp the AI answer (which has + // no timestamp of its own) with the time of the question that triggered it. + const turnTimes = useMemo(() => { + const map = new Map(); + let lastQuestionTime: string | undefined; + for (const message of messages) { + if (message.type === "human") { + const ts = message.additional_kwargs?.client_ts; + const fromAskTimes = message.id ? askTimes[message.id] : undefined; + lastQuestionTime = + typeof ts === "string" && ts ? ts : fromAskTimes; + } else if (message.type === "ai" && message.id && lastQuestionTime) { + map.set(message.id, lastQuestionTime); + } + } + return map; + }, [messages, askTimes]); + const [settings] = useLocalSettings(); const ragModeEnabled = settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); @@ -358,6 +385,7 @@ export function MessageList({ onMessageDeleted={ allowDelete ? handleMessageDeleted : undefined } + turnTime={msg.id ? turnTimes.get(msg.id) : undefined} /> ); }); diff --git a/frontend-web/src/components/workspace/messages/message-token-usage.tsx b/frontend-web/src/components/workspace/messages/message-token-usage.tsx index f45b2ef0..21cf508b 100644 --- a/frontend-web/src/components/workspace/messages/message-token-usage.tsx +++ b/frontend-web/src/components/workspace/messages/message-token-usage.tsx @@ -31,6 +31,44 @@ export function formatDuration(ms: number): string { return `${mins}m ${secs}s`; } +/** + * 速度条颜色含义图例 —— 生成结束后常驻在速度条下方一行,向用户解释进度条 + * 颜色代表的「模型生成延迟」状态。进度条本身仍是 5 档(极快/流畅/一般/偏慢/ + * 缓慢)的连续配色;这里把它归并成用户视角的 3 类延迟 + 「无响应」来说明: + * 绿(#16a34a,极快/流畅) → 延迟正常 + * 黄(#eab308,一般) → 延迟微长 + * 红(#ef4444,偏慢/缓慢) → 延迟长,可能失败 + * 灰(#9ca3af) → 无响应(无进度条/0 tok/s),任务失败,请重试 + */ +const SPEED_LEGEND: { color: string; label: string }[] = [ + { color: "#16a34a", label: "延迟正常" }, + { color: "#eab308", label: "延迟微长" }, + { color: "#ef4444", label: "延迟长,可能失败" }, + { color: "#9ca3af", label: "无响应,任务失败,请重试" }, +]; + +export function SpeedLegend({ className }: { className?: string }) { + return ( + + {SPEED_LEGEND.map((item) => ( + + + {item.label} + + ))} + + ); +} + export function SpeedPill({ metric }: { metric: MessageMetric }) { const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC)); const { color, label } = classifySpeed(metric.tokensPerSec); @@ -132,6 +170,8 @@ export function MessageTokenUsage({ {formatDuration(resolvedMetric.durationMs)} )} + {/* 生成结束(非流式中)后常驻一行颜色含义说明。 */} + {!resolvedMetric.isActive && } ) : null} diff --git a/frontend-web/src/core/auth/api.ts b/frontend-web/src/core/auth/api.ts index 1a2eb5f3..b2295ea8 100644 --- a/frontend-web/src/core/auth/api.ts +++ b/frontend-web/src/core/auth/api.ts @@ -11,6 +11,8 @@ export interface UserListItem { agent_count: number; /** 问答次数:用户提问的次数(一次提问算一次)。 */ qa_count: number; + /** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */ + approved: boolean; } export async function listAllUsers(): Promise { @@ -19,6 +21,20 @@ export async function listAllUsers(): Promise { return res.json() as Promise; } +/** 放行 / 取消放行某个注册用户(登录白名单)。Admin only。 */ +export async function setUserApproval(userId: string, approved: boolean): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/approval`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approved }), + }); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(extractErrorMessage(data, "保存失败")); + } + return res.json() as Promise; +} + // Normalize a FastAPI error body into a human-readable string. `detail` may be // a plain string, an object like AuthErrorResponse ({ code, message }), or an // array of validation errors — anything but a string would otherwise render as diff --git a/frontend-web/src/core/auth/hooks.ts b/frontend-web/src/core/auth/hooks.ts index 3bc5b40f..0adf00e7 100644 --- a/frontend-web/src/core/auth/hooks.ts +++ b/frontend-web/src/core/auth/hooks.ts @@ -1,6 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { listAllUsers } from "./api"; +import { OptType, writeYLog } from "@/core/log/y-log"; + +import { listAllUsers, setUserApproval } from "./api"; export function useAllUsers(enabled: boolean) { return useQuery({ @@ -9,3 +11,20 @@ export function useAllUsers(enabled: boolean) { enabled, }); } + +/** 放行 / 取消放行某个用户(登录白名单),成功后刷新用户列表缓存。 */ +export function useSetUserApproval() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, approved }: { userId: string; approved: boolean }) => setUserApproval(userId, approved), + onSuccess: (_data, vars) => { + writeYLog({ + optType: vars.approved ? OptType.enabled : OptType.disabled, + optDetail: `CM助手-全量-白名单管理-${vars.approved ? "放行" : "取消放行"}用户`, + url: `/api/v1/auth/users/${vars.userId}/approval`, + reqParam: vars, + }); + void queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); + }, + }); +} diff --git a/frontend-web/src/core/auth/index.ts b/frontend-web/src/core/auth/index.ts index 23659489..deca84f7 100644 --- a/frontend-web/src/core/auth/index.ts +++ b/frontend-web/src/core/auth/index.ts @@ -7,6 +7,12 @@ export interface LoginByUsernameResponse { system_role: string; needs_setup: boolean; created: boolean; + /** + * 本次登录解析出的原始登录用户名(token 登录=getTokenInfo 解析值;用户名登录=所传用户名)。 + * 用于跳转外链时拼 ``?username=``——后端落库的邮箱前缀是归一化(小写/替换)后的,有损,故以此为准。 + * 旧后端可能不返回。 + */ + username?: string; } import { diff --git a/frontend-web/src/core/auth/login-credentials.ts b/frontend-web/src/core/auth/login-credentials.ts new file mode 100644 index 00000000..69495c95 --- /dev/null +++ b/frontend-web/src/core/auth/login-credentials.ts @@ -0,0 +1,61 @@ +/** + * 任务工作区跳转外链时要拼接的「登录态凭据」:token1 + 登录用户名 的存取。 + * + * - **token1** = token 登录用的上游 token(登录 URL 的 ``?authToken=``,即走 + * ``/api/v1/auth/login/token`` 这条 token 换登录路径用的 token)。 + * - **username** = 后端 token 登录时用该 token 调 ``getTokenInfo`` 解析出的**登录用户名** + * (见 ``auth.py`` ``/login/token`` → ``UsernameLoginResponse.username``)。**不是**邮箱 + * 前缀、也**不是**解 JWT 得来——后端把它落库时会 ``.lower()`` + 归一化,邮箱前缀是有损的, + * 所以以后端回传的原始值为准。 + * + * 二者均经 [[scoped-storage]] 的 ``authStorage*`` 存取(键用 ``login.`` 前缀,属其受保护 + * 鉴权键族):嵌入(iframe)/独立会话态自动落 sessionStorage 隔离、正常态落 localStorage, + * 并随「开启独立会话」一起被 ``migrateAuthToSession`` 迁移。 + * + * **门控**:仅 token 登录(``authToken`` 存在)才写入。非 token 登录不写 token1 → 跳转按钮 + * 的「追加登录态」开关此时读不到 token,自然不追加(符合「必须用 login/token 登录才能带 + * token 跳转」)。 + */ + +import { + authStorageGet, + authStorageRemove, + authStorageSet, +} from "@/core/auth/scoped-storage"; +import { getAccountDisplayName, type LoginByUsernameResponse } from "@/core/auth"; + +const LOGIN_TOKEN_KEY = "login.token1"; +const LOGIN_USERNAME_KEY = "login.username"; + +/** + * 持久化 token 登录的 token1 + 用户名(token 登录成功后调用)。 + * ``authToken`` 为空则整次忽略(非 token 登录不留痕)。 + */ +export function persistLoginCredentials( + authToken: string | null | undefined, + auth: LoginByUsernameResponse | null, +): void { + const token = authToken?.trim(); + if (!token) return; + authStorageSet(LOGIN_TOKEN_KEY, token); + // 优先用后端回传的原始登录名(getTokenInfo 解析值);旧后端无该字段时退回账号显示名 + // (邮箱 @ 前缀)兜底,保证总有个值可用。 + const username = auth?.username?.trim() || getAccountDisplayName(auth); + if (username) authStorageSet(LOGIN_USERNAME_KEY, username); +} + +/** 读 token1(无则 ``null`` → 跳转不追加登录态)。 */ +export function readLoginToken(): string | null { + return authStorageGet(LOGIN_TOKEN_KEY); +} + +/** 读登录用户名(无则 ``null``)。 */ +export function readLoginUsername(): string | null { + return authStorageGet(LOGIN_USERNAME_KEY); +} + +/** 清除登录态凭据(登出时调用)。 */ +export function clearLoginCredentials(): void { + authStorageRemove(LOGIN_TOKEN_KEY); + authStorageRemove(LOGIN_USERNAME_KEY); +} diff --git a/frontend-web/src/core/auth/task-buttons.ts b/frontend-web/src/core/auth/task-buttons.ts index 19857479..2cfe8604 100644 --- a/frontend-web/src/core/auth/task-buttons.ts +++ b/frontend-web/src/core/auth/task-buttons.ts @@ -21,6 +21,10 @@ import { resolveTaskDeeplinkTarget, } from "@/core/auth/task-deeplink"; import { readUserInfo, resolveRouteParamValue } from "@/strategy-components/lib/light-app"; +import { + readLoginToken, + readLoginUsername, +} from "@/core/auth/login-credentials"; /** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */ export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent"; @@ -102,6 +106,16 @@ export interface TaskButton { openMode: TaskButtonOpenMode; /** ``url`` 外链追加的登录参数列表(仅 ``url`` 类型有意义;为空则不追加,行为同旧版)。 */ loginParams: TaskButtonLoginParam[]; + /** + * ``url`` 跳转是否追加登录态:token1(token 登录的上游 token)+ 登录用户名,供外链所在的 + * 外部系统识别当前登录用户。**仅 token 登录**(前端存有 token1,见 [[login-credentials]])时 + * 实际拼上;非 token 登录读不到 token1,自动跳过(按钮照常跳,只是不带这两个参数)。 + */ + appendAuth: boolean; + /** 追加 token 的查询参数名(空则用 ``token``)。 */ + authTokenParam: string; + /** 追加用户名的查询参数名(空则用 ``username``)。 */ + authNameParam: string; enabled: boolean; sortOrder: number; } @@ -154,6 +168,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idKind: "task", openMode: "blank", loginParams: [], + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder, }); @@ -169,6 +186,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idKind: "task", openMode: "blank", loginParams: [], + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder, }); @@ -185,6 +205,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idKind: "task", openMode: "blank", loginParams: [], + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder: 0, }, @@ -204,6 +227,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idKind: "task", openMode: "self", loginParams: [], + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder: 1, }, @@ -218,6 +244,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idKind: "task", openMode: "blank", loginParams: [], + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder: 2, }, @@ -232,6 +261,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idKind: "task", openMode: "blank", loginParams: [], + appendAuth: false, + authTokenParam: "token", + authNameParam: "username", enabled: true, sortOrder: 3, }, @@ -309,6 +341,18 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro url = appendQueryParam(url, key, resolveLoginParamValue(lp, userInfo)); } } + // 追加登录态:仅 token 登录(存在 token1)才拼 token + 用户名,供外链所在的外部系统识别 + // 当前登录用户。非 token 登录读不到 token1 → 整段跳过(不影响跳转本身)。 + if (btn.appendAuth) { + const token1 = readLoginToken(); + if (token1) { + url = appendQueryParam(url, (btn.authTokenParam || "token").trim() || "token", token1); + const name = readLoginUsername(); + if (name) { + url = appendQueryParam(url, (btn.authNameParam || "username").trim() || "username", name); + } + } + } // 应用内部路由(以单个 / 开头,如「上一步」→ 大屏绘制页): // self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。 if (base.startsWith("/") && !base.startsWith("//")) { diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 3c58bea6..7ca382fb 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -33,6 +33,7 @@ import { } from '@/core/page-layout/sidebar-enabled'; import { fetchYLogConfig } from '@/core/log/y-log-config'; import { persistYLogToken } from '@/core/log/y-log-token'; +import { persistLoginCredentials } from '@/core/auth/login-credentials'; import { OptType, writeYLog } from '@/core/log/y-log'; const DEFAULT_USERNAME = 'guest'; @@ -197,6 +198,9 @@ export default function LoginPage() { // 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。 if (authToken) { persistYLogToken(authToken); + // 存下 token1 + 登录用户名(getTokenInfo 解析出的原始名,后端回传),供任务工作区 + // 跳转按钮的「追加登录态」拼到外链上(仅 token 登录才有这两个值)。 + persistLoginCredentials(authToken, auth); void fetchYLogConfig(); writeYLog({ optType: OptType.login, diff --git a/frontend-web/src/pages/LoginWhitelistPage.tsx b/frontend-web/src/pages/LoginWhitelistPage.tsx new file mode 100644 index 00000000..b5851d2a --- /dev/null +++ b/frontend-web/src/pages/LoginWhitelistPage.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { Info, Search, ShieldCheck, UserCheck } from "lucide-react"; +import { toast } from "sonner"; + +import { Switch } from "@/components/ui/switch"; +import { getStoredAuth } from "@/core/auth"; +import { useAllUsers, useSetUserApproval } from "@/core/auth/hooks"; +import { + useLoginWhitelistEnabled, + useSetLoginWhitelistEnabled, +} from "@/strategy-components/api/login-whitelist"; + +/** + * 登录白名单管理(管理员)。 + * + * 顶部一个「总开关」:关 = 所有人可登录;开 = 仅已放行用户(或 admin)可登录。 + * 下方列出全部注册用户,逐个放行 / 取消放行。用户仍可自助注册,注册后默认 + * 「待审批」,开关打开时被拦在登录外,直到管理员在此放行。 + */ +export default function LoginWhitelistPage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + + const { data: enabled, isLoading: loadingSwitch } = useLoginWhitelistEnabled(); + const setEnabled = useSetLoginWhitelistEnabled(); + + const { data: users = [], isLoading: loadingUsers } = useAllUsers(isAdmin); + const setApproval = useSetUserApproval(); + + const [query, setQuery] = useState(""); + const [pendingId, setPendingId] = useState(null); + + const q = query.trim().toLowerCase(); + const visibleUsers = useMemo( + () => (!q ? users : users.filter((u) => u.email.toLowerCase().includes(q) || u.id.toLowerCase().includes(q))), + [users, q], + ); + + const approvedCount = users.filter((u) => u.approved || u.system_role === "admin").length; + + const handleToggleSwitch = async (next: boolean) => { + try { + await setEnabled.mutateAsync(next); + toast.success(next ? "已启用登录白名单" : "已停用登录白名单(所有人可登录)"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "保存失败"); + } + }; + + const handleToggleApproval = async (userId: string, next: boolean) => { + setPendingId(userId); + try { + await setApproval.mutateAsync({ userId, approved: next }); + toast.success(next ? "已放行该用户" : "已取消放行(下次登录被拦)"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "保存失败"); + } finally { + setPendingId(null); + } + }; + + if (!isAdmin) return ; + + return ( +
+
+ {/* 顶部信息卡 + 总开关 */} +
+
+ +
+
+
+
+ + + 登录访问控制 + +

+ 白名单管理 + + {users.length} 用户 / {approvedCount} 可登录 + +

+
+ +
+
+
登录白名单
+
{enabled ? "已启用:仅已放行用户可登录" : "已停用:所有人可登录"}
+
+ +
+
+ +

+ + + 用户仍可自助注册,注册后默认 待审批。开关关闭时所有人可正常登录使用; + 开启后仅已放行的用户可登录,其余被拦在登录外,请在下方逐个放行。 + 管理员(admin)始终可登录,不受开关与放行状态影响。 + +

+
+
+ + {/* 搜索栏 */} +
+
+ + setQuery(e.target.value)} + className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5" + /> +
+
+ + {/* 用户列表 */} +
+
+ 用户 + 问答 + 放行 +
+ + {loadingUsers ? ( +
加载中…
+ ) : visibleUsers.length === 0 ? ( +
+ {users.length === 0 ? "暂无注册用户" : "没有匹配的用户"} +
+ ) : ( +
    + {visibleUsers.map((user) => { + const isUserAdmin = user.system_role === "admin"; + const allowed = user.approved || isUserAdmin; + return ( +
  • +
    +
    + {user.email} + {isUserAdmin ? ( + + + 管理员 + + ) : allowed ? ( + + 已放行 + + ) : ( + + 待审批 + + )} +
    +
    {user.id}
    +
    +
    {user.qa_count}
    +
    + handleToggleApproval(user.id, v)} + /> +
    +
  • + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 84714ed1..1aa6498a 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -39,6 +39,7 @@ import LightAppIframeView from "./LightAppIframeView"; import LightAppListPage from "./LightAppListPage"; import LightAppManagementPage from "./LightAppManagementPage"; import MenuManagementPage from "./MenuManagementPage"; +import LoginWhitelistPage from "./LoginWhitelistPage"; import SensitiveWordsPage from "./SensitiveWordsPage"; import SkillAddressesPage from "./SkillAddressesPage"; import TaskButtonsPage from "./TaskButtonsPage"; @@ -395,6 +396,16 @@ export default function StrategyRoutes() { ) } /> + + ) : ( + + ) + } + /> 当前窗口 + + {r.appendAuth && ( +
+ & + update(r.id, { authTokenParam: e.target.value })} + placeholder="token" + className="h-8 w-24" + title="token 的查询参数名(默认 token)" + /> + =token1 & + update(r.id, { authNameParam: e.target.value })} + placeholder="username" + className="h-8 w-28" + title="用户名的查询参数名(默认 username)" + /> + =用户名 +
+ )} ) : (