This commit is contained in:
fengxiangdebaozi committed 2026-06-25 19:54:58 +08:00
commit ce69905950
44 files changed
+1207 -46

No files matched your search

+2 -1
View File
@@ -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. - **定时任务「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). - **轻应用中心 (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-<appId>`). 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). - **菜单管理 (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-<appId>`). 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. - **敏感词管理 (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/`。 - **技能地址管理 (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=<JWT>&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 切换到三Line truncated
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **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 - **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/` - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build", "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: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", "build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
@@ -11,6 +11,7 @@ import {
MousePointerClick, MousePointerClick,
Settings, Settings,
ShieldAlert, ShieldAlert,
ShieldCheck,
User, User,
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
@@ -222,6 +223,14 @@ function SecondaryNavSidebar() {
按钮管理 按钮管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/login-whitelist")}
>
<ShieldCheck className="size-4" />
白名单管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && ( {canManageSensitiveWords() && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/sensitive-words")} onClick={() => navigate("/page/strategy/admin/sensitive-words")}
@@ -508,6 +517,12 @@ function LegacyAccordionSidebar({
按钮管理 按钮管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
<ShieldCheck className="size-4" />
白名单管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && ( {canManageSensitiveWords() && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}> <DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
<ShieldAlert className="size-4" /> <ShieldAlert className="size-4" />
@@ -138,6 +138,7 @@ export function MessageListItem({
extraRehypePlugins, extraRehypePlugins,
onEditResubmit, onEditResubmit,
onMessageDeleted, onMessageDeleted,
turnTime,
}: { }: {
className?: string; className?: string;
fitRowToContent?: boolean; fitRowToContent?: boolean;
@@ -154,6 +155,13 @@ export function MessageListItem({
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>; extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void; onEditResubmit?: (text: string) => void;
onMessageDeleted?: (deletedIds: 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 isHuman = message.type === 'human';
const messageMarkdown = const messageMarkdown =
@@ -182,6 +190,7 @@ export function MessageListItem({
extraRehypePlugins={extraRehypePlugins} extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit} onEditResubmit={onEditResubmit}
onMessageDeleted={onMessageDeleted} onMessageDeleted={onMessageDeleted}
turnTime={turnTime}
/> />
{/* {/*
Human messages carry their own inline copy/edit toolbar inside Human messages carry their own inline copy/edit toolbar inside
@@ -390,6 +399,7 @@ function MessageContent_({
extraRehypePlugins, extraRehypePlugins,
onEditResubmit, onEditResubmit,
onMessageDeleted, onMessageDeleted,
turnTime,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
@@ -400,6 +410,7 @@ function MessageContent_({
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>; extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void; onEditResubmit?: (text: string) => void;
onMessageDeleted?: (deletedIds: string[]) => void; onMessageDeleted?: (deletedIds: string[]) => void;
turnTime?: string;
}) { }) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
@@ -456,9 +467,15 @@ function MessageContent_({
[threadId, resolveArtifact], [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( const messageTime = formatAskedTime(
message.additional_kwargs?.client_ts ?? message.additional_kwargs?.client_ts ??
(message.id ? askTimes[message.id] : undefined), (isHuman ? ownAskTime : (turnTime ?? ownAskTime)),
); );
const rawContent = extractContentFromMessage(message); const rawContent = extractContentFromMessage(message);
@@ -11,7 +11,7 @@ import {
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; 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 { useI18n } from "@/core/i18n/hooks";
import { import {
extractContentFromMessage, extractContentFromMessage,
@@ -167,15 +167,24 @@ export function MessageList({
[allMessages, hiddenMessageIds], [allMessages, hiddenMessageIds],
); );
// Stamp a wall-clock time onto every question/answer the first time it is const askTimes = useChatAskTimes();
// seen, in a single batched commit. Questions sent this session already carry
// an accurate time (see useThreadStream); this also covers assistant answers // Stamp a wall-clock time onto every *question* the first time it is seen, in
// and messages loaded from history. First-write-wins keeps the earliest time. // 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(() => { useEffect(() => {
const now = new Date().toISOString(); const now = new Date().toISOString();
const entries: Record<string, string> = {}; const entries: Record<string, string> = {};
for (const message of messages) { 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 (!message.id || message.id.startsWith("opt-")) continue;
if (typeof message.additional_kwargs?.client_ts === "string") continue; if (typeof message.additional_kwargs?.client_ts === "string") continue;
entries[message.id] = now; entries[message.id] = now;
@@ -183,6 +192,24 @@ export function MessageList({
if (Object.keys(entries).length > 0) rememberAskTimes(entries); if (Object.keys(entries).length > 0) rememberAskTimes(entries);
}, [messages]); }, [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<string, string>();
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 [settings] = useLocalSettings();
const ragModeEnabled = const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
@@ -358,6 +385,7 @@ export function MessageList({
onMessageDeleted={ onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined allowDelete ? handleMessageDeleted : undefined
} }
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
/> />
); );
}); });
@@ -31,6 +31,44 @@ export function formatDuration(ms: number): string {
return `${mins}m ${secs}s`; 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 (
<span
className={cn(
"text-muted-foreground inline-flex flex-wrap items-center gap-x-2.5 gap-y-0.5",
className,
)}
>
{SPEED_LEGEND.map((item) => (
<span className="inline-flex items-center gap-1" key={item.label}>
<span
aria-hidden
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: item.color }}
/>
{item.label}
</span>
))}
</span>
);
}
export function SpeedPill({ metric }: { metric: MessageMetric }) { export function SpeedPill({ metric }: { metric: MessageMetric }) {
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC)); const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec); const { color, label } = classifySpeed(metric.tokensPerSec);
@@ -132,6 +170,8 @@ export function MessageTokenUsage({
{formatDuration(resolvedMetric.durationMs)} {formatDuration(resolvedMetric.durationMs)}
</span> </span>
)} )}
{/* 生成结束(非流式中)后常驻一行颜色含义说明。 */}
{!resolvedMetric.isActive && <SpeedLegend className="basis-full" />}
</> </>
) : null} ) : null}
</div> </div>
+16
View File
@@ -11,6 +11,8 @@ export interface UserListItem {
agent_count: number; agent_count: number;
/** 问答次数:用户提问的次数(一次提问算一次)。 */ /** 问答次数:用户提问的次数(一次提问算一次)。 */
qa_count: number; qa_count: number;
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
approved: boolean;
} }
export async function listAllUsers(): Promise<UserListItem[]> { export async function listAllUsers(): Promise<UserListItem[]> {
@@ -19,6 +21,20 @@ export async function listAllUsers(): Promise<UserListItem[]> {
return res.json() as Promise<UserListItem[]>; return res.json() as Promise<UserListItem[]>;
} }
/** 放行 / 取消放行某个注册用户(登录白名单)。Admin only。 */
export async function setUserApproval(userId: string, approved: boolean): Promise<UserListItem> {
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<UserListItem>;
}
// Normalize a FastAPI error body into a human-readable string. `detail` may be // Normalize a FastAPI error body into a human-readable string. `detail` may be
// a plain string, an object like AuthErrorResponse ({ code, message }), or an // a plain string, an object like AuthErrorResponse ({ code, message }), or an
// array of validation errors — anything but a string would otherwise render as // array of validation errors — anything but a string would otherwise render as
+21 -2
View File
@@ -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) { export function useAllUsers(enabled: boolean) {
return useQuery({ return useQuery({
@@ -9,3 +11,20 @@ export function useAllUsers(enabled: boolean) {
enabled, 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"] });
},
});
}
+6
View File
@@ -7,6 +7,12 @@ export interface LoginByUsernameResponse {
system_role: string; system_role: string;
needs_setup: boolean; needs_setup: boolean;
created: boolean; created: boolean;
/**
* 本次登录解析出的原始登录用户名(token 登录=getTokenInfo 解析值;用户名登录=所传用户名)。
* 用于跳转外链时拼 ``?username=``——后端落库的邮箱前缀是归一化(小写/替换)后的,有损,故以此为准。
* 旧后端可能不返回。
*/
username?: string;
} }
import { import {
@@ -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);
}
@@ -21,6 +21,10 @@ import {
resolveTaskDeeplinkTarget, resolveTaskDeeplinkTarget,
} from "@/core/auth/task-deeplink"; } from "@/core/auth/task-deeplink";
import { readUserInfo, resolveRouteParamValue } from "@/strategy-components/lib/light-app"; import { readUserInfo, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
import {
readLoginToken,
readLoginUsername,
} from "@/core/auth/login-credentials";
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */ /** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent"; export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
@@ -102,6 +106,16 @@ export interface TaskButton {
openMode: TaskButtonOpenMode; openMode: TaskButtonOpenMode;
/** ``url`` 外链追加的登录参数列表(仅 ``url`` 类型有意义;为空则不追加,行为同旧版)。 */ /** ``url`` 外链追加的登录参数列表(仅 ``url`` 类型有意义;为空则不追加,行为同旧版)。 */
loginParams: TaskButtonLoginParam[]; loginParams: TaskButtonLoginParam[];
/**
* ``url`` 跳转是否追加登录态:token1(token 登录的上游 token)+ 登录用户名,供外链所在的
* 外部系统识别当前登录用户。**仅 token 登录**(前端存有 token1,见 [[login-credentials]])时
* 实际拼上;非 token 登录读不到 token1,自动跳过(按钮照常跳,只是不带这两个参数)。
*/
appendAuth: boolean;
/** 追加 token 的查询参数名(空则用 ``token``)。 */
authTokenParam: string;
/** 追加用户名的查询参数名(空则用 ``username``)。 */
authNameParam: string;
enabled: boolean; enabled: boolean;
sortOrder: number; sortOrder: number;
} }
@@ -154,6 +168,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task", idKind: "task",
openMode: "blank", openMode: "blank",
loginParams: [], loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true, enabled: true,
sortOrder, sortOrder,
}); });
@@ -169,6 +186,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task", idKind: "task",
openMode: "blank", openMode: "blank",
loginParams: [], loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true, enabled: true,
sortOrder, sortOrder,
}); });
@@ -185,6 +205,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task", idKind: "task",
openMode: "blank", openMode: "blank",
loginParams: [], loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true, enabled: true,
sortOrder: 0, sortOrder: 0,
}, },
@@ -204,6 +227,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task", idKind: "task",
openMode: "self", openMode: "self",
loginParams: [], loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true, enabled: true,
sortOrder: 1, sortOrder: 1,
}, },
@@ -218,6 +244,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task", idKind: "task",
openMode: "blank", openMode: "blank",
loginParams: [], loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true, enabled: true,
sortOrder: 2, sortOrder: 2,
}, },
@@ -232,6 +261,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task", idKind: "task",
openMode: "blank", openMode: "blank",
loginParams: [], loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true, enabled: true,
sortOrder: 3, sortOrder: 3,
}, },
@@ -309,6 +341,18 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
url = appendQueryParam(url, key, resolveLoginParamValue(lp, userInfo)); 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 路由新标签打开。 // self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。
if (base.startsWith("/") && !base.startsWith("//")) { if (base.startsWith("/") && !base.startsWith("//")) {
+4
View File
@@ -33,6 +33,7 @@ import {
} from '@/core/page-layout/sidebar-enabled'; } from '@/core/page-layout/sidebar-enabled';
import { fetchYLogConfig } from '@/core/log/y-log-config'; import { fetchYLogConfig } from '@/core/log/y-log-config';
import { persistYLogToken } from '@/core/log/y-log-token'; import { persistYLogToken } from '@/core/log/y-log-token';
import { persistLoginCredentials } from '@/core/auth/login-credentials';
import { OptType, writeYLog } from '@/core/log/y-log'; import { OptType, writeYLog } from '@/core/log/y-log';
const DEFAULT_USERNAME = 'guest'; const DEFAULT_USERNAME = 'guest';
@@ -197,6 +198,9 @@ export default function LoginPage() {
// 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。 // 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。
if (authToken) { if (authToken) {
persistYLogToken(authToken); persistYLogToken(authToken);
// 存下 token1 + 登录用户名(getTokenInfo 解析出的原始名,后端回传),供任务工作区
// 跳转按钮的「追加登录态」拼到外链上(仅 token 登录才有这两个值)。
persistLoginCredentials(authToken, auth);
void fetchYLogConfig(); void fetchYLogConfig();
writeYLog({ writeYLog({
optType: OptType.login, optType: OptType.login,
@@ -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<string | null>(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 <Navigate to="/page/workspace/chats/new" replace />;
return (
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl">
{/* 顶部信息卡 + 总开关 */}
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
<ShieldCheck className="size-44 text-slate-900" />
</div>
<div className="relative">
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
<ShieldCheck className="size-3" />
登录访问控制
</span>
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
白名单管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{users.length} 用户 / {approvedCount} 可登录
</span>
</h1>
</div>
<div className="flex items-center gap-3 rounded-2xl border border-slate-200/60 bg-slate-50/70 px-4 py-3">
<div className="text-right">
<div className="text-sm font-semibold text-slate-700">登录白名单</div>
<div className="text-xs text-slate-500">{enabled ? "已启用:仅已放行用户可登录" : "已停用:所有人可登录"}</div>
</div>
<Switch
checked={Boolean(enabled)}
disabled={loadingSwitch || setEnabled.isPending}
onCheckedChange={handleToggleSwitch}
/>
</div>
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>
用户仍可<strong>自助注册</strong>,注册后默认<strong> 待审批</strong>。开关<strong>关闭</strong>时所有人可正常登录使用;
<strong> 开启</strong>后仅<strong>已放行</strong>的用户可登录,其余被拦在登录外,请在下方逐个放行。
<strong> 管理员(admin)始终可登录</strong>,不受开关与放行状态影响。
</span>
</p>
</div>
</div>
{/* 搜索栏 */}
<div className="mb-6 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-5">
<div className="relative w-full md:w-80">
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="搜索邮箱 / 用户ID…"
value={query}
onChange={(e) => 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"
/>
</div>
</div>
{/* 用户列表 */}
<div className="overflow-hidden rounded-2xl border border-slate-200/60 bg-white shadow-sm">
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-3 border-b border-slate-200/60 bg-slate-50/70 px-4 py-2.5 text-xs font-semibold text-slate-500">
<span>用户</span>
<span className="px-1 text-center">问答</span>
<span className="px-1 text-center">放行</span>
</div>
{loadingUsers ? (
<div className="px-4 py-12 text-center text-sm text-slate-400">加载中…</div>
) : visibleUsers.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-slate-400">
{users.length === 0 ? "暂无注册用户" : "没有匹配的用户"}
</div>
) : (
<ul className="divide-y divide-slate-100">
{visibleUsers.map((user) => {
const isUserAdmin = user.system_role === "admin";
const allowed = user.approved || isUserAdmin;
return (
<li
key={user.id}
className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-slate-800">{user.email}</span>
{isUserAdmin ? (
<span className="inline-flex items-center gap-1 rounded-md border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[11px] font-semibold text-amber-600">
<UserCheck className="size-3" />
管理员
</span>
) : allowed ? (
<span className="rounded-md border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600">
已放行
</span>
) : (
<span className="rounded-md border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[11px] font-semibold text-slate-500">
待审批
</span>
)}
</div>
<div className="truncate font-mono text-xs text-slate-400">{user.id}</div>
</div>
<div className="px-1 text-center text-sm tabular-nums text-slate-500">{user.qa_count}</div>
<div className="px-1">
<Switch
checked={allowed}
disabled={isUserAdmin || (setApproval.isPending && pendingId === user.id)}
onCheckedChange={(v) => handleToggleApproval(user.id, v)}
/>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
);
}
+11
View File
@@ -39,6 +39,7 @@ import LightAppIframeView from "./LightAppIframeView";
import LightAppListPage from "./LightAppListPage"; import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage"; import LightAppManagementPage from "./LightAppManagementPage";
import MenuManagementPage from "./MenuManagementPage"; import MenuManagementPage from "./MenuManagementPage";
import LoginWhitelistPage from "./LoginWhitelistPage";
import SensitiveWordsPage from "./SensitiveWordsPage"; import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage"; import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage"; import TaskButtonsPage from "./TaskButtonsPage";
@@ -395,6 +396,16 @@ export default function StrategyRoutes() {
) )
} }
/> />
<Route
path="admin/login-whitelist"
element={
getStoredAuth()?.system_role === "admin" ? (
<LoginWhitelistPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="admin/roundtable-diagnostics" path="admin/roundtable-diagnostics"
element={ element={
@@ -108,6 +108,9 @@ export default function TaskButtonsPage() {
idKind: "task", idKind: "task",
openMode: "blank", openMode: "blank",
loginParams: [], loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true, enabled: true,
sortOrder: maxSort + 1, sortOrder: maxSort + 1,
}, },
@@ -337,6 +340,37 @@ export default function TaskButtonsPage() {
<SelectItem value="self">当前窗口</SelectItem> <SelectItem value="self">当前窗口</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<label
className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs"
title="跳转时在链接后追加 token + 用户名(仅 token 登录时生效)"
>
<Switch
checked={r.appendAuth}
onCheckedChange={(v) => update(r.id, { appendAuth: v })}
/>
追加登录态
</label>
{r.appendAuth && (
<div className="flex shrink-0 items-center gap-1 text-xs">
<span className="text-muted-foreground">&amp;</span>
<Input
value={r.authTokenParam}
onChange={(e) => update(r.id, { authTokenParam: e.target.value })}
placeholder="token"
className="h-8 w-24"
title="token 的查询参数名(默认 token)"
/>
<span className="text-muted-foreground">=token1 &amp;</span>
<Input
value={r.authNameParam}
onChange={(e) => update(r.id, { authNameParam: e.target.value })}
placeholder="username"
className="h-8 w-28"
title="用户名的查询参数名(默认 username)"
/>
<span className="text-muted-foreground">=用户名</span>
</div>
)}
</> </>
) : ( ) : (
<Select value={r.target} onValueChange={(v) => update(r.id, { target: v })}> <Select value={r.target} onValueChange={(v) => update(r.id, { target: v })}>
@@ -0,0 +1,68 @@
/**
* 登录白名单「总开关」API 客户端 + React Query hooks。
*
* 后端:`GET|PUT /api/system-settings/login-whitelist`(admin only)。
* 开关本身存系统设置 JSON,即时生效、免重启;逐用户的放行标记走
* `core/auth`(`UserListItem.approved` + `setUserApproval`)。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
const BASE = () => `${getBackendBaseURL()}/api/system-settings/login-whitelist`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} failed (${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
export async function getLoginWhitelistEnabled(): Promise<boolean> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ enabled: boolean }>(res, "load login whitelist");
return Boolean(data.enabled);
}
export async function setLoginWhitelistEnabled(enabled: boolean): Promise<boolean> {
const res = await apiFetch(BASE(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
const data = await readJson<{ enabled: boolean }>(res, "save login whitelist");
writeYLog({
optType: enabled ? OptType.enabled : OptType.disabled,
optDetail: `CM助手-全量-白名单管理-${enabled ? "启用" : "停用"}登录白名单`,
url: "/api/system-settings/login-whitelist",
reqParam: { enabled },
});
return Boolean(data.enabled);
}
export const LOGIN_WHITELIST_KEY = "login-whitelist-enabled";
export function useLoginWhitelistEnabled() {
return useQuery({
queryKey: [LOGIN_WHITELIST_KEY],
queryFn: getLoginWhitelistEnabled,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
}
export function useSetLoginWhitelistEnabled() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: setLoginWhitelistEnabled,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [LOGIN_WHITELIST_KEY] }),
});
}
@@ -39,6 +39,9 @@ interface ServerButton {
id_kind: string; id_kind: string;
open_mode: string; open_mode: string;
login_params: ServerLoginParam[]; login_params: ServerLoginParam[];
append_auth: boolean;
auth_token_param: string;
auth_name_param: string;
enabled: boolean; enabled: boolean;
sort_order: number; sort_order: number;
} }
@@ -63,6 +66,9 @@ function toButton(s: ServerButton): TaskButton {
idKind: (s.id_kind as TaskButtonIdKind) ?? "task", idKind: (s.id_kind as TaskButtonIdKind) ?? "task",
openMode: (s.open_mode as TaskButtonOpenMode) ?? "blank", openMode: (s.open_mode as TaskButtonOpenMode) ?? "blank",
loginParams: (s.login_params ?? []).map(toLoginParam), loginParams: (s.login_params ?? []).map(toLoginParam),
appendAuth: s.append_auth ?? false,
authTokenParam: s.auth_token_param || "token",
authNameParam: s.auth_name_param || "username",
enabled: s.enabled ?? true, enabled: s.enabled ?? true,
sortOrder: s.sort_order ?? 0, sortOrder: s.sort_order ?? 0,
}; };
@@ -84,6 +90,9 @@ function toBody(b: TaskButton): ServerButton {
source: p.source, source: p.source,
custom_value: p.customValue, custom_value: p.customValue,
})), })),
append_auth: b.appendAuth,
auth_token_param: b.authTokenParam,
auth_name_param: b.authNameParam,
enabled: b.enabled, enabled: b.enabled,
sort_order: b.sortOrder, sort_order: b.sortOrder,
}; };
+3 -2
View File
@@ -244,6 +244,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific | | **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id | | **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
| **Open Chat** (`/api/open/chat`) | **Unauthenticated** open Q&A entry for external systems (the `/api/open/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES` + CSRF-exempt in `csrf_middleware.should_check_csrf`). `POST /chat` `{message \| messages[], model_name?, agent_name?, agent_id?, thinking_enabled=false, strip_think=true, thread_id?, recursion_limit=100}` → resolves the agent by **Chinese display name** (`agent_name`, matched against each `.deer-flow/agents/*/config.yaml` `name` via `_resolve_agent_id`; explicit `agent_id` wins if given), builds the lead agent via `make_lead_agent` (so the chosen **agent's** SOUL/tools/skills all apply), `ainvoke`s it once (stateless — no checkpointer, no thread persistence; runs under the `"default"` user bucket), and returns **only the final** `{answer, model_name, agent_name, agent_id, thinking_enabled, thread_id}` (no intermediate tool-call/process). **`POST /chat/stream`** is the **SSE streaming variant** (same request body) for **long tasks** — the blocking `/chat` produces no downstream bytes for the whole run, so a front nginx `proxy_read_timeout` (default 60s) cuts it off with a **504**; the stream keeps the connection warm (a `: keepalive` SSE comment every `_HEARTBEAT_SECONDS=15`s via a background producer-task + queue, plus `event: delta` AI-text increments and `event: progress`), and ends with **`event: result`** carrying the same final `{answer, …}` JSON (or `event: error`). Clients only need to read `event: result`; intermediate events are ignorable. Both endpoints share `_build_run_context` (config/agent/thread). `GET /agents` lists `{name, id}` so callers know what `agent_name` to send. **Thinking off is reliable**: when `thinking_enabled=false` the route also sets `thinking_force_disabled=true` so the lead agent calls `create_chat_model(force_disable_thinking=True)`, which injects **three** OpenAI-compatible "thinking off" shapes into `extra_body` — top-level `enable_thinking=false` (**SiliconFlow / DashScope hybrid models** like `deepseek-ai/DeepSeek-V4-*` on `api.siliconflow.cn` — the knob the nested `chat_template_kwargs` shape does NOT reach), `chat_template_kwargs.enable_thinking=false` (vLLM/Qwen native), and `thinking.type="disabled"` (Zhipu GLM) — even for models that only declare `supports_thinking: true`. For the **web-UI path** (which sends `thinking_enabled=false` but NOT `force_disable`), SiliconFlow models need `when_thinking_disabled: {extra_body: {enable_thinking: false}}` in their `config.yaml` entry (added to `deepseek-ai/DeepSeek-V4-Flash`) for thinking-off to take effect. Sets `is_scheduled_run=true` internally to skip the auto-title LLM call. **Generated-file return (`include_files=true`, default on)**: an agent that produces its report as a file (writes to `/mnt/user-data/outputs/` + `present_files`) returns only a short summary in `answer`; to recover the real deliverable both endpoints now also return `files: [{name, virtual_path, encoding(text|base64), content}]` — `_collect_output_files` scans the run's `state['thread_data']['outputs_path']` (the open thread is stateless so artifacts can't be fetched by thread, but the endpoint runs server-side and the sandbox already wrote the files to disk), text decoded as utf-8 else base64, capped by `_MAX_OUTPUT_FILES=20` / `_MAX_OUTPUT_FILE_BYTES=5MB`. `/chat` puts `files` on the response; `/chat/stream` puts it in the `event: result` payload. A run with no answer **and** no files now 502s ("…文本答案或文件"). Router `app/gateway/routers/open_chat.py`; standalone caller `test_open_chat_api.py` (repo root, streaming by default); tests `tests/test_open_chat.py`. | | **Open Chat** (`/api/open/chat`) | **Unauthenticated** open Q&A entry for external systems (the `/api/open/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES` + CSRF-exempt in `csrf_middleware.should_check_csrf`). `POST /chat` `{message \| messages[], model_name?, agent_name?, agent_id?, thinking_enabled=false, strip_think=true, thread_id?, recursion_limit=100}` → resolves the agent by **Chinese display name** (`agent_name`, matched against each `.deer-flow/agents/*/config.yaml` `name` via `_resolve_agent_id`; explicit `agent_id` wins if given), builds the lead agent via `make_lead_agent` (so the chosen **agent's** SOUL/tools/skills all apply), `ainvoke`s it once (stateless — no checkpointer, no thread persistence; runs under the `"default"` user bucket), and returns **only the final** `{answer, model_name, agent_name, agent_id, thinking_enabled, thread_id}` (no intermediate tool-call/process). **`POST /chat/stream`** is the **SSE streaming variant** (same request body) for **long tasks** — the blocking `/chat` produces no downstream bytes for the whole run, so a front nginx `proxy_read_timeout` (default 60s) cuts it off with a **504**; the stream keeps the connection warm (a `: keepalive` SSE comment every `_HEARTBEAT_SECONDS=15`s via a background producer-task + queue, plus `event: delta` AI-text increments and `event: progress`), and ends with **`event: result`** carrying the same final `{answer, …}` JSON (or `event: error`). Clients only need to read `event: result`; intermediate events are ignorable. Both endpoints share `_build_run_context` (config/agent/thread). `GET /agents` lists `{name, id}` so callers know what `agent_name` to send. **Thinking off is reliable**: when `thinking_enabled=false` the route also sets `thinking_force_disabled=true` so the lead agent calls `create_chat_model(force_disable_thinking=True)`, which injects **three** OpenAI-compatible "thinking off" shapes into `extra_body` — top-level `enable_thinking=false` (**SiliconFlow / DashScope hybrid models** like `deepseek-ai/DeepSeek-V4-*` on `api.siliconflow.cn` — the knob the nested `chat_template_kwargs` shape does NOT reach), `chat_template_kwargs.enable_thinking=false` (vLLM/Qwen native), and `thinking.type="disabled"` (Zhipu GLM) — even for models that only declare `supports_thinking: true`. For the **web-UI path** (which sends `thinking_enabled=false` but NOT `force_disable`), SiliconFlow models need `when_thinking_disabled: {extra_body: {enable_thinking: false}}` in their `config.yaml` entry (added to `deepseek-ai/DeepSeek-V4-Flash`) for thinking-off to take effect. Sets `is_scheduled_run=true` internally to skip the auto-title LLM call. **Generated-file return (`include_files=true`, default on)**: an agent that produces its report as a file (writes to `/mnt/user-data/outputs/` + `present_files`) returns only a short summary in `answer`; to recover the real deliverable both endpoints now also return `files: [{name, virtual_path, encoding(text|base64), content}]` — `_collect_output_files` scans the run's `state['thread_data']['outputs_path']` (the open thread is stateless so artifacts can't be fetched by thread, but the endpoint runs server-side and the sandbox already wrote the files to disk), text decoded as utf-8 else base64, capped by `_MAX_OUTPUT_FILES=20` / `_MAX_OUTPUT_FILE_BYTES=5MB`. `/chat` puts `files` on the response; `/chat/stream` puts it in the `event: result` payload. A run with no answer **and** no files now 502s ("…文本答案或文件"). Router `app/gateway/routers/open_chat.py`; standalone caller `test_open_chat_api.py` (repo root, streaming by default); tests `tests/test_open_chat.py`. |
| **Open 3qfx Ask** (`/api/open/3qfx/ask`) | **Unauthenticated** open entry that, given just a **任务id**, directly kicks off the 3qfx(三情分析)「第一次问答」as a **real persisted conversation** (sibling of `/api/open/chat`; same `/api/open/` auth-whitelist + CSRF-exempt). `POST /ask` `{task_id, message?, model_name?, thinking_enabled=false}` → (1) resolves the `3Q` agent from `app.state.business_mapping_store.get_mapping("3Q")` (the **same** agent the frontend deep-link `goPath=3qfx` uses; 503 if unconfigured); (2) **fetches the task detail server-side** (consumer `cop-task-detail`) and builds the opening text **identical to the frontend** `buildOpeningText` 3qfx branch — `任务名称:…\n任务内容:…\n任务id(task_id)为{task_id}` (an explicit `message` overrides and **skips** the fetch); (3) creates a fresh thread tagged `metadata.{taskId, agent_id}` (the exact filter `useTaskThreads` uses, and `metadata.taskId` makes it task-deeplink-shared / creator-path-resolved) and **starts a background persisted run** via `start_run(..., enforce_access=False)` (writes the checkpointer, runs under the `"default"` bucket); (4) **returns immediately** `{thread_id, run_id, agent_id, status, opening_text}` — the caller just needs "started", then watches the run in the 3qfx 任务工作区 conversation list. `start_run` gained a keyword-only `enforce_access` (default `True`); the open entry passes `False` so the admin-configured agent isn't rejected by the per-(missing)-login visibility check. **Consumer fetch config** lives in `config.yaml → task_deeplink`: `cop_task_detail_url` (full URL, appends `?taskId=`), `cop_task_detail_authorization` (default `Bearer admin`), `cop_task_detail_test` + `mock.cop_task_detail` (offline fake when the upstream is unreachable — on in this deployment). Router `app/gateway/routers/open_3qfx.py`; tests `tests/test_open_3qfx.py`. |
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. | | **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. | | **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta — incl. `chain_title`, the business-chain name used this session, cheaply regex-extracted from the in-memory `step2` blob in `_draft_to_meta`/`_extract_chain_title`; null = no chain / 自由模式; surfaced on the 第一步「分析记录」history card + sidebar list instead of the old「第 X 步」); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. | | **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta — incl. `chain_title`, the business-chain name used this session, cheaply regex-extracted from the in-memory `step2` blob in `_draft_to_meta`/`_extract_chain_title`; null = no chain / 自由模式; surfaced on the 第一步「分析记录」history card + sidebar list instead of the old「第 X 步」); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
@@ -258,7 +259,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. | | **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. | | **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析登录拉取的业务 JSON → report-json → 渲染 `DashboardApp`;输入 seam `core/auth/dashboard-input.ts`(JSON 接口未就绪时回退 copTaskDetail 占位)。Tests: `tests/test_dashboard_sessions.py`。 | | **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析登录拉取的业务 JSON → report-json → 渲染 `DashboardApp`;输入 seam `core/auth/dashboard-input.ts`(JSON 接口未就绪时回退 copTaskDetail 占位)。Tests: `tests/test_dashboard_sessions.py`。 |
| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, enabled, sort_order}`. `login_params` (JSON column `login_params_json`, `PortableJSON`, migration `20260625_01`) = a list of `{key, source, custom_value}` the **frontend** resolves at click time for `url` buttons (`source` = a `userInfo` field or `other`→literal `custom_value`) and appends to the target URL — mirrors the light-app login params; empty = no extra params. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. | | **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, append_auth, auth_token_param, auth_name_param, enabled, sort_order}`. **Two independent, coexisting login-append mechanisms for `url` buttons** — both resolved frontend-side: (1) `login_params` (JSON column `login_params_json`, `PortableJSON`, migration `20260625_01`) = a list of `{key, source, custom_value}` resolved at click time (`source` = a `userInfo` field or `other`→literal `custom_value`) and appended to the target URL — mirrors the light-app login params; empty = nothing appended. (2) `append_auth` + `auth_token_param` + `auth_name_param` (columns, migration `20260625_02`, default off) — appends `?{auth_token_param}={token1}&{auth_name_param}={username}` so the target system can identify the current user; gated on **token login** — the frontend only stashes `token1` (the `?authToken=`) + the raw `getTokenInfo` username (`login.token1`/`login.username`) when login went through `/login/token`, so a non-token-login session appends neither. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). | | **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway. Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
@@ -807,7 +808,7 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_
- `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens) - `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens)
- `auth_login` - Custom login modes layered on the base auth (`deerflow/config/login_config.py`): - `auth_login` - Custom login modes layered on the base auth (`deerflow/config/login_config.py`):
- `username_login.{require_password, password}` - Password gate for `POST /api/v1/auth/login/username`. When `require_password=true`, the (otherwise passwordless) username login additionally requires `?password=<shared-secret>`; empty `password` falls back to `123ewq`. Gate only ? it does not change which account logs in. - `username_login.{require_password, password}` - Password gate for `POST /api/v1/auth/login/username`. When `require_password=true`, the (otherwise passwordless) username login additionally requires `?password=<shared-secret>`; empty `password` falls back to `123ewq`. Gate only ? it does not change which account logs in.
- `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with the caller-supplied token placed directly in the `Authorization: Bearer <token>` header (no cookie; the static `service_authorization` field is deprecated/unused, kept only for backward compat), reads `data.username` from a `{"state":"200",...}` body, then signs the matching local user in (auto-registering if absent). `token_info_url` is HTTPS ? `verify_ssl: false` skips cert verification for self-signed/internal UAT hosts, or set `ca_cert_path` to a PEM bundle (takes precedence, keeps verification on). See [docs/AUTH_LOGIN.md](docs/AUTH_LOGIN.md) for the full request/response contract. - `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path, mock_tokens}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with the caller-supplied token placed directly in the `Authorization: Bearer <token>` header (no cookie; the static `service_authorization` field is deprecated/unused, kept only for backward compat), reads `data.username` from a `{"state":"200",...}` body, then signs the matching local user in (auto-registering if absent). `token_info_url` is HTTPS ? `verify_ssl: false` skips cert verification for self-signed/internal UAT hosts, or set `ca_cert_path` to a PEM bundle (takes precedence, keeps verification on). **`mock_tokens` (offline test mapping `{token: username}`)**: when an incoming token matches a key, `_fetch_username_from_token` returns the mapped username and **skips the `token_info_url` call entirely** — for environments where the upstream `getTokenInfo` host is unreachable (e.g. external networks). Empty in prod (no effect); seeded `{"123ewq": "admin"}` in this deployment so `/login/任意?authToken=123ewq` signs in as admin. **`UsernameLoginResponse` now also returns `username`** (the raw resolved login name, pre email-normalization) on both `/login/token` and `/login/username`, so the frontend can append a faithful `?username=` on jump links (按钮管理 追加登录态) instead of the lossy email local-part. See [docs/AUTH_LOGIN.md](docs/AUTH_LOGIN.md) for the full request/response contract.
Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`. Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`.
@@ -50,6 +50,7 @@ from app.gateway.routers import (
models, models,
multi_agent, multi_agent,
notifications, notifications,
open_3qfx,
open_chat, open_chat,
parallel_agent_tasks, parallel_agent_tasks,
parallel_agents, parallel_agents,
@@ -1478,6 +1479,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式 # 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
app.include_router(open_chat.router) app.include_router(open_chat.router)
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
app.include_router(open_3qfx.router)
# --- ?????????? /page/workspace/roundtable/planning?--- # --- ?????????? /page/workspace/roundtable/planning?---
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py? # ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
# ??? frontend-web/docs/multi-agent-backend-dev.md # ??? frontend-web/docs/multi-agent-backend-dev.md
@@ -79,7 +79,7 @@ class LocalAuthProvider(AuthProvider):
""" """
return await self._repo.find_users_by_email_local_part_like(substring, limit=limit) return await self._repo.find_users_by_email_local_part_like(substring, limit=limit)
async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User: async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False, approved: bool = False) -> User:
"""Create a new local user. """Create a new local user.
Args: Args:
@@ -87,6 +87,8 @@ class LocalAuthProvider(AuthProvider):
password: Plain text password (will be hashed) password: Plain text password (will be hashed)
system_role: Role to assign ("admin" or "user") system_role: Role to assign ("admin" or "user")
needs_setup: If True, user must complete setup on first login needs_setup: If True, user must complete setup on first login
approved: Login-whitelist 放行标记. Regular self-registered users
default to ``False`` (待审批); admins are created ``True``.
Returns: Returns:
Created User instance Created User instance
@@ -97,6 +99,7 @@ class LocalAuthProvider(AuthProvider):
password_hash=password_hash, password_hash=password_hash,
system_role=system_role, system_role=system_role,
needs_setup=needs_setup, needs_setup=needs_setup,
approved=approved,
) )
return await self._repo.create_user(user) return await self._repo.create_user(user)
@@ -30,6 +30,7 @@ class User(BaseModel):
# Auth lifecycle # Auth lifecycle
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes") needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs") token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
approved: bool = Field(default=False, description="登录白名单放行标记;仅当系统设置 login_whitelist.enabled 打开时拦截未放行用户。新普通用户默认待审批(False)。")
class UserResponse(BaseModel): class UserResponse(BaseModel):
@@ -46,6 +46,7 @@ class SQLiteUserRepository(UserRepository):
oauth_id=row.oauth_id, oauth_id=row.oauth_id,
needs_setup=row.needs_setup, needs_setup=row.needs_setup,
token_version=row.token_version, token_version=row.token_version,
approved=row.approved,
) )
@staticmethod @staticmethod
@@ -60,6 +61,7 @@ class SQLiteUserRepository(UserRepository):
oauth_id=user.oauth_id, oauth_id=user.oauth_id,
needs_setup=user.needs_setup, needs_setup=user.needs_setup,
token_version=user.token_version, token_version=user.token_version,
approved=user.approved,
) )
# ── CRUD ────────────────────────────────────────────────────────── # ── CRUD ──────────────────────────────────────────────────────────
@@ -154,6 +156,7 @@ class SQLiteUserRepository(UserRepository):
row.oauth_id = user.oauth_id row.oauth_id = user.oauth_id
row.needs_setup = user.needs_setup row.needs_setup = user.needs_setup
row.token_version = user.token_version row.token_version = user.token_version
row.approved = user.approved
await session.commit() await session.commit()
return user return user
@@ -9,6 +9,7 @@ owner filtering works automatically via the sentinel pattern.
Fine-grained permission checks remain in authz.py decorators. Fine-grained permission checks remain in authz.py decorators.
""" """
import re
from collections.abc import Callable from collections.abc import Callable
from fastapi import HTTPException, Request, Response from fastapi import HTTPException, Request, Response
@@ -64,6 +65,20 @@ def _is_public(path: str) -> bool:
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES) return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
# 「发起问答」的 run 创建接口(会真正调用大模型):
# POST /api/threads/{id}/runs[/stream|/wait]
# POST /api/runs/{stream|wait}
# (含 nginx 未改写到的 /api/langgraph/... 前缀,双保险)
# 只匹配这些「提交问答」的 POST,不碰 GET 列表 / join / cancel / feedback 等,
# 这样登录白名单开启后,未放行用户(凭旧 token)无法再用接口问答,但普通浏览不受影响。
_QA_RUN_RE = re.compile(r"^/api/(?:langgraph/)?(?:threads/[^/]+/)?runs(?:/stream|/wait)?$")
def _is_qa_run_request(method: str, path: str) -> bool:
"""True for an authenticated「发起问答」run-creation request (see ``_QA_RUN_RE``)."""
return method.upper() == "POST" and bool(_QA_RUN_RE.match(path.rstrip("/")))
class AuthMiddleware(BaseHTTPMiddleware): class AuthMiddleware(BaseHTTPMiddleware):
"""Strict auth gate: reject requests without a valid session. """Strict auth gate: reject requests without a valid session.
@@ -148,6 +163,18 @@ class AuthMiddleware(BaseHTTPMiddleware):
# JWT-decode + DB-lookup pipeline a second time per request). # JWT-decode + DB-lookup pipeline a second time per request).
request.state.user = user request.state.user = user
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
# 登录白名单:开关开启时,未放行用户(非 admin)不仅被拦在登录外,连「接口
# 问答」也封掉——已拿到 token 的未放行用户用旧会话直接调问答接口同样被拒。
# 仅作用于「发起问答」的 run 接口;内部/渠道调用(internal_user)不受限。
if internal_user is None and _is_qa_run_request(request.method, app_relative_path(request)):
from app.gateway.routers.auth import enforce_user_approved
try:
enforce_user_approved(user)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
token = set_current_user(user) token = set_current_user(user)
try: try:
return await call_next(request) return await call_next(request)
@@ -12,6 +12,7 @@ from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field, field_validator from pydantic import BaseModel, EmailStr, Field, field_validator
from deerflow.config import get_app_config from deerflow.config import get_app_config
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.skills.base import SkillStore from deerflow.persistence.skills.base import SkillStore
@@ -145,6 +146,15 @@ class UsernameLoginResponse(BaseModel):
system_role: Literal["admin", "user"] = "user" system_role: Literal["admin", "user"] = "user"
needs_setup: bool = False needs_setup: bool = False
created: bool = Field(default=False, description="True when the user was auto-registered by this call") created: bool = Field(default=False, description="True when the user was auto-registered by this call")
username: str | None = Field(
default=None,
description=(
"The login username this call resolved (token login: the value read "
"from getTokenInfo; username login: the supplied username). Used by "
"the frontend to append ?username= on task-workspace jump links; the "
"stored email local-part is lossy (lowercased/normalized)."
),
)
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
@@ -246,7 +256,9 @@ async def _get_or_create_user_by_username(username: str):
if user is None: if user is None:
try: try:
role = "admin" if username == "admin" else "user" role = "admin" if username == "admin" else "user"
user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False) # admin 创建即放行;普通用户自助注册后默认「待审批」(approved=False),
# 需管理员在白名单管理页放行——但仅当登录白名单开关打开时才会被拦。
user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False, approved=(role == "admin"))
created = True created = True
logger.info("Auto-registered passwordless user: %s", email) logger.info("Auto-registered passwordless user: %s", email)
except ValueError: except ValueError:
@@ -267,6 +279,35 @@ async def _get_or_create_user_by_username(username: str):
return user, created return user, created
def enforce_user_approved(user) -> None:
"""登录白名单拦截:开关打开且用户未放行时拒绝登录(注册照常,只是不发 token)。
规则(三条硬约束防锁死):
- 开关关(``login_whitelist.enabled`` 默认 False)→ 直接放行,人人可登录。
- admin → 永远放行(避免管理员把自己关在门外)。
- 其余:``approved`` 为 False(注册后待审批)→ 403 ``PENDING_APPROVAL``;
前端据此提示「等待管理员开通」,且不写任何登录态。
放在各登录端点拿到 user 之后、签发 JWT 之前调用——账号此时已自助注册完成,
被拦只是拿不到 token,管理员可在白名单管理页看到该待审批账号并放行。
"""
try:
enabled = load_system_settings().login_whitelist.enabled
except Exception:
# 读配置失败时绝不误伤(白名单是附加闸门,故障时回到「不拦截」)。
logger.warning("读取登录白名单配置失败,本次登录不拦截", exc_info=True)
return
if not enabled:
return
if getattr(user, "system_role", None) == "admin":
return
if not getattr(user, "approved", False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "PENDING_APPROVAL", "message": "您的账号正在等待管理员开通,请联系管理员"},
)
async def _fetch_username_from_token(token: str) -> str: async def _fetch_username_from_token(token: str) -> str:
"""Resolve an upstream access token to a username via the configured endpoint. """Resolve an upstream access token to a username via the configured endpoint.
@@ -277,6 +318,15 @@ async def _fetch_username_from_token(token: str) -> str:
Any upstream failure, non-200 ``state``, or missing username raises HTTP 401. Any upstream failure, non-200 ``state``, or missing username raises HTTP 401.
""" """
config = get_app_config().auth_login.token_login config = get_app_config().auth_login.token_login
# Offline test shortcut: a token listed in ``mock_tokens`` resolves to its
# mapped username without ever calling the (possibly unreachable) upstream
# ``token_info_url``. Lets token login be exercised end-to-end where the real
# getTokenInfo host can't be reached (external networks). Empty in prod.
mocked = config.mock_tokens.get(token)
if mocked and str(mocked).strip():
return str(mocked).strip()
if not config.token_info_url: if not config.token_info_url:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="token 登录未配置(token_info_url 为空)") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="token 登录未配置(token_info_url 为空)")
@@ -489,6 +539,7 @@ async def login_local(
) )
_record_login_success(client_ip) _record_login_success(client_ip)
enforce_user_approved(user)
token = create_access_token(str(user.id), token_version=user.token_version) token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request) _set_session_cookie(response, token, request)
await _sync_user_position(request, user.id) await _sync_user_position(request, user.id)
@@ -534,6 +585,7 @@ async def login_by_username(
user, created = await _get_or_create_user_by_username(body.username) user, created = await _get_or_create_user_by_username(body.username)
enforce_user_approved(user)
token = create_access_token(str(user.id), token_version=user.token_version) token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request) _set_session_cookie(response, token, request)
await _sync_user_position(request, user.id) await _sync_user_position(request, user.id)
@@ -547,6 +599,7 @@ async def login_by_username(
system_role=user.system_role, system_role=user.system_role,
needs_setup=user.needs_setup, needs_setup=user.needs_setup,
created=created, created=created,
username=body.username,
) )
@@ -585,6 +638,7 @@ async def login_by_token(
user, created = await _get_or_create_user_by_username(username) user, created = await _get_or_create_user_by_username(username)
enforce_user_approved(user)
access_token = create_access_token(str(user.id), token_version=user.token_version) access_token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, access_token, request) _set_session_cookie(response, access_token, request)
await _sync_user_position(request, user.id) await _sync_user_position(request, user.id)
@@ -598,6 +652,9 @@ async def login_by_token(
system_role=user.system_role, system_role=user.system_role,
needs_setup=user.needs_setup, needs_setup=user.needs_setup,
created=created, created=created,
# The raw username resolved from getTokenInfo (pre email-normalization),
# so the frontend can append the exact ?username= on jump links.
username=username,
) )
@@ -745,7 +802,7 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
) )
try: try:
user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="admin", needs_setup=False) user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="admin", needs_setup=False, approved=True)
except ValueError: except ValueError:
# DB unique-constraint race: another concurrent request beat us. # DB unique-constraint race: another concurrent request beat us.
raise HTTPException( raise HTTPException(
@@ -769,6 +826,7 @@ class UserListItem(BaseModel):
skill_count: int skill_count: int
agent_count: int agent_count: int
qa_count: int = 0 # 问答次数: number of runs (one run = one question) qa_count: int = 0 # 问答次数: number of runs (one run = one question)
approved: bool = True # 登录白名单放行标记(白名单管理页据此放行/取消)
@router.get("/users", response_model=list[UserListItem]) @router.get("/users", response_model=list[UserListItem])
@@ -801,11 +859,44 @@ async def list_all_users(
skill_count=await skill_store.count_owned(uid), skill_count=await skill_store.count_owned(uid),
agent_count=await agent_store.count_owned(uid), agent_count=await agent_store.count_owned(uid),
qa_count=qa_counts.get(uid, 0), qa_count=qa_counts.get(uid, 0),
approved=getattr(u, "approved", True),
) )
) )
return result return result
class UserApprovalRequest(BaseModel):
approved: bool = Field(..., description="True=放行该用户登录;False=取消放行(下次登录被拦)")
@router.put("/users/{user_id}/approval", response_model=UserListItem)
async def set_user_approval(user_id: str, body: UserApprovalRequest, request: Request) -> UserListItem:
"""放行 / 取消放行某个注册用户(登录白名单)。Admin only。
取消放行只拦该用户的下次登录(不动 ``token_version``,现有会话不强制下线)。
"""
admin = await get_current_user_from_request(request)
if admin.system_role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
provider = get_local_provider()
target = await provider.get_user(user_id)
if target is None:
raise HTTPException(status_code=404, detail="用户不存在")
target.approved = bool(body.approved)
saved = await provider.update_user(target)
return UserListItem(
id=str(saved.id),
email=saved.email,
system_role=saved.system_role,
skill_count=0,
agent_count=0,
qa_count=0,
approved=saved.approved,
)
# ── OAuth Endpoints (Future/Placeholder) ───────────────────────────────── # ── OAuth Endpoints (Future/Placeholder) ─────────────────────────────────
@@ -0,0 +1,186 @@
"""开放接口:按 taskId 直接发起 3qfx(三情分析)的「第一次问答」(无需登录)。
外部系统只需把一个 **任务id** 传进来,后端就会:
1. 解析 ``3Q`` 业务配置的智能体(``business_mapping`` 里 ``business_code=="3Q"`` 那一行的
``agent_id``,与前端任务深链 ``goPath=3qfx`` 取的是同一个);
2. 按 taskId 拉取任务详情(consumer 的 ``cop-task-detail``,地址 / 鉴权头 / mock 都在
``config.yaml`` 的 ``task_deeplink`` 段),拼出与前端深链一致的开场白
``任务名称:…\n任务内容:…\n任务id(task_id)为…``;
3. **新建一条真实会话**(thread 打 ``metadata.{taskId, agent_id}`` 标签,与前端
``taskThreadMetadata`` / ``useTaskThreads`` 的过滤口径一致),以开场白作为第一条用户消息
**后台启动一次持久化运行**(写 checkpointer,不阻塞);
4. **立即返回** ``{thread_id, run_id, agent_id, status}`` —— 调用方拿到「调用成功」即可,
随后在 3qfx 任务工作区的对话列表里就能看到这条问答并实时观看其进展。
该路由前缀 ``/api/open/`` 已在 ``auth_middleware._PUBLIC_PATH_PREFIXES`` 白名单里(且
``csrf_middleware`` 豁免),**绕过登录鉴权**,以 ``"default"`` 用户桶运行;因 thread 带
``metadata.taskId``(任务深链对话全局共享、按创建者解析文件路径),任何已登录用户打开该任务
都能看到同一条会话。
请求示例::
POST /api/open/3qfx/ask
{"task_id": "2055"}
返回::
{"thread_id": "…", "run_id": "…", "agent_id": "…", "status": "running",
"opening_text": "任务名称:…\n任务内容:…\n任务id(task_id)为2055"}
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.services import start_run
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/open/3qfx", tags=["open"])
# 与前端任务深链 goPath=3qfx → businessCode 的映射保持一致。
_THREEQ_BUSINESS_CODE = "3Q"
class ThreeQAskRequest(BaseModel):
"""3qfx 开放问答请求体。"""
task_id: str = Field(..., description="任务id(cop-task-detail 的 taskId)", min_length=1)
message: str | None = Field(
default=None,
description="可选:自定义首条问题文本。留空(默认)则后端按 task_id 拉取任务详情自动拼开场白。",
)
model_name: str | None = Field(default=None, description="可选:覆盖模型(models[] 中的 name)")
thinking_enabled: bool = Field(default=False, description="是否开启思考;默认关闭")
class ThreeQAskResponse(BaseModel):
thread_id: str
run_id: str
agent_id: str
status: str
opening_text: str
class CopTaskDetail(BaseModel):
"""consumer ``cop-task-detail`` 返回的 ``data`` 字段(与前端 CopTaskDetail 对齐)。"""
id: str | int | None = None
overview: str = ""
task_direction: str = ""
task_name: str = ""
task_content: str = ""
async def _resolve_threeq_agent_id(request: Request) -> str:
"""取 ``business_code=="3Q"`` 配置的智能体 id;未配置 → 503。"""
store = getattr(request.app.state, "business_mapping_store", None)
if store is None:
raise HTTPException(status_code=503, detail="业务映射未就绪(business_mapping_store 不可用)。")
mapping = await store.get_mapping(_THREEQ_BUSINESS_CODE)
agent_id = (mapping or {}).get("agent_id")
if not agent_id:
raise HTTPException(status_code=503, detail="3Q(三情分析)业务尚未在「业务链条管理」里配置智能体。")
return str(agent_id)
def _parse_cop_task_detail(body: Any) -> CopTaskDetail:
"""从 ``{data: {...}}`` 整包里抽出任务详情(字段缺失按空串)。"""
data = body.get("data") if isinstance(body, dict) else None
if not isinstance(data, dict):
return CopTaskDetail()
return CopTaskDetail(
id=data.get("id"),
overview=data.get("overview") if isinstance(data.get("overview"), str) else "",
task_direction=data.get("taskDirection") if isinstance(data.get("taskDirection"), str) else "",
task_name=data.get("taskName") if isinstance(data.get("taskName"), str) else "",
task_content=data.get("taskContent") if isinstance(data.get("taskContent"), str) else "",
)
async def _fetch_cop_task_detail(task_id: str) -> CopTaskDetail:
"""按 taskId 取任务详情。``cop_task_detail_test`` 为真走 mock;否则打真实 consumer 接口。"""
cfg = get_app_config().task_deeplink
if cfg.cop_task_detail_test:
return _parse_cop_task_detail((cfg.mock or {}).get("cop_task_detail"))
url = (cfg.cop_task_detail_url or "").strip()
if not url:
raise HTTPException(status_code=503, detail="未配置任务详情接口(task_deeplink.cop_task_detail_url),无法按 id 拉取。")
try:
async with httpx.AsyncClient(timeout=20.0) as client:
resp = await client.get(
url,
params={"taskId": task_id},
headers={"Authorization": cfg.cop_task_detail_authorization or "Bearer admin"},
)
resp.raise_for_status()
body = resp.json()
except Exception as exc: # noqa: BLE001 — 开放接口需把上游错误归一成 502
logger.warning("拉取 cop-task-detail 失败 (taskId=%s): %s", task_id, exc)
raise HTTPException(status_code=502, detail=f"拉取任务详情失败:{exc}") from exc
return _parse_cop_task_detail(body)
def build_opening_text(detail: CopTaskDetail, task_id: str) -> str:
"""拼 3qfx 开场白,与前端 ``buildOpeningText``(3qfx 分支)逐行对齐。"""
parts = [
f"任务名称:{detail.task_name}" if detail.task_name else "",
f"任务内容:{detail.task_content}" if detail.task_content else "",
f"任务id(task_id)为{task_id}" if task_id else "",
]
return "\n".join(p for p in parts if p)
@router.post("/ask", response_model=ThreeQAskResponse)
async def ask_3qfx(body: ThreeQAskRequest, request: Request) -> ThreeQAskResponse:
"""按 taskId 直接发起 3qfx 第一次问答;后台运行,立即返回 thread_id。"""
task_id = body.task_id.strip()
if not task_id:
raise HTTPException(status_code=422, detail="task_id 不能为空。")
agent_id = await _resolve_threeq_agent_id(request)
# 首条问题:默认按 id 自动拉取任务详情拼开场白;显式传 message 则用它覆盖。
if body.message and body.message.strip():
opening_text = body.message.strip()
else:
detail = await _fetch_cop_task_detail(task_id)
opening_text = build_opening_text(detail, task_id)
if not opening_text:
raise HTTPException(status_code=502, detail="任务详情为空,无法生成首条问题(可改用 message 显式传入)。")
thread_id = str(uuid.uuid4())
context: dict[str, Any] = {"agent_id": agent_id, "thinking_enabled": body.thinking_enabled}
if body.model_name:
context["model_name"] = body.model_name
run_body = RunCreateRequest(
assistant_id=agent_id,
input={"messages": [{"role": "user", "content": opening_text}]},
# taskId + agent_id 让该会话被 3qfx 任务工作区的 useTaskThreads 过滤命中(按任务+业务隔离),
# 且 metadata.taskId 触发后端「任务深链对话全局共享 / 按创建者解析文件」语义。
metadata={"taskId": task_id, "agent_id": agent_id},
context=context,
multitask_strategy="reject",
)
# enforce_access=False:开放接口已自行解析 admin 配置的 3Q 智能体,不再按当前(无)登录用户做可见性校验。
record = await start_run(run_body, thread_id, request, enforce_access=False)
return ThreeQAskResponse(
thread_id=record.thread_id,
run_id=record.run_id,
agent_id=agent_id,
status=record.status.value,
opening_text=opening_text,
)
@@ -451,6 +451,36 @@ class AgentModuleConfigUpdateRequest(BaseModel):
tag_ids: list[str] = Field(default_factory=list, description="Full replacement list of selected agent tag ids") tag_ids: list[str] = Field(default_factory=list, description="Full replacement list of selected agent tag ids")
class LoginWhitelistResponse(BaseModel):
enabled: bool = Field(..., description="是否启用登录白名单(关=所有人可登录,开=仅已放行用户/admin 可登录)")
class LoginWhitelistUpdateRequest(BaseModel):
enabled: bool | None = None
@router.get("/login-whitelist", response_model=LoginWhitelistResponse)
async def get_login_whitelist(request: Request) -> LoginWhitelistResponse:
"""Return the login-whitelist master switch. Admin only (它驱动谁能登录,不公开)."""
await _require_admin(request)
return LoginWhitelistResponse(enabled=load_system_settings().login_whitelist.enabled)
@router.put("/login-whitelist", response_model=LoginWhitelistResponse)
async def update_login_whitelist(body: LoginWhitelistUpdateRequest, request: Request) -> LoginWhitelistResponse:
"""Toggle the login-whitelist master switch. Admin only."""
await _require_admin(request)
current = load_system_settings()
update: dict[str, object] = {}
fields = body.model_dump(exclude_unset=True)
if "enabled" in fields and fields["enabled"] is not None:
update["enabled"] = bool(fields["enabled"])
updated = current.login_whitelist.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"login_whitelist": updated}))
return LoginWhitelistResponse(enabled=saved.login_whitelist.enabled)
@router.get("/agent-modules/{key}", response_model=AgentModuleConfigResponse) @router.get("/agent-modules/{key}", response_model=AgentModuleConfigResponse)
async def get_agent_module_config(key: str) -> AgentModuleConfigResponse: async def get_agent_module_config(key: str) -> AgentModuleConfigResponse:
"""Return the agent tag ids configured for ``key`` (readable by any user — """Return the agent tag ids configured for ``key`` (readable by any user —
@@ -62,6 +62,11 @@ class TaskButton(BaseModel):
open_mode: str = Field(default="blank", max_length=16) open_mode: str = Field(default="blank", max_length=16)
# url 外链追加的登录参数列表(key + 取值来源)。 # url 外链追加的登录参数列表(key + 取值来源)。
login_params: list[TaskButtonLoginParam] = Field(default_factory=list) login_params: list[TaskButtonLoginParam] = Field(default_factory=list)
# url 跳转是否追加登录态(token 登录的上游 token + 登录用户名)。仅 token 登录时实际拼上。
append_auth: bool = False
# 追加 token / 用户名的查询参数名。
auth_token_param: str = Field(default="token", max_length=64)
auth_name_param: str = Field(default="username", max_length=64)
enabled: bool = True enabled: bool = True
sort_order: int = 0 sort_order: int = 0
@@ -529,6 +529,8 @@ async def start_run(
body: Any, body: Any,
thread_id: str, thread_id: str,
request: Request, request: Request,
*,
enforce_access: bool = True,
) -> RunRecord: ) -> RunRecord:
"""Create a RunRecord and launch the background agent task. """Create a RunRecord and launch the background agent task.
@@ -560,7 +562,13 @@ async def start_run(
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_ disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
t_pre_enforce = time.perf_counter() t_pre_enforce = time.perf_counter()
await _enforce_agent_access(body, request) # ``enforce_access=False`` is for intentionally open/unauthenticated entry
# points (e.g. /api/open/3qfx/ask) that resolve the agent themselves from an
# admin-configured mapping rather than from a per-user-visible assistant_id.
# Those run under the "default" bucket, where a user-owned unpublished agent
# would otherwise 404 the access check.
if enforce_access:
await _enforce_agent_access(body, request)
t_post_enforce = time.perf_counter() t_post_enforce = time.perf_counter()
try: try:
+17 -1
View File
@@ -322,7 +322,7 @@ auth_login:
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录; # (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
# 用户名不在库里时自动注册。 # 用户名不在库里时自动注册。
token_login: token_login:
enabled: false enabled: true
token_info_url: 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo' token_info_url: 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo'
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。 # 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
service_authorization: 'Bearer admin' service_authorization: 'Bearer admin'
@@ -331,6 +331,11 @@ auth_login:
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。 # 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
verify_ssl: false verify_ssl: false
ca_cert_path: '' # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验 ca_cert_path: '' # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
# 离线测试映射:token -> 用户名。命中的 token 跳过 token_info_url 调用,直接当成
# 该用户登录——外网调不通 getTokenInfo 时用来本地全链路联调。生产留空即可(不生效)。
# 例:用 /login/任意?authToken=123ewq 进入,即以 admin 身份登录。
mock_tokens:
'123ewq': 'admin'
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口, # 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。 # 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
@@ -455,6 +460,9 @@ task_deeplink:
test: true # 目的树查询走 mock.purpose_detail 假数据 test: true # 目的树查询走 mock.purpose_detail 假数据
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id) action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id)
# 开放接口 POST /api/open/3qfx/ask 按 taskId 拉取任务详情拼开场白;上游不可达时打开下面开关走 mock.cop_task_detail。
cop_task_detail_test: true
cop_task_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/cop-task-detail"
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
# rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。 # rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。
delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData" delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData"
@@ -506,3 +514,11 @@ task_deeplink:
actionName: "" actionName: ""
actionStatus: 25 actionStatus: 25
narration: "" narration: ""
# cop_task_detail_test=true 时原样返回给开放接口 /api/open/3qfx/ask(结构同真实 cop-task-detail 返回)。
cop_task_detail:
data:
id: 2055
overview: "暑期文旅消费高峰期间的舆情态势综合研判与引导任务"
taskDirection: "正向引导 + 风险预警"
taskName: "任务01:暑期文旅舆情综合研判"
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
@@ -11,6 +11,7 @@ from langgraph.runtime import Runtime
from deerflow.agents.thread_state import ThreadDataState from deerflow.agents.thread_state import ThreadDataState
from deerflow.config.paths import Paths, get_paths from deerflow.config.paths import Paths, get_paths
from deerflow.runtime.thread_paths import resolve_path_user_id from deerflow.runtime.thread_paths import resolve_path_user_id
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -81,10 +82,15 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
@override @override
def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None: def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None:
context = runtime.context or {} context = runtime.context or {}
configurable = {}
try:
config = get_config()
configurable = config.get("configurable", {}) or {}
except RuntimeError:
pass
thread_id = context.get("thread_id") thread_id = context.get("thread_id")
if thread_id is None: if thread_id is None:
config = get_config() thread_id = configurable.get("thread_id")
thread_id = config.get("configurable", {}).get("thread_id")
if thread_id is None: if thread_id is None:
raise ValueError("Thread ID is required in runtime context or config.configurable") raise ValueError("Thread ID is required in runtime context or config.configurable")
@@ -93,7 +99,8 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
# thread creator so every account that shares the conversation writes to # thread creator so every account that shares the conversation writes to
# the same physical user-data directory. Personal threads resolve to the # the same physical user-data directory. Personal threads resolve to the
# current login unchanged. # current login unchanged.
user_id = resolve_path_user_id(thread_id) is_ephemeral = bool(context.get("ephemeral") or configurable.get("ephemeral"))
user_id = get_effective_user_id() if is_ephemeral else resolve_path_user_id(thread_id)
if self._lazy_init: if self._lazy_init:
# Lazy initialization: only compute paths, don't create directories # Lazy initialization: only compute paths, don't create directories
@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any, NotRequired, override
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware import AgentMiddleware
from langgraph.config import get_config
from langgraph.runtime import Runtime from langgraph.runtime import Runtime
from deerflow.config.title_config import get_title_config from deerflow.config.title_config import get_title_config
@@ -139,13 +138,29 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
return text.strip() return text.strip()
def _parse_title(self, content: object) -> str: def _parse_title(self, content: object) -> str:
"""Normalize model output into a clean title string.""" """Normalize model output into a clean title string (strips <think> blocks)."""
config = self._get_title_config() config = self._get_title_config()
title_content = self._normalize_content(content) title_content = self._strip_think_tags(self._normalize_content(content))
title_content = self._strip_think_tags(title_content)
title = title_content.strip().strip('"').strip("'") title = title_content.strip().strip('"').strip("'")
return title[: config.max_chars] if len(title) > config.max_chars else title return title[: config.max_chars] if len(title) > config.max_chars else title
def _isolated_config(self) -> dict[str, Any]:
"""Build a RunnableConfig that does NOT inherit the parent's streaming callbacks.
The leak that pushed title-generation reasoning into the live UI stream came
from inheriting the parent RunnableConfig (and with it the SSE/streaming
callbacks) for the title model call. Here we deliberately start from an empty
config with ``callbacks: []`` so the title model's tokens — including any
``<think>`` reasoning from a reasoning-only deployment — never reach the
frontend stream or the run journal as conversation output. The stored title
is additionally cleaned by ``_parse_title``/``_strip_think_tags``.
"""
return {
"run_name": "title_agent",
"tags": ["middleware:title"],
"callbacks": [],
}
def _fallback_title(self, user_msg: str) -> str: def _fallback_title(self, user_msg: str) -> str:
config = self._get_title_config() config = self._get_title_config()
fallback_chars = min(config.max_chars, 50) fallback_chars = min(config.max_chars, 50)
@@ -153,21 +168,6 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
return user_msg[:fallback_chars].rstrip() + "..." return user_msg[:fallback_chars].rstrip() + "..."
return user_msg if user_msg else "New Conversation" return user_msg if user_msg else "New Conversation"
def _get_runnable_config(self) -> dict[str, Any]:
"""Inherit the parent RunnableConfig and add middleware tag.
This ensures RunJournal identifies LLM calls from this middleware
as ``middleware:title`` instead of ``lead_agent``.
"""
try:
parent = get_config()
except Exception:
parent = {}
config = {**parent}
config["run_name"] = "title_agent"
config["tags"] = [*(config.get("tags") or []), "middleware:title"]
return config
def _provisional_title_result(self, state: TitleMiddlewareState) -> dict | None: def _provisional_title_result(self, state: TitleMiddlewareState) -> dict | None:
"""Set an instant placeholder title from the first user message (no LLM).""" """Set an instant placeholder title from the first user message (no LLM)."""
if not self._should_set_provisional_title(state): if not self._should_set_provisional_title(state):
@@ -221,7 +221,15 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
logger.debug("Could not override extra_body to disable thinking on title model", exc_info=True) logger.debug("Could not override extra_body to disable thinking on title model", exc_info=True)
async def _agenerate_title_result(self, state: TitleMiddlewareState) -> dict | None: async def _agenerate_title_result(self, state: TitleMiddlewareState) -> dict | None:
"""Generate a title asynchronously and fall back locally on failure.""" """Polish the title with an LLM, without leaking its reasoning to the UI.
The title model is invoked with an *isolated* config (``_isolated_config``)
that does not inherit the parent's streaming callbacks, so even a
reasoning-only deployment's ``<think>`` tokens never reach the live UI
stream or the run journal. Thinking is also force-disabled on the model,
and the final stored title is stripped of any residual ``<think>`` blocks.
On any failure we fall back to the local question-derived title.
"""
if not self._should_generate_title(state): if not self._should_generate_title(state):
return None return None
@@ -229,7 +237,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
prompt, user_msg = self._build_title_prompt(state) prompt, user_msg = self._build_title_prompt(state)
try: try:
model_kwargs = {"thinking_enabled": False} model_kwargs: dict[str, Any] = {"thinking_enabled": False, "force_disable_thinking": True}
if self._app_config is not None: if self._app_config is not None:
model_kwargs["app_config"] = self._app_config model_kwargs["app_config"] = self._app_config
if config.model_name: if config.model_name:
@@ -237,12 +245,12 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
else: else:
model = create_chat_model(**model_kwargs) model = create_chat_model(**model_kwargs)
self._force_disable_thinking(model) self._force_disable_thinking(model)
response = await model.ainvoke(prompt, config=self._get_runnable_config()) response = await model.ainvoke(prompt, config=self._isolated_config())
title = self._parse_title(response.content) title = self._parse_title(response.content)
if title: if title:
return {"title": title, "title_provisional": False} return {"title": title, "title_provisional": False}
except Exception: except Exception:
logger.debug("Failed to generate async title; falling back to local title", exc_info=True) logger.debug("Failed to generate async title via LLM; falling back to local title", exc_info=True)
return {"title": self._fallback_title(user_msg), "title_provisional": False} return {"title": self._fallback_title(user_msg), "title_provisional": False}
@override @override
@@ -285,6 +285,13 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
if not isinstance(last_message, HumanMessage): if not isinstance(last_message, HumanMessage):
return None return None
# Only turns with fresh upload metadata need the uploads directory.
# Avoid resolving the thread owner (which may hit the DB) for ordinary
# no-file chat turns, including ephemeral open-chat requests.
kwargs_files = (last_message.additional_kwargs or {}).get("files")
if not isinstance(kwargs_files, list) or not kwargs_files:
return None
# Resolve uploads directory for existence checks # Resolve uploads directory for existence checks
thread_id = (runtime.context or {}).get("thread_id") thread_id = (runtime.context or {}).get("thread_id")
if thread_id is None: if thread_id is None:
@@ -74,6 +74,16 @@ class TokenLoginConfig(BaseModel):
"certificate. Takes precedence over verify_ssl when set." "certificate. Takes precedence over verify_ssl when set."
), ),
) )
mock_tokens: dict[str, str] = Field(
default_factory=dict,
description=(
"Offline test mapping of token -> username. When an incoming token "
"matches a key here, the gateway skips the token_info_url call and "
"signs in as the mapped username — useful where the upstream "
"getTokenInfo endpoint is unreachable (e.g. external networks). "
"Empty in production (no effect). Example: {\"123ewq\": \"admin\"}."
),
)
class AuthLoginConfig(BaseModel): class AuthLoginConfig(BaseModel):
@@ -221,10 +221,23 @@ def set_module_tag_ids(
return settings.model_copy(update={"modules": modules}) return settings.model_copy(update={"modules": modules})
class LoginWhitelistSettings(BaseModel):
"""登录白名单总开关(管理员可在「白名单管理」页切换,即时生效、免重启)。
``enabled`` 关(默认) → 忽略每个用户的 ``approved`` 放行标记,人人可登录;
开 → 仅 ``approved=True`` 或 admin 可登录,其余用户注册后处于「待审批」,
被管理员放行前一律 403。空/默认即关,故部署后行为零变化。
"""
enabled: bool = Field(default=False, description="是否启用登录白名单;关=所有人可登录,开=仅已放行用户(或 admin)可登录")
model_config = ConfigDict(extra="ignore")
class SystemSettings(BaseModel): class SystemSettings(BaseModel):
"""Top-level system settings document.""" """Top-level system settings document."""
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings) prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
login_whitelist: LoginWhitelistSettings = Field(default_factory=LoginWhitelistSettings)
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings) private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
publish_squares: PublishSquaresSettings = Field(default_factory=PublishSquaresSettings) publish_squares: PublishSquaresSettings = Field(default_factory=PublishSquaresSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings) appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
@@ -41,6 +41,18 @@ class TaskDeeplinkConfig(BaseModel):
default=False, default=False,
description="为真时前端「行动→任务」解析走 mock.action_detail 假数据(不打真实接口)", description="为真时前端「行动→任务」解析走 mock.action_detail 假数据(不打真实接口)",
) )
cop_task_detail_test: bool = Field(
default=False,
description="为真时开放接口 POST /api/open/3qfx/ask 按 taskId 取任务详情走 mock.cop_task_detail 假数据(上游 consumer 不可达时用)",
)
cop_task_detail_url: str = Field(
default="",
description="任务详情接口完整 URL(开放接口 /api/open/3qfx/ask 后端按 taskId 拉取,追加 ?taskId=);与前端 cop-task-detail 同一上游。留空则该开放接口报 503。",
)
cop_task_detail_authorization: str = Field(
default="Bearer admin",
description="拉取任务详情时下发的 Authorization 头(与前端 axios 拦截器一致,默认 Bearer admin)",
)
purpose_detail_url: str = Field( purpose_detail_url: str = Field(
default="", default="",
description="目的树查询完整 URL,调用时追加 ?taskId=", description="目的树查询完整 URL,调用时追加 ?taskId=",
@@ -0,0 +1,61 @@
"""Add append_auth / auth_token_param / auth_name_param to task_buttons.
These back the「追加登录态」option on task-workspace jump buttons (按钮管理):
a ``url`` button can append the login token (``token``) + username (``username``)
to its external link so the target system can identify the current user.
Revision ID: 20260625_02
Revises: 20260625_01
Create Date: 2026-06-25
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260625_02"
down_revision = "20260625_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
# ``task_buttons`` may not exist yet on a brand-new DB — create_all handles
# that case with the columns already present, so only patch existing tables.
if _has_table("task_buttons"):
if not _has_column("task_buttons", "append_auth"):
op.add_column(
"task_buttons",
sa.Column("append_auth", sa.Boolean(), nullable=False, server_default="0"),
)
if not _has_column("task_buttons", "auth_token_param"):
op.add_column(
"task_buttons",
sa.Column("auth_token_param", sa.String(length=64), nullable=True, server_default="token"),
)
if not _has_column("task_buttons", "auth_name_param"):
op.add_column(
"task_buttons",
sa.Column("auth_name_param", sa.String(length=64), nullable=True, server_default="username"),
)
def downgrade() -> None:
for col in ("auth_name_param", "auth_token_param", "append_auth"):
if _has_column("task_buttons", col):
op.drop_column("task_buttons", col)
@@ -0,0 +1,52 @@
"""Add users.approved (登录白名单放行标记).
Backs the login-whitelist feature: when system setting ``login_whitelist.enabled``
is on, only ``approved=True`` users (or admins) may log in; self-registered
regular users default to ``approved=False`` (待审批) until an admin approves them.
Existing rows are backfilled to TRUE (``server_default="1"``) so flipping the
switch on for the first time never locks out users who already had accounts.
Revision ID: 20260625_03
Revises: 20260625_02
Create Date: 2026-06-25
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260625_03"
down_revision = "20260625_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
# A brand-new DB gets the column from create_all; only patch existing tables.
if _has_table("users") and not _has_column("users", "approved"):
op.add_column(
"users",
# server_default="1" backfills existing rows to 已放行 (avoid lockout).
sa.Column("approved", sa.Boolean(), nullable=False, server_default="1"),
)
def downgrade() -> None:
if _has_column("users", "approved"):
op.drop_column("users", "approved")
@@ -20,6 +20,9 @@ def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
"id_kind": str(data.get("id_kind") or "task"), "id_kind": str(data.get("id_kind") or "task"),
"open_mode": str(data.get("open_mode") or "blank"), "open_mode": str(data.get("open_mode") or "blank"),
"login_params": list(data.get("login_params") or []), "login_params": list(data.get("login_params") or []),
"append_auth": bool(data.get("append_auth", False)),
"auth_token_param": str(data.get("auth_token_param") or "token"),
"auth_name_param": str(data.get("auth_name_param") or "username"),
"enabled": bool(data.get("enabled", True)), "enabled": bool(data.get("enabled", True)),
"sort_order": int(data.get("sort_order", 0)), "sort_order": int(data.get("sort_order", 0)),
"updated_by": updated_by, "updated_by": updated_by,
@@ -53,6 +53,15 @@ class TaskButtonRow(Base):
# (``other`` 时取 ``custom_value`` 字面值)。每项 ``{key, source, custom_value}``。 # (``other`` 时取 ``custom_value`` 字面值)。每项 ``{key, source, custom_value}``。
# 旧行 / NULL 读时归一化为 ``[]``。 # 旧行 / NULL 读时归一化为 ``[]``。
login_params_json: Mapped[list] = mapped_column(PortableJSON(), default=list) login_params_json: Mapped[list] = mapped_column(PortableJSON(), default=list)
# ``url`` 跳转是否追加登录态(token 登录的上游 token + 登录用户名),供外链所在的外部系统
# 校验当前登录用户。仅 token 登录(前端存有 token1)时实际拼上;非 token 登录自动跳过。
# ``server_default`` 必须显式声明:否则在已存在 ``task_buttons`` 表的旧库上,
# 启动期自动补列 ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」
# 的列(见 engine.py),导致该列永远补不上 → GET 查询报 Unknown column → 500。
append_auth: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
# 追加 token / 用户名的查询参数名。可空,读时归一化为 ``token`` / ``username``。
auth_token_param: Mapped[str | None] = mapped_column(String(64), default="token")
auth_name_param: Mapped[str | None] = mapped_column(String(64), default="username")
enabled: Mapped[bool] = mapped_column(Boolean, default=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0) sort_order: Mapped[int] = mapped_column(Integer, default=0)
updated_by: Mapped[str | None] = mapped_column(String(64)) updated_by: Mapped[str | None] = mapped_column(String(64))
@@ -23,6 +23,9 @@ def _to_dict(row: TaskButtonRow) -> dict[str, Any]:
data["open_mode"] = data.get("open_mode") or "blank" data["open_mode"] = data.get("open_mode") or "blank"
# JSON 列改名 login_params_json → login_params;旧行 / NULL 归一化为 []。 # JSON 列改名 login_params_json → login_params;旧行 / NULL 归一化为 []。
data["login_params"] = data.pop("login_params_json", None) or [] data["login_params"] = data.pop("login_params_json", None) or []
data["append_auth"] = bool(data.get("append_auth", False))
data["auth_token_param"] = data.get("auth_token_param") or "token"
data["auth_name_param"] = data.get("auth_name_param") or "username"
return data return data
@@ -38,6 +41,9 @@ def _row_from(data: dict[str, Any], updated_by: str | None) -> TaskButtonRow:
id_kind=str(data.get("id_kind") or "task"), id_kind=str(data.get("id_kind") or "task"),
open_mode=str(data.get("open_mode") or "blank"), open_mode=str(data.get("open_mode") or "blank"),
login_params_json=list(data.get("login_params") or []), login_params_json=list(data.get("login_params") or []),
append_auth=bool(data.get("append_auth", False)),
auth_token_param=str(data.get("auth_token_param") or "token"),
auth_name_param=str(data.get("auth_name_param") or "username"),
enabled=bool(data.get("enabled", True)), enabled=bool(data.get("enabled", True)),
sort_order=int(data.get("sort_order", 0)), sort_order=int(data.get("sort_order", 0)),
updated_by=updated_by, updated_by=updated_by,
@@ -49,6 +49,13 @@ class UserRow(Base):
needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
token_version: Mapped[int] = mapped_column(nullable=False, default=0) token_version: Mapped[int] = mapped_column(nullable=False, default=0)
# 登录白名单(放行)标记。仅当系统设置 ``login_whitelist.enabled`` 打开时生效:
# 关 → 忽略此列,人人可登录;开 → 仅 approved=True(或 admin)可登录,其余
# 注册后处于「待审批」直到管理员放行。新注册的普通用户默认 False(待审批),
# admin 创建即 True;存量用户由迁移回填 True(避免首次打开开关时把老用户锁死)。
# server_default=1 让迁移 ADD COLUMN 时把已存在行置为已放行。
approved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("1"))
# 岗位 (position) the user is assigned to (1:1). NULL = no explicit # 岗位 (position) the user is assigned to (1:1). NULL = no explicit
# position → the user inherits the ``is_default`` position's grants. Drives # position → the user inherits the ``is_default`` position's grants. Drives
# the subscription sync engine (see ``deerflow.services.position_sync``). # the subscription sync engine (see ``deerflow.services.position_sync``).
@@ -42,6 +42,12 @@ logger = logging.getLogger(__name__)
_owner_cache: dict[str, str] = {} _owner_cache: dict[str, str] = {}
_owner_cache_lock = threading.Lock() _owner_cache_lock = threading.Lock()
_OWNER_CACHE_MAX = 4096 _OWNER_CACHE_MAX = 4096
_LOCAL_ONLY_THREAD_PREFIXES = ("open-", "__skill-review__")
def _is_local_only_thread(thread_id: str | None) -> bool:
"""Return True for synthetic threads that are never persisted in threads_meta."""
return bool(thread_id) and str(thread_id).startswith(_LOCAL_ONLY_THREAD_PREFIXES)
def _cache_get(thread_id: str) -> str | None: def _cache_get(thread_id: str) -> str | None:
@@ -112,7 +118,7 @@ def resolve_path_user_id(thread_id: str | None) -> str:
share the conversation read/write the same physical directory. For every share the conversation read/write the same physical directory. For every
other case it is the current login (``get_effective_user_id``). other case it is the current login (``get_effective_user_id``).
""" """
if not thread_id: if not thread_id or _is_local_only_thread(thread_id):
return get_effective_user_id() return get_effective_user_id()
cached = _cache_get(thread_id) cached = _cache_get(thread_id)
if cached is not None: if cached is not None:
@@ -132,7 +138,7 @@ def resolve_path_user_id(thread_id: str | None) -> str:
async def aresolve_path_user_id(thread_id: str | None) -> str: async def aresolve_path_user_id(thread_id: str | None) -> str:
"""Async variant of :func:`resolve_path_user_id` for FastAPI handlers.""" """Async variant of :func:`resolve_path_user_id` for FastAPI handlers."""
if not thread_id: if not thread_id or _is_local_only_thread(thread_id):
return get_effective_user_id() return get_effective_user_id()
cached = _cache_get(thread_id) cached = _cache_get(thread_id)
if cached is not None: if cached is not None:
@@ -123,6 +123,13 @@ CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEF
-- ORM: TaskButtonRow.login_params_json = PortableJSON()(MySQL 落 TEXT)。 -- ORM: TaskButtonRow.login_params_json = PortableJSON()(MySQL 落 TEXT)。
CALL _add_column_if_missing('task_buttons', 'login_params_json', 'TEXT NULL DEFAULT NULL'); CALL _add_column_if_missing('task_buttons', 'login_params_json', 'TEXT NULL DEFAULT NULL');
-- ---- users.approved: 登录白名单放行标记,对应 migration 20260625_03 -----------
-- 系统设置 login_whitelist.enabled 打开后,仅 approved=1(或 admin)可登录;普通用户
-- 自助注册后默认 0(待审批),管理员在白名单管理页放行。DEFAULT 1 让存量用户(执行本
-- 脚本补列时)一并置为已放行,避免首次打开开关把老用户锁死。
-- ORM: UserRow.approved = Boolean, NOT NULL, server_default '1'。
CALL _add_column_if_missing('users', 'approved', 'TINYINT(1) NOT NULL DEFAULT 1');
-- ---- 清理临时存储过程 -------------------------------------------------------- -- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _add_column_if_missing; DROP PROCEDURE IF EXISTS _add_column_if_missing;
@@ -168,6 +175,15 @@ PREPARE stmt FROM @idx_ddl;
EXECUTE stmt; EXECUTE stmt;
DEALLOCATE PREPARE stmt; DEALLOCATE PREPARE stmt;
-- ---- task_buttons ------------------------------------------------------------
-- 任务工作区跳转按钮的「追加登录态」选项,对应 migration 20260625_02。url 按钮可在外链
-- 后追加 token(token 登录的上游 token)+ username(登录用户名),供外部系统识别当前用户。
-- ORM: TaskButtonRow.append_auth = Boolean NOT NULL default False;
-- auth_token_param / auth_name_param = String(64), nullable, 读时归一化为 token/username。
CALL _add_column_if_missing('task_buttons', 'append_auth', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL _add_column_if_missing('task_buttons', 'auth_token_param', "VARCHAR(64) NULL DEFAULT 'token'");
CALL _add_column_if_missing('task_buttons', 'auth_name_param', "VARCHAR(64) NULL DEFAULT 'username'");
-- ---- 执行后自检:确认本批次涉及的列都已存在 --------------------------------- -- ---- 执行后自检:确认本批次涉及的列都已存在 ---------------------------------
SELECT table_name, column_name SELECT table_name, column_name
FROM information_schema.columns FROM information_schema.columns
@@ -175,5 +191,6 @@ SELECT table_name, column_name
AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail', 'square_id', 'always_on')) AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail', 'square_id', 'always_on'))
OR (table_name = 'agents' AND column_name IN ('square_id')) OR (table_name = 'agents' AND column_name IN ('square_id'))
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id') OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')) OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param')))
ORDER BY table_name, column_name; ORDER BY table_name, column_name;
+6
View File
@@ -212,6 +212,12 @@ auth_login:
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。 # 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
verify_ssl: false verify_ssl: false
ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验 ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
# 离线测试映射:token -> 用户名。命中的 token 跳过 token_info_url 调用,直接当成
# 该用户登录——外网调不通 getTokenInfo 时用来本地全链路联调。生产留空即可(不生效)。
# 例:用 /login/任意?authToken=123ewq 进入,即以 admin 身份登录;token1=123ewq、
# username=admin 会被存下,供任务工作区跳转按钮的「追加登录态」拼到外链上。
mock_tokens:
"123ewq": "admin"
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口, # 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。 # 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。