Merge branch 'ji' into master
按钮管理「登录参数」两套实现冲突解决——两分支各自独立给跳转按钮加了登录态追加能力, 二者正交、保留两套,互不影响: - master: login_params (JSON 列 login_params_json,从 userInfo 解析,类似轻应用登录参数) - ji: append_auth + auth_token_param + auth_name_param (token 登录时追加 token1+用户名) 迁移 revision 碰撞修复(两分支都用了 20260625_01):重编号为线性链 20260620_02 → 20260625_01 (login_params) → 20260625_02 (auth_params) → 20260625_03 (users_approved) 并同步 column_additions_mysql.sql 注释里的 migration 编号。 前后端 task_buttons(model/sql/memory/router/前端 type·api·admin 页)均保留两套字段; 两份 CLAUDE.md 文档合并为同时记录两种机制。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
58357547c9
44 files changed
+1206
-45
No files matched your search
@@ -82,9 +82,10 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<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.
|
||||
- **技能地址管理 (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`
|
||||
- **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/`
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
|
||||
"build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit",
|
||||
"build:test": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode test",
|
||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MousePointerClick,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
@@ -222,6 +223,14 @@ function SecondaryNavSidebar() {
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/login-whitelist")}
|
||||
>
|
||||
<ShieldCheck className="size-4" />
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
|
||||
@@ -508,6 +517,12 @@ function LegacyAccordionSidebar({
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||
<ShieldCheck className="size-4" />
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
|
||||
<ShieldAlert className="size-4" />
|
||||
|
||||
@@ -138,6 +138,7 @@ export function MessageListItem({
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
className?: string;
|
||||
fitRowToContent?: boolean;
|
||||
@@ -154,6 +155,13 @@ export function MessageListItem({
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
/**
|
||||
* Durable ask time of the turn this message belongs to (the preceding
|
||||
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
|
||||
* timestamp of its own — without it the answer would fall back to the current
|
||||
* wall clock at render time and appear to "drift" to now on every reload.
|
||||
*/
|
||||
turnTime?: string;
|
||||
}) {
|
||||
const isHuman = message.type === 'human';
|
||||
const messageMarkdown =
|
||||
@@ -182,6 +190,7 @@ export function MessageListItem({
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onMessageDeleted={onMessageDeleted}
|
||||
turnTime={turnTime}
|
||||
/>
|
||||
{/*
|
||||
Human messages carry their own inline copy/edit toolbar inside
|
||||
@@ -390,6 +399,7 @@ function MessageContent_({
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
@@ -400,6 +410,7 @@ function MessageContent_({
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
turnTime?: string;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
|
||||
@@ -456,9 +467,15 @@ function MessageContent_({
|
||||
[threadId, resolveArtifact],
|
||||
);
|
||||
|
||||
// Question carries its own `client_ts` (round-trips through the checkpoint).
|
||||
// The AI answer has no timestamp of its own, so it inherits the turn's ask
|
||||
// time (its question's `client_ts`, threaded down as `turnTime`). This is
|
||||
// durable and turn-accurate, and — unlike the old per-id `now` fallback —
|
||||
// never drifts to the current clock when an unfinished answer is reloaded.
|
||||
const ownAskTime = message.id ? askTimes[message.id] : undefined;
|
||||
const messageTime = formatAskedTime(
|
||||
message.additional_kwargs?.client_ts ??
|
||||
(message.id ? askTimes[message.id] : undefined),
|
||||
(isHuman ? ownAskTime : (turnTime ?? ownAskTime)),
|
||||
);
|
||||
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { rememberAskTimes } from "@/core/chat-timestamps";
|
||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@@ -167,15 +167,24 @@ export function MessageList({
|
||||
[allMessages, hiddenMessageIds],
|
||||
);
|
||||
|
||||
// Stamp a wall-clock time onto every question/answer the first time it is
|
||||
// seen, in a single batched commit. Questions sent this session already carry
|
||||
// an accurate time (see useThreadStream); this also covers assistant answers
|
||||
// and messages loaded from history. First-write-wins keeps the earliest time.
|
||||
const askTimes = useChatAskTimes();
|
||||
|
||||
// Stamp a wall-clock time onto every *question* the first time it is seen, in
|
||||
// a single batched commit. Questions sent this session already carry an
|
||||
// accurate `client_ts` (see useThreadStream) and are skipped; this only backs
|
||||
// up legacy questions loaded from history that predate `client_ts`.
|
||||
// First-write-wins keeps the earliest time.
|
||||
//
|
||||
// AI answers are deliberately NOT stamped here: they carry no timestamp of
|
||||
// their own, so a `now` stamp would be the *load* time, not the answer time —
|
||||
// and for an unfinished/interrupted turn that re-stamps to the current clock
|
||||
// on every reload. Instead the answer inherits its question's durable time
|
||||
// via `turnTimes` below.
|
||||
useEffect(() => {
|
||||
const now = new Date().toISOString();
|
||||
const entries: Record<string, string> = {};
|
||||
for (const message of messages) {
|
||||
if (message.type !== "human" && message.type !== "ai") continue;
|
||||
if (message.type !== "human") continue;
|
||||
if (!message.id || message.id.startsWith("opt-")) continue;
|
||||
if (typeof message.additional_kwargs?.client_ts === "string") continue;
|
||||
entries[message.id] = now;
|
||||
@@ -183,6 +192,24 @@ export function MessageList({
|
||||
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
|
||||
}, [messages]);
|
||||
|
||||
// Durable ask time per message id, used to time-stamp the AI answer (which has
|
||||
// no timestamp of its own) with the time of the question that triggered it.
|
||||
const turnTimes = useMemo(() => {
|
||||
const map = new Map<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 ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
@@ -358,6 +385,7 @@ export function MessageList({
|
||||
onMessageDeleted={
|
||||
allowDelete ? handleMessageDeleted : undefined
|
||||
}
|
||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,44 @@ export function formatDuration(ms: number): string {
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 速度条颜色含义图例 —— 生成结束后常驻在速度条下方一行,向用户解释进度条
|
||||
* 颜色代表的「模型生成延迟」状态。进度条本身仍是 5 档(极快/流畅/一般/偏慢/
|
||||
* 缓慢)的连续配色;这里把它归并成用户视角的 3 类延迟 + 「无响应」来说明:
|
||||
* 绿(#16a34a,极快/流畅) → 延迟正常
|
||||
* 黄(#eab308,一般) → 延迟微长
|
||||
* 红(#ef4444,偏慢/缓慢) → 延迟长,可能失败
|
||||
* 灰(#9ca3af) → 无响应(无进度条/0 tok/s),任务失败,请重试
|
||||
*/
|
||||
const SPEED_LEGEND: { color: string; label: string }[] = [
|
||||
{ color: "#16a34a", label: "延迟正常" },
|
||||
{ color: "#eab308", label: "延迟微长" },
|
||||
{ color: "#ef4444", label: "延迟长,可能失败" },
|
||||
{ color: "#9ca3af", label: "无响应,任务失败,请重试" },
|
||||
];
|
||||
|
||||
export function SpeedLegend({ className }: { className?: string }) {
|
||||
return (
|
||||
<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 }) {
|
||||
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
||||
const { color, label } = classifySpeed(metric.tokensPerSec);
|
||||
@@ -132,6 +170,8 @@ export function MessageTokenUsage({
|
||||
{formatDuration(resolvedMetric.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
{/* 生成结束(非流式中)后常驻一行颜色含义说明。 */}
|
||||
{!resolvedMetric.isActive && <SpeedLegend className="basis-full" />}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface UserListItem {
|
||||
agent_count: number;
|
||||
/** 问答次数:用户提问的次数(一次提问算一次)。 */
|
||||
qa_count: number;
|
||||
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
|
||||
approved: boolean;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
@@ -19,6 +21,20 @@ export async function listAllUsers(): 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
|
||||
// a plain string, an object like AuthErrorResponse ({ code, message }), or an
|
||||
// array of validation errors — anything but a string would otherwise render as
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { listAllUsers } from "./api";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
import { listAllUsers, setUserApproval } from "./api";
|
||||
|
||||
export function useAllUsers(enabled: boolean) {
|
||||
return useQuery({
|
||||
@@ -9,3 +11,20 @@ export function useAllUsers(enabled: boolean) {
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** 放行 / 取消放行某个用户(登录白名单),成功后刷新用户列表缓存。 */
|
||||
export function useSetUserApproval() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, approved }: { userId: string; approved: boolean }) => setUserApproval(userId, approved),
|
||||
onSuccess: (_data, vars) => {
|
||||
writeYLog({
|
||||
optType: vars.approved ? OptType.enabled : OptType.disabled,
|
||||
optDetail: `CM助手-全量-白名单管理-${vars.approved ? "放行" : "取消放行"}用户`,
|
||||
url: `/api/v1/auth/users/${vars.userId}/approval`,
|
||||
reqParam: vars,
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,12 @@ export interface LoginByUsernameResponse {
|
||||
system_role: string;
|
||||
needs_setup: boolean;
|
||||
created: boolean;
|
||||
/**
|
||||
* 本次登录解析出的原始登录用户名(token 登录=getTokenInfo 解析值;用户名登录=所传用户名)。
|
||||
* 用于跳转外链时拼 ``?username=``——后端落库的邮箱前缀是归一化(小写/替换)后的,有损,故以此为准。
|
||||
* 旧后端可能不返回。
|
||||
*/
|
||||
username?: string;
|
||||
}
|
||||
|
||||
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,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
import { readUserInfo, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
|
||||
import {
|
||||
readLoginToken,
|
||||
readLoginUsername,
|
||||
} from "@/core/auth/login-credentials";
|
||||
|
||||
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
|
||||
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
|
||||
@@ -102,6 +106,16 @@ export interface TaskButton {
|
||||
openMode: TaskButtonOpenMode;
|
||||
/** ``url`` 外链追加的登录参数列表(仅 ``url`` 类型有意义;为空则不追加,行为同旧版)。 */
|
||||
loginParams: TaskButtonLoginParam[];
|
||||
/**
|
||||
* ``url`` 跳转是否追加登录态:token1(token 登录的上游 token)+ 登录用户名,供外链所在的
|
||||
* 外部系统识别当前登录用户。**仅 token 登录**(前端存有 token1,见 [[login-credentials]])时
|
||||
* 实际拼上;非 token 登录读不到 token1,自动跳过(按钮照常跳,只是不带这两个参数)。
|
||||
*/
|
||||
appendAuth: boolean;
|
||||
/** 追加 token 的查询参数名(空则用 ``token``)。 */
|
||||
authTokenParam: string;
|
||||
/** 追加用户名的查询参数名(空则用 ``username``)。 */
|
||||
authNameParam: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
@@ -154,6 +168,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder,
|
||||
});
|
||||
@@ -169,6 +186,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder,
|
||||
});
|
||||
@@ -185,6 +205,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
@@ -204,6 +227,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "self",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
@@ -218,6 +244,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder: 2,
|
||||
},
|
||||
@@ -232,6 +261,9 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder: 3,
|
||||
},
|
||||
@@ -309,6 +341,18 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
|
||||
url = appendQueryParam(url, key, resolveLoginParamValue(lp, userInfo));
|
||||
}
|
||||
}
|
||||
// 追加登录态:仅 token 登录(存在 token1)才拼 token + 用户名,供外链所在的外部系统识别
|
||||
// 当前登录用户。非 token 登录读不到 token1 → 整段跳过(不影响跳转本身)。
|
||||
if (btn.appendAuth) {
|
||||
const token1 = readLoginToken();
|
||||
if (token1) {
|
||||
url = appendQueryParam(url, (btn.authTokenParam || "token").trim() || "token", token1);
|
||||
const name = readLoginUsername();
|
||||
if (name) {
|
||||
url = appendQueryParam(url, (btn.authNameParam || "username").trim() || "username", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 应用内部路由(以单个 / 开头,如「上一步」→ 大屏绘制页):
|
||||
// self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。
|
||||
if (base.startsWith("/") && !base.startsWith("//")) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '@/core/page-layout/sidebar-enabled';
|
||||
import { fetchYLogConfig } from '@/core/log/y-log-config';
|
||||
import { persistYLogToken } from '@/core/log/y-log-token';
|
||||
import { persistLoginCredentials } from '@/core/auth/login-credentials';
|
||||
import { OptType, writeYLog } from '@/core/log/y-log';
|
||||
|
||||
const DEFAULT_USERNAME = 'guest';
|
||||
@@ -197,6 +198,9 @@ export default function LoginPage() {
|
||||
// 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。
|
||||
if (authToken) {
|
||||
persistYLogToken(authToken);
|
||||
// 存下 token1 + 登录用户名(getTokenInfo 解析出的原始名,后端回传),供任务工作区
|
||||
// 跳转按钮的「追加登录态」拼到外链上(仅 token 登录才有这两个值)。
|
||||
persistLoginCredentials(authToken, auth);
|
||||
void fetchYLogConfig();
|
||||
writeYLog({
|
||||
optType: OptType.login,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import LightAppIframeView from "./LightAppIframeView";
|
||||
import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import MenuManagementPage from "./MenuManagementPage";
|
||||
import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
@@ -395,6 +396,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/login-whitelist"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<LoginWhitelistPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/roundtable-diagnostics"
|
||||
element={
|
||||
|
||||
@@ -108,6 +108,9 @@ export default function TaskButtonsPage() {
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
appendAuth: false,
|
||||
authTokenParam: "token",
|
||||
authNameParam: "username",
|
||||
enabled: true,
|
||||
sortOrder: maxSort + 1,
|
||||
},
|
||||
@@ -337,6 +340,37 @@ export default function TaskButtonsPage() {
|
||||
<SelectItem value="self">当前窗口</SelectItem>
|
||||
</SelectContent>
|
||||
</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">&</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 &</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 })}>
|
||||
|
||||
@@ -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;
|
||||
open_mode: string;
|
||||
login_params: ServerLoginParam[];
|
||||
append_auth: boolean;
|
||||
auth_token_param: string;
|
||||
auth_name_param: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
@@ -63,6 +66,9 @@ function toButton(s: ServerButton): TaskButton {
|
||||
idKind: (s.id_kind as TaskButtonIdKind) ?? "task",
|
||||
openMode: (s.open_mode as TaskButtonOpenMode) ?? "blank",
|
||||
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,
|
||||
sortOrder: s.sort_order ?? 0,
|
||||
};
|
||||
@@ -84,6 +90,9 @@ function toBody(b: TaskButton): ServerButton {
|
||||
source: p.source,
|
||||
custom_value: p.customValue,
|
||||
})),
|
||||
append_auth: b.appendAuth,
|
||||
auth_token_param: b.authTokenParam,
|
||||
auth_name_param: b.authNameParam,
|
||||
enabled: b.enabled,
|
||||
sort_order: b.sortOrder,
|
||||
};
|
||||
|
||||
@@ -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 |
|
||||
| **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 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. |
|
||||
| **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`. |
|
||||
@@ -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`. |
|
||||
| **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`。 |
|
||||
| **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). |
|
||||
|
||||
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)
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from app.gateway.routers import (
|
||||
models,
|
||||
multi_agent,
|
||||
notifications,
|
||||
open_3qfx,
|
||||
open_chat,
|
||||
parallel_agent_tasks,
|
||||
parallel_agents,
|
||||
@@ -1478,6 +1479,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
|
||||
app.include_router(open_chat.router)
|
||||
|
||||
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
|
||||
app.include_router(open_3qfx.router)
|
||||
|
||||
# --- ?????????? /page/workspace/roundtable/planning?---
|
||||
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
|
||||
# ??? 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)
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -87,6 +87,8 @@ class LocalAuthProvider(AuthProvider):
|
||||
password: Plain text password (will be hashed)
|
||||
system_role: Role to assign ("admin" or "user")
|
||||
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:
|
||||
Created User instance
|
||||
@@ -97,6 +99,7 @@ class LocalAuthProvider(AuthProvider):
|
||||
password_hash=password_hash,
|
||||
system_role=system_role,
|
||||
needs_setup=needs_setup,
|
||||
approved=approved,
|
||||
)
|
||||
return await self._repo.create_user(user)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class User(BaseModel):
|
||||
# Auth lifecycle
|
||||
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")
|
||||
approved: bool = Field(default=False, description="登录白名单放行标记;仅当系统设置 login_whitelist.enabled 打开时拦截未放行用户。新普通用户默认待审批(False)。")
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
|
||||
@@ -46,6 +46,7 @@ class SQLiteUserRepository(UserRepository):
|
||||
oauth_id=row.oauth_id,
|
||||
needs_setup=row.needs_setup,
|
||||
token_version=row.token_version,
|
||||
approved=row.approved,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -60,6 +61,7 @@ class SQLiteUserRepository(UserRepository):
|
||||
oauth_id=user.oauth_id,
|
||||
needs_setup=user.needs_setup,
|
||||
token_version=user.token_version,
|
||||
approved=user.approved,
|
||||
)
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────
|
||||
@@ -154,6 +156,7 @@ class SQLiteUserRepository(UserRepository):
|
||||
row.oauth_id = user.oauth_id
|
||||
row.needs_setup = user.needs_setup
|
||||
row.token_version = user.token_version
|
||||
row.approved = user.approved
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ owner filtering works automatically via the sentinel pattern.
|
||||
Fine-grained permission checks remain in authz.py decorators.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# 「发起问答」的 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):
|
||||
"""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).
|
||||
request.state.user = user
|
||||
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)
|
||||
try:
|
||||
return await call_next(request)
|
||||
|
||||
@@ -12,6 +12,7 @@ from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
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.skills.base import SkillStore
|
||||
|
||||
@@ -145,6 +146,15 @@ class UsernameLoginResponse(BaseModel):
|
||||
system_role: Literal["admin", "user"] = "user"
|
||||
needs_setup: bool = False
|
||||
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):
|
||||
@@ -246,7 +256,9 @@ async def _get_or_create_user_by_username(username: str):
|
||||
if user is None:
|
||||
try:
|
||||
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
|
||||
logger.info("Auto-registered passwordless user: %s", email)
|
||||
except ValueError:
|
||||
@@ -267,6 +279,35 @@ async def _get_or_create_user_by_username(username: str):
|
||||
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:
|
||||
"""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.
|
||||
"""
|
||||
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:
|
||||
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)
|
||||
enforce_user_approved(user)
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
_set_session_cookie(response, token, request)
|
||||
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)
|
||||
|
||||
enforce_user_approved(user)
|
||||
token = create_access_token(str(user.id), token_version=user.token_version)
|
||||
_set_session_cookie(response, token, request)
|
||||
await _sync_user_position(request, user.id)
|
||||
@@ -547,6 +599,7 @@ async def login_by_username(
|
||||
system_role=user.system_role,
|
||||
needs_setup=user.needs_setup,
|
||||
created=created,
|
||||
username=body.username,
|
||||
)
|
||||
|
||||
|
||||
@@ -585,6 +638,7 @@ async def login_by_token(
|
||||
|
||||
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)
|
||||
_set_session_cookie(response, access_token, request)
|
||||
await _sync_user_position(request, user.id)
|
||||
@@ -598,6 +652,9 @@ async def login_by_token(
|
||||
system_role=user.system_role,
|
||||
needs_setup=user.needs_setup,
|
||||
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:
|
||||
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:
|
||||
# DB unique-constraint race: another concurrent request beat us.
|
||||
raise HTTPException(
|
||||
@@ -769,6 +826,7 @@ class UserListItem(BaseModel):
|
||||
skill_count: int
|
||||
agent_count: int
|
||||
qa_count: int = 0 # 问答次数: number of runs (one run = one question)
|
||||
approved: bool = True # 登录白名单放行标记(白名单管理页据此放行/取消)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserListItem])
|
||||
@@ -801,11 +859,44 @@ async def list_all_users(
|
||||
skill_count=await skill_store.count_owned(uid),
|
||||
agent_count=await agent_store.count_owned(uid),
|
||||
qa_count=qa_counts.get(uid, 0),
|
||||
approved=getattr(u, "approved", True),
|
||||
)
|
||||
)
|
||||
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) ─────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
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)
|
||||
async def get_agent_module_config(key: str) -> AgentModuleConfigResponse:
|
||||
"""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)
|
||||
# url 外链追加的登录参数列表(key + 取值来源)。
|
||||
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
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
@@ -529,6 +529,8 @@ async def start_run(
|
||||
body: Any,
|
||||
thread_id: str,
|
||||
request: Request,
|
||||
*,
|
||||
enforce_access: bool = True,
|
||||
) -> RunRecord:
|
||||
"""Create a RunRecord and launch the background agent task.
|
||||
|
||||
@@ -560,6 +562,12 @@ async def start_run(
|
||||
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
|
||||
|
||||
t_pre_enforce = time.perf_counter()
|
||||
# ``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()
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ auth_login:
|
||||
# (把该 token 直接拼成 Authorization: Bearer <token> 头)换出用户名,再登录;
|
||||
# 用户名不在库里时自动注册。
|
||||
token_login:
|
||||
enabled: false
|
||||
enabled: true
|
||||
token_info_url: 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo'
|
||||
# 已弃用:鉴权头现由前端传入的 token 拼成 "Bearer <token>",此项不再生效,保留仅兼容。
|
||||
service_authorization: 'Bearer admin'
|
||||
@@ -331,6 +331,11 @@ auth_login:
|
||||
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
|
||||
verify_ssl: false
|
||||
ca_cert_path: '' # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
|
||||
# 离线测试映射:token -> 用户名。命中的 token 跳过 token_info_url 调用,直接当成
|
||||
# 该用户登录——外网调不通 getTokenInfo 时用来本地全链路联调。生产留空即可(不生效)。
|
||||
# 例:用 /login/任意?authToken=123ewq 进入,即以 admin 身份登录。
|
||||
mock_tokens:
|
||||
'123ewq': 'admin'
|
||||
|
||||
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
|
||||
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
||||
@@ -455,6 +460,9 @@ task_deeplink:
|
||||
test: true # 目的树查询走 mock.purpose_detail 假数据
|
||||
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
|
||||
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"
|
||||
# rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。
|
||||
delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData"
|
||||
@@ -506,3 +514,11 @@ task_deeplink:
|
||||
actionName: ""
|
||||
actionStatus: 25
|
||||
narration: ""
|
||||
# cop_task_detail_test=true 时原样返回给开放接口 /api/open/3qfx/ask(结构同真实 cop-task-detail 返回)。
|
||||
cop_task_detail:
|
||||
data:
|
||||
id: 2055
|
||||
overview: "暑期文旅消费高峰期间的舆情态势综合研判与引导任务"
|
||||
taskDirection: "正向引导 + 风险预警"
|
||||
taskName: "任务01:暑期文旅舆情综合研判"
|
||||
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
|
||||
+10
-3
@@ -11,6 +11,7 @@ from langgraph.runtime import Runtime
|
||||
from deerflow.agents.thread_state import ThreadDataState
|
||||
from deerflow.config.paths import Paths, get_paths
|
||||
from deerflow.runtime.thread_paths import resolve_path_user_id
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,10 +82,15 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
||||
@override
|
||||
def before_agent(self, state: ThreadDataMiddlewareState, runtime: Runtime) -> dict | None:
|
||||
context = runtime.context or {}
|
||||
configurable = {}
|
||||
try:
|
||||
config = get_config()
|
||||
configurable = config.get("configurable", {}) or {}
|
||||
except RuntimeError:
|
||||
pass
|
||||
thread_id = context.get("thread_id")
|
||||
if thread_id is None:
|
||||
config = get_config()
|
||||
thread_id = config.get("configurable", {}).get("thread_id")
|
||||
thread_id = configurable.get("thread_id")
|
||||
|
||||
if thread_id is None:
|
||||
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
|
||||
# the same physical user-data directory. Personal threads resolve to the
|
||||
# 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:
|
||||
# Lazy initialization: only compute paths, don't create directories
|
||||
|
||||
+31
-23
@@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any, NotRequired, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langgraph.config import get_config
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.config.title_config import get_title_config
|
||||
@@ -139,13 +138,29 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
return text.strip()
|
||||
|
||||
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()
|
||||
title_content = self._normalize_content(content)
|
||||
title_content = self._strip_think_tags(title_content)
|
||||
title_content = self._strip_think_tags(self._normalize_content(content))
|
||||
title = title_content.strip().strip('"').strip("'")
|
||||
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:
|
||||
config = self._get_title_config()
|
||||
fallback_chars = min(config.max_chars, 50)
|
||||
@@ -153,21 +168,6 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
return user_msg[:fallback_chars].rstrip() + "..."
|
||||
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:
|
||||
"""Set an instant placeholder title from the first user message (no LLM)."""
|
||||
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)
|
||||
|
||||
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):
|
||||
return None
|
||||
|
||||
@@ -229,7 +237,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
prompt, user_msg = self._build_title_prompt(state)
|
||||
|
||||
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:
|
||||
model_kwargs["app_config"] = self._app_config
|
||||
if config.model_name:
|
||||
@@ -237,12 +245,12 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
else:
|
||||
model = create_chat_model(**model_kwargs)
|
||||
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)
|
||||
if title:
|
||||
return {"title": title, "title_provisional": False}
|
||||
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}
|
||||
|
||||
@override
|
||||
|
||||
+7
@@ -285,6 +285,13 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
if not isinstance(last_message, HumanMessage):
|
||||
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
|
||||
thread_id = (runtime.context or {}).get("thread_id")
|
||||
if thread_id is None:
|
||||
|
||||
@@ -74,6 +74,16 @@ class TokenLoginConfig(BaseModel):
|
||||
"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):
|
||||
|
||||
@@ -221,10 +221,23 @@ def set_module_tag_ids(
|
||||
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):
|
||||
"""Top-level system settings document."""
|
||||
|
||||
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
||||
login_whitelist: LoginWhitelistSettings = Field(default_factory=LoginWhitelistSettings)
|
||||
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
||||
publish_squares: PublishSquaresSettings = Field(default_factory=PublishSquaresSettings)
|
||||
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
||||
|
||||
+12
@@ -41,6 +41,18 @@ class TaskDeeplinkConfig(BaseModel):
|
||||
default=False,
|
||||
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(
|
||||
default="",
|
||||
description="目的树查询完整 URL,调用时追加 ?taskId=",
|
||||
|
||||
+61
@@ -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)
|
||||
+52
@@ -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")
|
||||
+3
@@ -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"),
|
||||
"open_mode": str(data.get("open_mode") or "blank"),
|
||||
"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)),
|
||||
"sort_order": int(data.get("sort_order", 0)),
|
||||
"updated_by": updated_by,
|
||||
|
||||
+9
@@ -53,6 +53,15 @@ class TaskButtonRow(Base):
|
||||
# (``other`` 时取 ``custom_value`` 字面值)。每项 ``{key, source, custom_value}``。
|
||||
# 旧行 / NULL 读时归一化为 ``[]``。
|
||||
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)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
+6
@@ -23,6 +23,9 @@ def _to_dict(row: TaskButtonRow) -> dict[str, Any]:
|
||||
data["open_mode"] = data.get("open_mode") or "blank"
|
||||
# JSON 列改名 login_params_json → login_params;旧行 / NULL 归一化为 []。
|
||||
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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
open_mode=str(data.get("open_mode") or "blank"),
|
||||
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)),
|
||||
sort_order=int(data.get("sort_order", 0)),
|
||||
updated_by=updated_by,
|
||||
|
||||
@@ -49,6 +49,13 @@ class UserRow(Base):
|
||||
needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
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 inherits the ``is_default`` position's grants. Drives
|
||||
# the subscription sync engine (see ``deerflow.services.position_sync``).
|
||||
|
||||
@@ -42,6 +42,12 @@ logger = logging.getLogger(__name__)
|
||||
_owner_cache: dict[str, str] = {}
|
||||
_owner_cache_lock = threading.Lock()
|
||||
_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:
|
||||
@@ -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
|
||||
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()
|
||||
cached = _cache_get(thread_id)
|
||||
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 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()
|
||||
cached = _cache_get(thread_id)
|
||||
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)。
|
||||
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;
|
||||
|
||||
@@ -168,6 +175,15 @@ PREPARE stmt FROM @idx_ddl;
|
||||
EXECUTE 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
|
||||
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'))
|
||||
OR (table_name = 'agents' AND column_name IN ('square_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;
|
||||
@@ -212,6 +212,12 @@ auth_login:
|
||||
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
|
||||
verify_ssl: false
|
||||
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 关掉则不显示该入口。
|
||||
|
||||
|
||||
Reference in new issue
Block a user