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

No files matched your search

+2 -1
View File
@@ -82,9 +82,10 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API.
- **轻应用中心 (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/`
+1
View File
@@ -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>
+16
View File
@@ -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
+21 -2
View File
@@ -1,6 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { listAllUsers } from "./api";
import { OptType, writeYLog } from "@/core/log/y-log";
import { listAllUsers, setUserApproval } from "./api";
export function useAllUsers(enabled: boolean) {
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"] });
},
});
}
+6
View File
@@ -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("//")) {
+4
View File
@@ -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>
);
}
+11
View File
@@ -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">&amp;</span>
<Input
value={r.authTokenParam}
onChange={(e) => update(r.id, { authTokenParam: e.target.value })}
placeholder="token"
className="h-8 w-24"
title="token 的查询参数名(默认 token)"
/>
<span className="text-muted-foreground">=token1 &amp;</span>
<Input
value={r.authNameParam}
onChange={(e) => update(r.id, { authNameParam: e.target.value })}
placeholder="username"
className="h-8 w-28"
title="用户名的查询参数名(默认 username)"
/>
<span className="text-muted-foreground">=用户名</span>
</div>
)}
</>
) : (
<Select value={r.target} onValueChange={(v) => update(r.id, { target: v })}>
@@ -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,
};
+3 -2
View File
@@ -244,6 +244,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
| **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,7 +562,13 @@ async def start_run(
disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_
t_pre_enforce = time.perf_counter()
await _enforce_agent_access(body, request)
# ``enforce_access=False`` is for intentionally open/unauthenticated entry
# points (e.g. /api/open/3qfx/ask) that resolve the agent themselves from an
# admin-configured mapping rather than from a per-user-visible assistant_id.
# Those run under the "default" bucket, where a user-owned unpublished agent
# would otherwise 404 the access check.
if enforce_access:
await _enforce_agent_access(body, request)
t_post_enforce = time.perf_counter()
try:
+17 -1
View File
@@ -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: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
@@ -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
@@ -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
@@ -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)
@@ -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=",
@@ -0,0 +1,61 @@
"""Add append_auth / auth_token_param / auth_name_param to task_buttons.
These back the「追加登录态」option on task-workspace jump buttons (按钮管理):
a ``url`` button can append the login token (``token``) + username (``username``)
to its external link so the target system can identify the current user.
Revision ID: 20260625_02
Revises: 20260625_01
Create Date: 2026-06-25
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260625_02"
down_revision = "20260625_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
# ``task_buttons`` may not exist yet on a brand-new DB — create_all handles
# that case with the columns already present, so only patch existing tables.
if _has_table("task_buttons"):
if not _has_column("task_buttons", "append_auth"):
op.add_column(
"task_buttons",
sa.Column("append_auth", sa.Boolean(), nullable=False, server_default="0"),
)
if not _has_column("task_buttons", "auth_token_param"):
op.add_column(
"task_buttons",
sa.Column("auth_token_param", sa.String(length=64), nullable=True, server_default="token"),
)
if not _has_column("task_buttons", "auth_name_param"):
op.add_column(
"task_buttons",
sa.Column("auth_name_param", sa.String(length=64), nullable=True, server_default="username"),
)
def downgrade() -> None:
for col in ("auth_name_param", "auth_token_param", "append_auth"):
if _has_column("task_buttons", col):
op.drop_column("task_buttons", col)
@@ -0,0 +1,52 @@
"""Add users.approved (登录白名单放行标记).
Backs the login-whitelist feature: when system setting ``login_whitelist.enabled``
is on, only ``approved=True`` users (or admins) may log in; self-registered
regular users default to ``approved=False`` (待审批) until an admin approves them.
Existing rows are backfilled to TRUE (``server_default="1"``) so flipping the
switch on for the first time never locks out users who already had accounts.
Revision ID: 20260625_03
Revises: 20260625_02
Create Date: 2026-06-25
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260625_03"
down_revision = "20260625_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
# A brand-new DB gets the column from create_all; only patch existing tables.
if _has_table("users") and not _has_column("users", "approved"):
op.add_column(
"users",
# server_default="1" backfills existing rows to 已放行 (avoid lockout).
sa.Column("approved", sa.Boolean(), nullable=False, server_default="1"),
)
def downgrade() -> None:
if _has_column("users", "approved"):
op.drop_column("users", "approved")
@@ -20,6 +20,9 @@ def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
"id_kind": str(data.get("id_kind") or "task"),
"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,
@@ -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))
@@ -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;
+6
View File
@@ -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 关掉则不显示该入口。