Merge branch 'dhk-1': ai写作技能检索稳定接收数据 + 内网兜底优化
This commit is contained in:
commit
368673c3b4
20 files changed
+784
-98
No files matched your search
@@ -84,7 +84,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **菜单管理 (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).
|
||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels.
|
||||
- **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。
|
||||
- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **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, 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`.
|
||||
- **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/`
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
readCopTaskDetail,
|
||||
resolveTaskDeeplinkTarget,
|
||||
} from "@/core/auth/task-deeplink";
|
||||
import { readUserInfo, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
|
||||
|
||||
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
|
||||
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
|
||||
@@ -66,6 +67,20 @@ export type TaskButtonIdKind = "task" | "action";
|
||||
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口(嵌入态即当前 iframe)。 */
|
||||
export type TaskButtonOpenMode = "blank" | "self";
|
||||
|
||||
/**
|
||||
* ``url`` 外链追加的**登录参数**(与「应用管理」一致):在拼好的跳转地址后再依次
|
||||
* 追加 ``?{key}={value}``,``value`` 运行时从 ``userInfo`` 按 ``source`` 解析
|
||||
* (``other`` 时取 ``customValue`` 字面值)。复用 [[light-app-center]] 的取值来源。
|
||||
*/
|
||||
export interface TaskButtonLoginParam {
|
||||
/** 拼到 URL 的查询参数名。 */
|
||||
key: string;
|
||||
/** 取值来源:``userInfo`` 字段名(如 ``accessToken`` / ``yUserId``)或 ``other``(自填)。 */
|
||||
source: string;
|
||||
/** ``source==="other"`` 时的自定义字面值。 */
|
||||
customValue: string;
|
||||
}
|
||||
|
||||
/** 一个可配置跳转按钮。 */
|
||||
export interface TaskButton {
|
||||
id: string;
|
||||
@@ -85,10 +100,28 @@ export interface TaskButton {
|
||||
idKind: TaskButtonIdKind;
|
||||
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
|
||||
openMode: TaskButtonOpenMode;
|
||||
/** ``url`` 外链追加的登录参数列表(仅 ``url`` 类型有意义;为空则不追加,行为同旧版)。 */
|
||||
loginParams: TaskButtonLoginParam[];
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时解析单个登录参数的值——**与「应用管理」完全一致**:直接复用 light-app 的
|
||||
* ``resolveRouteParamValue``(把本按钮的登录参数适配成 ``login`` 类型的路由参数),
|
||||
* 保证取值来源/逻辑跟轻应用一模一样(``other`` 取字面 ``customValue``,否则从
|
||||
* ``userInfo`` 取 ``source`` 字段)。
|
||||
*/
|
||||
function resolveLoginParamValue(
|
||||
p: TaskButtonLoginParam,
|
||||
userInfo: Record<string, unknown>,
|
||||
): string {
|
||||
return resolveRouteParamValue(
|
||||
{ key: p.key, paramType: "login", source: p.source, customValue: p.customValue },
|
||||
userInfo,
|
||||
);
|
||||
}
|
||||
|
||||
/** 内部切换可选的目标业务(``business`` 类型按钮的下拉项)。 */
|
||||
export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [
|
||||
{ code: "3qfx", label: "三情分析" },
|
||||
@@ -120,6 +153,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
enabled: true,
|
||||
sortOrder,
|
||||
});
|
||||
@@ -134,6 +168,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
enabled: true,
|
||||
sortOrder,
|
||||
});
|
||||
@@ -149,6 +184,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
@@ -167,6 +203,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idParam: "taskId",
|
||||
idKind: "task",
|
||||
openMode: "self",
|
||||
loginParams: [],
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
@@ -180,6 +217,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
enabled: true,
|
||||
sortOrder: 2,
|
||||
},
|
||||
@@ -193,6 +231,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
enabled: true,
|
||||
sortOrder: 3,
|
||||
},
|
||||
@@ -259,19 +298,36 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
|
||||
// 行动分析(xdfx)可选追加行动id;否则用任务id。行动id 缺失时回退任务id。
|
||||
const id = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
|
||||
const param = (btn.idParam || "id").trim() || "id";
|
||||
const url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base;
|
||||
let url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base;
|
||||
// 追加登录参数(与应用管理一致):运行时从 userInfo 解析每项的值后拼到地址尾部。
|
||||
const loginParams = btn.loginParams ?? [];
|
||||
if (loginParams.length > 0) {
|
||||
const userInfo = readUserInfo();
|
||||
for (const lp of loginParams) {
|
||||
const key = (lp.key || "").trim();
|
||||
if (!key) continue;
|
||||
url = appendQueryParam(url, key, resolveLoginParamValue(lp, userInfo));
|
||||
}
|
||||
}
|
||||
// 应用内部路由(以单个 / 开头,如「上一步」→ 大屏绘制页):
|
||||
// self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。
|
||||
if (base.startsWith("/") && !base.startsWith("//")) {
|
||||
if (btn.openMode === "blank") {
|
||||
const full = `${window.location.origin}${window.location.pathname}#${url}`;
|
||||
// 打印实际打开地址,便于排查登录参数/拼接问题。
|
||||
console.info(`[task-button] 「${btn.label}」内部路由(新标签) → ${full}`);
|
||||
window.open(full, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
console.info(`[task-button] 「${btn.label}」内部路由(同窗) → ${url}`);
|
||||
ctx.navigate(url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 外链:self=当前窗口(嵌入态即当前 iframe);否则 blank=新窗口(默认)。
|
||||
// 打印最终跳转地址(含追加的 id / 登录参数),便于排查问题。
|
||||
console.info(
|
||||
`[task-button] 「${btn.label}」外链(${btn.openMode === "self" ? "当前窗口" : "新窗口"}) → ${url}`,
|
||||
);
|
||||
if (btn.openMode === "self") {
|
||||
window.location.assign(url);
|
||||
} else {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { ArrowDown, ArrowUp, Download, MousePointerClick, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Download, MousePointerClick, Plus, Save, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -37,7 +37,9 @@ import {
|
||||
defaultTaskButtons,
|
||||
type TaskBusiness,
|
||||
type TaskButton,
|
||||
type TaskButtonLoginParam,
|
||||
} from "@/core/auth/task-buttons";
|
||||
import { LOGIN_SOURCE_OPTIONS } from "@/strategy-components/lib/light-app";
|
||||
import { useTaskButtonActions, useTaskButtons } from "@/strategy-components/api/task-buttons";
|
||||
|
||||
let _seq = 1;
|
||||
@@ -105,12 +107,28 @@ export default function TaskButtonsPage() {
|
||||
idParam: "id",
|
||||
idKind: "task",
|
||||
openMode: "blank",
|
||||
loginParams: [],
|
||||
enabled: true,
|
||||
sortOrder: maxSort + 1,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// ── 登录参数(url 外链追加,与应用管理一致)增删改 ──
|
||||
const updateLoginParams = (id: string, fn: (ps: TaskButtonLoginParam[]) => TaskButtonLoginParam[]) =>
|
||||
setRows((rs) =>
|
||||
rs.map((r) => (r.id === id ? { ...r, loginParams: fn(r.loginParams ?? []) } : r)),
|
||||
);
|
||||
|
||||
const addLoginParam = (id: string) =>
|
||||
updateLoginParams(id, (ps) => [...ps, { key: "", source: "accessToken", customValue: "" }]);
|
||||
|
||||
const patchLoginParam = (id: string, idx: number, patch: Partial<TaskButtonLoginParam>) =>
|
||||
updateLoginParams(id, (ps) => ps.map((p, i) => (i === idx ? { ...p, ...patch } : p)));
|
||||
|
||||
const removeLoginParam = (id: string, idx: number) =>
|
||||
updateLoginParams(id, (ps) => ps.filter((_, i) => i !== idx));
|
||||
|
||||
const moveRow = (id: string, dir: "up" | "down") => {
|
||||
const list = [...tabRows];
|
||||
const i = list.findIndex((r) => r.id === id);
|
||||
@@ -335,6 +353,77 @@ export default function TaskButtonsPage() {
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 登录参数(仅外链):与「应用管理」一致,运行时从 userInfo 解析后拼到地址尾部 */}
|
||||
{r.linkType === "url" && (
|
||||
<div className="border-border/60 ml-16 flex flex-col gap-2 rounded-md border border-dashed bg-muted/30 p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
登录参数(追加到地址,值取自登录信息)
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => addLoginParam(r.id)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 添加登录参数
|
||||
</Button>
|
||||
</div>
|
||||
{(r.loginParams ?? []).length === 0 ? (
|
||||
<span className="text-muted-foreground text-[11px]">
|
||||
暂无登录参数,跳转时不追加额外参数。
|
||||
</span>
|
||||
) : (
|
||||
(r.loginParams ?? []).map((p, pi) => (
|
||||
<div key={pi} className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={p.key}
|
||||
onChange={(e) => patchLoginParam(r.id, pi, { key: e.target.value })}
|
||||
placeholder="参数名 key"
|
||||
className="h-8 w-32"
|
||||
title="拼到地址的查询参数名"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">=</span>
|
||||
<Select
|
||||
value={p.source}
|
||||
onValueChange={(v) => patchLoginParam(r.id, pi, { source: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-36" title="取值来源">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOGIN_SOURCE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{p.source === "other" && (
|
||||
<Input
|
||||
value={p.customValue}
|
||||
onChange={(e) =>
|
||||
patchLoginParam(r.id, pi, { customValue: e.target.value })
|
||||
}
|
||||
placeholder="自定义值"
|
||||
className="h-8 w-40"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
onClick={() => removeLoginParam(r.id, pi)}
|
||||
title="删除该登录参数"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -177,13 +177,13 @@ function ToolStep({
|
||||
const display = getStepDisplay(step.key, step.label, step.args);
|
||||
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
|
||||
const [, toolName] = step.key.split(":");
|
||||
// read_file 也可点 —— 验证类「查看文件…」步骤常出现在最后一条,点击后在沙箱里
|
||||
// 查看该文件内容(走虚拟预览或 outputs HTTP)。主聊天只让 write 类可点,这里增强。
|
||||
// 只让「自己生成/展示的文件」可点(write_file / str_replace / present_files),
|
||||
// 与主聊天页一致。read_file 不可点 —— 否则智能体调用技能时读取的 SOUL.md、
|
||||
// 执行的 .py 脚本等也会被渲染成可点击文件名,点开却没有意义。
|
||||
const isArtifactStep =
|
||||
toolName === "write_file" ||
|
||||
toolName === "str_replace" ||
|
||||
toolName === "present_files" ||
|
||||
toolName === "read_file";
|
||||
toolName === "present_files";
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
|
||||
@@ -963,16 +963,8 @@ export function useStep2Orchestration(
|
||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
|
||||
return;
|
||||
}
|
||||
if (toolName === "read_file") {
|
||||
// 验证类「查看文件…」步骤:用 args.path 在沙箱预览该文件。
|
||||
// openArtifactPreview 内部 findSeatArtifactSource 优先按 path 命中流式期间
|
||||
// write_file 缓存的内容(虚拟预览);未命中再走 seat thread 的 outputs HTTP。
|
||||
const path = step.args?.path;
|
||||
if (typeof path === "string" && path) {
|
||||
openArtifactPreview(seatThreadId, path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// read_file 不再可点(见 MessageStepsCard 的 isArtifactStep):智能体调用技能
|
||||
// 时读取的 SOUL.md / 执行的 .py 脚本等不应作为可点击文件渲染。
|
||||
if (toolName === "present_files") {
|
||||
const filepaths = step.args?.filepaths;
|
||||
if (Array.isArray(filepaths)) {
|
||||
|
||||
@@ -584,6 +584,11 @@ export function useStep3Summary(
|
||||
// 不再要求用户说出「结构化输出/流程图」等关键词。
|
||||
const flowActiveRef = useRef(false);
|
||||
const lastAutoOpenedUrlRef = useRef<string | null>(null);
|
||||
// 流程图抽取串行化:在途抽取的 aborter + 当前承接抽取/校验的气泡 id。新一轮抽取会先中止
|
||||
// 上一轮、并复用同一张「调度中」气泡——保证任何时刻只有一张流程图卡片。修复:报告刚结束时
|
||||
// run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。
|
||||
const flowAborterRef = useRef<AbortController | null>(null);
|
||||
const activeFlowBubbleRef = useRef<number | null>(null);
|
||||
|
||||
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
|
||||
const appendStreamingDialogue = useCallback(
|
||||
@@ -646,6 +651,25 @@ export function useStep3Summary(
|
||||
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, sender } : d)));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 复用一条旧气泡承接新一轮(改名 + 清空内容/步骤 + 重新转圈)。流程图抽取被新一轮抢占时
|
||||
* 复用上一轮那条气泡,避免遗留一张卡死的「调度中」卡片。
|
||||
*/
|
||||
const resetDialogueForReuse = useCallback((id: number, sender: string) => {
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === id
|
||||
? { ...d, sender, content: "", steps: [], streaming: true, confidence: "调度中", time: nowTime() }
|
||||
: d,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** 移除一条气泡(流程图抽取抢占时清掉被取代的那张孤儿「调度中」卡片)。 */
|
||||
const removeDialogue = useCallback((id: number) => {
|
||||
setDialogues((prev) => prev.filter((d) => d.id !== id));
|
||||
}, []);
|
||||
|
||||
const appendStepIfNew = useCallback(
|
||||
(
|
||||
bubbleId: number,
|
||||
@@ -1149,19 +1173,36 @@ export function useStep3Summary(
|
||||
opts?: { instruction?: string; currentGraph?: FlowGraphData | null; bubbleId?: number },
|
||||
): Promise<void> => {
|
||||
const isEdit = !!opts?.instruction && !!opts?.currentGraph;
|
||||
// 串行化:先中止上一轮仍在途的流程图抽取(auto 自动出图与用户手动「重画/跳过报告生成流程图」
|
||||
// 可能并发),让被抢占的旧流走 abort 分支静默退出,避免两条抽取流各自建卡片 → 双「调度中」。
|
||||
if (flowAborterRef.current && flowAborterRef.current !== aborter) {
|
||||
flowAborterRef.current.abort();
|
||||
}
|
||||
flowAborterRef.current = aborter;
|
||||
setFlowPhase("extracting");
|
||||
if (!isEdit) setFlowGraphState(null); // 编辑模式保留旧图直到新图就绪(防画布闪空)
|
||||
// 两个气泡:抽取/修改 + 校验——把过程(思考 `<think>` + 文字 + flow-json 代码块)都留着展示,
|
||||
// 由 Step2Message 渲染(思考折叠块 + markdown 代码块),不折叠成一句话。
|
||||
// 传入 bubbleId(意图分类期间已建的「调度中」气泡)则复用并改名,避免空窗 + 不重复建气泡。
|
||||
// 气泡选择:①调用方指定(意图分类期间已建的「调度中」气泡)则复用并改名;②上一轮抽取气泡仍在
|
||||
// (被本轮抢占)则复用并重置(清掉旧内容、重新转圈),不另起新卡片;③否则新建。
|
||||
const extractSender = isEdit ? "结构化流程图 · 修改" : "结构化流程图 · 抽取";
|
||||
const prevFlowBubble = activeFlowBubbleRef.current;
|
||||
let extractBubbleId: number;
|
||||
if (opts?.bubbleId != null) {
|
||||
extractBubbleId = opts.bubbleId;
|
||||
relabelDialogue(extractBubbleId, extractSender);
|
||||
} else if (prevFlowBubble != null) {
|
||||
extractBubbleId = prevFlowBubble;
|
||||
resetDialogueForReuse(extractBubbleId, extractSender);
|
||||
} else {
|
||||
extractBubbleId = appendStreamingDialogue({ sender: extractSender, avatarType: "blue" });
|
||||
}
|
||||
// 抢占场景:上一轮在途流程图气泡 ≠ 本轮要用的气泡(如 ask 用意图分类气泡承接,而 auto 流程图
|
||||
// 另起过一张)→ 移除那张被取代的孤儿「调度中」卡片,保证只剩一张。
|
||||
if (prevFlowBubble != null && prevFlowBubble !== extractBubbleId) {
|
||||
removeDialogue(prevFlowBubble);
|
||||
}
|
||||
activeFlowBubbleRef.current = extractBubbleId;
|
||||
let validateBubbleId: number | null = null;
|
||||
try {
|
||||
const graph = await streamFlowExtract({
|
||||
@@ -1179,7 +1220,10 @@ export function useStep3Summary(
|
||||
instruction: opts?.instruction,
|
||||
signal: aborter.signal,
|
||||
// 模型自动容错:后端切到下一个模型重抽 → 清空抽取气泡里失败尝试的半截内容 + 留可见提示。
|
||||
// 各回调都先看本轮是否仍是「在途的那一轮」——被新一轮抢占(aborter 已 abort)后立即
|
||||
// 变 no-op,避免旧流的迟到帧污染已被新一轮复用的同一张气泡。
|
||||
onModelSwitch: (info) => {
|
||||
if (aborter.signal.aborted) return;
|
||||
setDialogues((prev) => prev.map((d) => (d.id === extractBubbleId ? { ...d, content: "" } : d)));
|
||||
setDialogueConfidence(extractBubbleId, `模型切换:${info.failedModel}→${info.nextModel}`);
|
||||
appendStepIfNew(
|
||||
@@ -1188,18 +1232,27 @@ export function useStep3Summary(
|
||||
`model_switch:${info.nextModel}`,
|
||||
);
|
||||
},
|
||||
onExtractDelta: (t) => appendToDialogue(extractBubbleId, t),
|
||||
onExtractDelta: (t) => {
|
||||
if (aborter.signal.aborted) return;
|
||||
appendToDialogue(extractBubbleId, t);
|
||||
},
|
||||
onValidating: () => {
|
||||
if (aborter.signal.aborted) return;
|
||||
setFlowPhase("validating");
|
||||
// 抽取气泡收尾(保留已流出的内容),另起校验气泡。
|
||||
// 抽取气泡收尾(保留已流出的内容),另起校验气泡(校验阶段它成为当前流程图气泡)。
|
||||
finalizeDialogue(extractBubbleId, "", "抽取完成");
|
||||
validateBubbleId = appendStreamingDialogue({ sender: "结构化流程图 · 校验", avatarType: "blue" });
|
||||
activeFlowBubbleRef.current = validateBubbleId;
|
||||
},
|
||||
onValidateDelta: (t) => {
|
||||
if (aborter.signal.aborted) return;
|
||||
if (validateBubbleId != null) appendToDialogue(validateBubbleId, t);
|
||||
},
|
||||
});
|
||||
if (aborter.signal.aborted) return;
|
||||
// 本轮成功收尾 → 释放串行化句柄(下一轮抽取会新建气泡,而非复用本轮这条已完成的)。
|
||||
if (flowAborterRef.current === aborter) flowAborterRef.current = null;
|
||||
activeFlowBubbleRef.current = null;
|
||||
setFlowGraphState(graph);
|
||||
setFlowPhase("done");
|
||||
flowActiveRef.current = true;
|
||||
@@ -1220,6 +1273,9 @@ export function useStep3Summary(
|
||||
}
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
// 本轮失败收尾 → 同样释放串行化句柄。
|
||||
if (flowAborterRef.current === aborter) flowAborterRef.current = null;
|
||||
activeFlowBubbleRef.current = null;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setFlowPhase("error");
|
||||
finalizeDialogue(
|
||||
@@ -1238,6 +1294,8 @@ export function useStep3Summary(
|
||||
appendToDialogue,
|
||||
finalizeDialogue,
|
||||
relabelDialogue,
|
||||
resetDialogueForReuse,
|
||||
removeDialogue,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1283,6 +1341,9 @@ export function useStep3Summary(
|
||||
setSeatStubMessages({});
|
||||
setFlowGraphState(null);
|
||||
setFlowPhase("idle");
|
||||
// 清空对话同时复位流程图串行化句柄(旧气泡已随 setDialogues([]) 移除,不能再被复用)。
|
||||
flowAborterRef.current = null;
|
||||
activeFlowBubbleRef.current = null;
|
||||
// 整体重跑(重试 / 重新生成)= 重新分析 → 复位入库状态,旧产物的入库询问/按钮先隐藏;
|
||||
// 重跑出新流程图后 runFlowExtract 会重新询问是否入库。
|
||||
resetIngestState();
|
||||
@@ -1316,9 +1377,13 @@ export function useStep3Summary(
|
||||
// 不再在总结线程里续问吐 flow-json(那会被 coordinator_prompt 的报告锚定带偏、不稳);
|
||||
// 改走独立端点:流式抽取(实时显示) → 校验修复 → 用校验后结构化图绘制。
|
||||
// 有报告或有席位交付即触发(无报告也能据席位交付出图)。
|
||||
// `aborterRef.current === aborter` 防竞态:用户在报告刚结束时发「跳过报告直接生成流程图」,
|
||||
// ask() 会先 abort() 本轮(aborterRef 被置空/换成新 aborter)→ 这里不再自动出图,
|
||||
// 由 ask 的手动出图独占,避免 auto + manual 双卡片。
|
||||
if (
|
||||
autoFlow &&
|
||||
!aborter.signal.aborted &&
|
||||
aborterRef.current === aborter &&
|
||||
(currentMdRef.current.trim() || seatDeliveries.length > 0)
|
||||
) {
|
||||
await runFlowExtract(aborter);
|
||||
@@ -1795,6 +1860,9 @@ export function useStep3Summary(
|
||||
(snap: DraftStep3SummarySnapshot) => {
|
||||
abort();
|
||||
runningRef.current = false;
|
||||
// 恢复历史 → 复位流程图串行化句柄(避免指向已不存在的旧气泡)。
|
||||
flowAborterRef.current = null;
|
||||
activeFlowBubbleRef.current = null;
|
||||
// 恢复流程图:快照 closing 里若存了 flow-json,解析成结构化图填进 flowGraph 状态,
|
||||
// 让画布直接用它绘制(也供 Phase 3 手动编辑)。解析不出则回退到对话气泡解析。
|
||||
const restoredGraph = snap.closing ? extractFlowGraph(snap.closing) : null;
|
||||
|
||||
@@ -150,6 +150,12 @@ const BUSINESS_CODE_TO_TASK_BUSINESS: Record<string, TaskBusiness> = {
|
||||
"7BF": "xdfx",
|
||||
};
|
||||
|
||||
/**
|
||||
* 入库仅 rwfx-A(任务分析,businessCode `6BF`)这一深链会商模式才提供——该业务较特殊,
|
||||
* 其它业务(3qfx-A / xdfx-A 等)即便深链进会商生成了流程图也不出现入库按钮 / 入库询问。
|
||||
*/
|
||||
const INGEST_BUSINESS_CODE = "6BF";
|
||||
|
||||
/** 从任务页带进来的预填(经 navigation state 传递)。见会商自动化串联(模块 C)。 */
|
||||
interface RoundtablePrefill {
|
||||
/** 任务名称(注入 Step 1 首条用户消息)。 */
|
||||
@@ -379,7 +385,8 @@ export default function RoundtablePlanningPage() {
|
||||
const ingestTaskIdForCheck = prefillRef.current?.taskId ?? null;
|
||||
const ingestBusinessCode = prefillRef.current?.businessCode ?? null;
|
||||
useEffect(() => {
|
||||
if (!ingestTaskIdForCheck) {
|
||||
// 入库仅 rwfx-A(businessCode 6BF)深链会商模式提供;其它业务一律不判定、不显示入库。
|
||||
if (!ingestTaskIdForCheck || ingestBusinessCode !== INGEST_BUSINESS_CODE) {
|
||||
setIngestAvailable(false);
|
||||
setIngestSkill("");
|
||||
return;
|
||||
@@ -2258,6 +2265,7 @@ export default function RoundtablePlanningPage() {
|
||||
<TaskJumpBar
|
||||
business={BUSINESS_CODE_TO_TASK_BUSINESS[prefillRef.current.businessCode]}
|
||||
taskId={prefillRef.current.taskId}
|
||||
actionId={prefillRef.current.actionId}
|
||||
roundtable
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -16,11 +16,18 @@ import type {
|
||||
TaskButton,
|
||||
TaskButtonIdKind,
|
||||
TaskButtonLinkType,
|
||||
TaskButtonLoginParam,
|
||||
TaskButtonOpenMode,
|
||||
} from "@/core/auth/task-buttons";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/task-buttons`;
|
||||
|
||||
interface ServerLoginParam {
|
||||
key: string;
|
||||
source: string;
|
||||
custom_value: string;
|
||||
}
|
||||
|
||||
interface ServerButton {
|
||||
id: string;
|
||||
business: string;
|
||||
@@ -31,10 +38,19 @@ interface ServerButton {
|
||||
id_param: string;
|
||||
id_kind: string;
|
||||
open_mode: string;
|
||||
login_params: ServerLoginParam[];
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
function toLoginParam(p: ServerLoginParam): TaskButtonLoginParam {
|
||||
return {
|
||||
key: p.key ?? "",
|
||||
source: p.source || "accessToken",
|
||||
customValue: p.custom_value ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function toButton(s: ServerButton): TaskButton {
|
||||
return {
|
||||
id: s.id,
|
||||
@@ -46,6 +62,7 @@ function toButton(s: ServerButton): TaskButton {
|
||||
idParam: s.id_param || "id",
|
||||
idKind: (s.id_kind as TaskButtonIdKind) ?? "task",
|
||||
openMode: (s.open_mode as TaskButtonOpenMode) ?? "blank",
|
||||
loginParams: (s.login_params ?? []).map(toLoginParam),
|
||||
enabled: s.enabled ?? true,
|
||||
sortOrder: s.sort_order ?? 0,
|
||||
};
|
||||
@@ -62,6 +79,11 @@ function toBody(b: TaskButton): ServerButton {
|
||||
id_param: b.idParam,
|
||||
id_kind: b.idKind,
|
||||
open_mode: b.openMode,
|
||||
login_params: (b.loginParams ?? []).map((p) => ({
|
||||
key: p.key,
|
||||
source: p.source,
|
||||
custom_value: p.customValue,
|
||||
})),
|
||||
enabled: b.enabled,
|
||||
sort_order: b.sortOrder,
|
||||
};
|
||||
|
||||
@@ -44,10 +44,19 @@ import { TaskPurposeDialog } from "@/strategy-components/components/task-compone
|
||||
export function TaskJumpBar({
|
||||
business,
|
||||
taskId,
|
||||
actionId,
|
||||
roundtable,
|
||||
}: {
|
||||
business: TaskBusiness;
|
||||
taskId: string;
|
||||
/**
|
||||
* xdfx 深链原始「行动id」(URL ?taskId= 传入值,与解析后的任务id 不同)。
|
||||
* 单智能体问答(``TaskFxWorkspace``)由 ``persistTaskContext`` 写进 ``TaskContext``,
|
||||
* 故省略时回退 ``readTaskContext()?.actionId``;多智能体会商(``RoundtablePlanningPage``)
|
||||
* 不写 ``TaskContext``(只在 ``roundtablePrefill`` 里),必须**显式**把 ``prefill.actionId``
|
||||
* 传进来,否则「追加行动id」的按钮会拿不到行动id 而回退任务id。
|
||||
*/
|
||||
actionId?: string;
|
||||
/** 多智能体会商深链模式:不展示「编辑」与「弹框查看」(purpose)按钮。 */
|
||||
roundtable?: boolean;
|
||||
}) {
|
||||
@@ -73,10 +82,13 @@ export function TaskJumpBar({
|
||||
|
||||
// 目的树弹框(purpose 类型按钮)由组件层打开。
|
||||
const [purposeOpen, setPurposeOpen] = useState(false);
|
||||
// xdfx:深链原始「行动id」(与解析后的任务id 不同才算真行动id),弹框据此换回任务id 再查目的。
|
||||
const ctxActionId = readTaskContext()?.actionId;
|
||||
// xdfx 深链原始「行动id」:会商模式由 prop 显式传入;单智能体回退 TaskContext。
|
||||
const effectiveActionId = actionId ?? readTaskContext()?.actionId;
|
||||
// 与解析后的任务id 不同才算真行动id,弹框据此换回任务id 再查目的。
|
||||
const purposeActionId =
|
||||
business === "xdfx" && ctxActionId && ctxActionId !== taskId ? ctxActionId : undefined;
|
||||
business === "xdfx" && effectiveActionId && effectiveActionId !== taskId
|
||||
? effectiveActionId
|
||||
: undefined;
|
||||
|
||||
if (jumpButtons.length === 0) return null;
|
||||
// 深链 `hideJump=1`(外部 iframe 套用时):整条跳转栏隐藏——底部「下一步」等按钮 +
|
||||
@@ -90,7 +102,7 @@ export function TaskJumpBar({
|
||||
}
|
||||
void runTaskButton(btn, {
|
||||
taskId,
|
||||
actionId: readTaskContext()?.actionId,
|
||||
actionId: effectiveActionId,
|
||||
navigate,
|
||||
onError: (m) => toast.error(m),
|
||||
});
|
||||
|
||||
@@ -258,7 +258,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), enabled, sort_order}`. 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, 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`. |
|
||||
| **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.
|
||||
@@ -274,7 +274,11 @@ Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship
|
||||
|
||||
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). **Skill-failure fault tolerance**: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a `skill_search_step` of type `skill_error` (carrying the error `reason`), and if the configured skills collected **no** new materials at all it runs a `fallback` pass over other available skills (`_fallback_candidate_skills`: the built-in `knowledge-base-search` native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend `SkillStepsCard` renders `skill_error` (rose) and `fallback` (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: `tests/test_ai_writing_material_source.py`.
|
||||
|
||||
**Skill-call timeout** is `AI_WRITING_SKILL_SEARCH_TIMEOUT_SECONDS` (env, default **120s**; `researcher.py` `_SKILL_SEARCH_TIMEOUT_SECONDS`); the DuckDuckGo provider's internal cap was raised 30s→120s so it doesn't undercut it.
|
||||
**Skill-call timeout** is hardcoded **300s (5 min)** — `researcher.py` `_SKILL_SEARCH_TIMEOUT_SECONDS` (per-skill wrapper) and `_skill_agent.py` `_REAL_AGENT_TIMEOUT_SECONDS` (whole real-agent skill run). The intranet ES fallback (`IntranetSearchProvider`) gets a longer **600s (10 min)** default (`intranet_stub.py`; overridable via `ai_writing.intranet_search_timeout`) — that interface is the slow one, so our own fallback timeout is generous to avoid being blamed for "检不出".
|
||||
|
||||
**Parallel intranet fallback (技能 0 条 → 直接用并行预跑的内网结果).** When `ai_writing.intranet_search_url` is configured, the researcher node launches the intranet ES search (`IntranetSearchProvider` over the generated keywords) as a **concurrent `asyncio.Task` the moment the skill loop starts** (not sequentially after all skills fail). If the configured + fallback skills collect **nothing**, the node awaits that already-running task and uses its results (`_collect`) — no extra wait. If the skills **did** get materials, the task is cancelled (`_cancel_intranet_task`, suppresses CancelledError) so the intranet call isn't wasted. URL empty → no task, behavior unchanged. Tests: `tests/test_ai_writing_intranet_parallel.py`.
|
||||
|
||||
**Partial-result harvest + query simplification (检不出/超时的容错).** When `researcher_skill_agent: true` runs a non-native skill via `run_skill_via_real_agent` (the deployment's real path — the skill, e.g. `knowledge-search-v2`, is invoked by the real agent through `bash python3 scripts/search.py "<query>"`), one writing run issues many queries and **some time out** (the skill script prints `{"success":false,"error":"请求超时…","count":0}`) while others succeed (`{"success":true,"count":30,"results":[…]}`). Two fixes so successful data is never dropped: (1) `_drive()` captures every **ToolMessage** stdout and `_harvest_tool_results()` parses the skill's JSON **by reusing the lead-agent 参考文献 mode's proven extractor** (`deerflow.runtime.references`: `_parse_json_like` + `_first_list` + `_normalize_reference_item`) — the same logic that already extracts skill JSON into citations 100% reliably on ChatPage/AgentChatPage, with its huge field-name union (title/m_title/name, content/page_content/content_preview/snippet/…, recUuid/uuid/docId/…) and per-skill display-config support — plus skips `success:false` outputs — so even if the model's final `{materials:[]}` summary gives up (confused by the timeouts), the successful queries' real results are still collected; the function merges model materials + harvested results (dedup by url/recUuid/title, capped at `max_results`). **The harvest+merge (`_finalize`) runs on EVERY exit path — normal end, overall timeout, AND `await task` exceptions (e.g. `GraphRecursionError`)** — so when the run is cut off before the model writes its summary (observed: 10 queries × ~2 graph steps > the old `recursion_limit`, so the agent errored out with the real 30+ results already in `tool_outputs`), those results are still salvaged instead of returning `[]`. `recursion_limit` was also raised from `_REAL_AGENT_MAX_TURNS` to `_REAL_AGENT_MAX_TURNS*2+4` (it counts graph super-steps ≈ 2 per model→tool round, not model turns) so the model normally has room to finish its summary. **Early-stop on `max_results`:** `_drive` re-harvests after every tool output and, once the deduped harvested count reaches `max_results` (= `per_skill` = `max_materials`), **breaks the nested `astream`** and emits a 「已获取 N 条素材,达到上限,停止本技能检索」 step — without it a weak model keeps issuing new queries forever (observed: 100+ step-bar entries, materials far over the limit). Because the skill now returns exactly `max_materials`, the researcher loop's own `collect_limit` early-stop fires right after, so later configured skills don't run either. (2) The model is instructed (in `_MATERIAL_OUTPUT_CONTRACT`) that a 0-result/timeout query must be **retried with a simpler, core query** (drop topic suffixes like 竞选/诉讼/政策, keep the core entity: 「特朗普竞选」→「特朗普」) and that any data already retrieved must always be kept. The same deterministic simplifier `search/base.py::simplify_query` is wired into `IntranetSearchProvider.search`: an empty first attempt retries once with the simplified query. **(3) Root cause of the instability — bash output middle-truncation.** The skill prints its results as JSON to stdout, and the `bash` tool **middle-truncates** any output over `sandbox.bash_output_max_chars` (default **20000**, inserting a `... [middle truncated …] ...` marker). A `count:30` skill response (~30 records × ~600 chars) exceeds 20000 → the JSON is cut in the middle → **unparseable as a whole** by both the model and `_try_load_json` → all materials lost (while a small `count:9` response parses fine — hence the flapping). Fix: when whole-JSON parse yields no items, `_harvest_tool_results` falls back to `_scan_balanced_json_objects` — a brace-depth/string-aware scanner that recovers **every individually-complete `{…}` record object** from the truncated text (the records on each side of the truncation marker are still valid JSON), so ~16+16 records survive a 20000-char cut, far above `max_materials`. This is why the lead-agent chat pages (`/page/workspace/chats/new`, agent chat) call the same skill with 100% success — they only need the model to summarize prose from whatever it sees, never to parse a structured list, so truncation doesn't hurt them; ai-writing needs the structured records, so it must salvage them itself. Tests: `tests/test_ai_writing_skill_real_agent.py`, `tests/test_ai_writing_query_simplify.py`.
|
||||
|
||||
**Agentic skill execution (`ai_writing.researcher_skill_agent`, default false).** By default a configured non-native skill is *not* run agent-style — the node just turns its `description` into a query for the generic search provider. With this flag on, the directed-skill branch instead runs `nodes/_skill_agent.py::run_skill_search_agent`: it injects the skill's **SKILL.md** (read from `skill.skill_file`) alongside the researcher SOUL as the system prompt, binds a `search` tool to the researcher model, and runs a bounded (`_MAX_TURNS=4`) tool-call loop so the model drives the retrieval per the skill's instructions; the `SearchResult`s the tool returns are harvested directly as materials (no fuzzy text extraction). Requires a tool-calling model; if the model can't tool-call (returns no `tool_calls`) or the agent yields nothing, `_execute_skill` falls back to the legacy description→query search so materials are never empty. The built-in `knowledge-base-search` native concurrent path is unchanged. Tests: `tests/test_ai_writing_skill_agent.py`.
|
||||
|
||||
|
||||
@@ -35,6 +35,18 @@ router = APIRouter(prefix="/api/task-buttons", tags=["task-buttons"])
|
||||
# ── schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TaskButtonLoginParam(BaseModel):
|
||||
"""url 外链追加的登录参数(与应用管理一致)。
|
||||
|
||||
运行时由前端从 ``userInfo`` 解析:``source`` 为 ``userInfo`` 字段名
|
||||
(如 ``accessToken`` / ``yUserId``),``other`` 时取 ``custom_value`` 字面值。
|
||||
"""
|
||||
|
||||
key: str = Field(default="", max_length=64)
|
||||
source: str = Field(default="accessToken", max_length=64)
|
||||
custom_value: str = Field(default="", max_length=512)
|
||||
|
||||
|
||||
class TaskButton(BaseModel):
|
||||
id: str = Field(..., min_length=1, max_length=128)
|
||||
business: str = Field(..., min_length=1, max_length=32)
|
||||
@@ -48,6 +60,8 @@ class TaskButton(BaseModel):
|
||||
id_kind: str = Field(default="task", max_length=16)
|
||||
# url 外链打开方式:blank=新窗口(默认)/ self=当前窗口。
|
||||
open_mode: str = Field(default="blank", max_length=16)
|
||||
# url 外链追加的登录参数列表(key + 取值来源)。
|
||||
login_params: list[TaskButtonLoginParam] = Field(default_factory=list)
|
||||
enabled: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
+246
-28
@@ -53,19 +53,179 @@ def use_skill_search_agent() -> bool:
|
||||
_REAL_AGENT_MAX_TURNS = 16
|
||||
|
||||
# 真实智能体跑技能:整段运行(读 SKILL.md + 多轮工具调用)的超时上限,**硬编码 5 分钟**。
|
||||
# 内网检索很慢、技能常做多次检索,单次直连检索的 120s 不够整段用;这里写死,不依赖 env
|
||||
# (部署方不一定会配环境变量),保证内网慢检索至少有 5 分钟才被切断。
|
||||
# 内网检索很慢、技能常做多次检索,单次直连检索的 120s 不够整段用;5 分钟足够整段跑完
|
||||
# (含模型对超时检索词自行换简单词重试),不依赖 env(部署方不一定会配环境变量)。
|
||||
_REAL_AGENT_TIMEOUT_SECONDS = 300
|
||||
|
||||
_MATERIAL_OUTPUT_CONTRACT = (
|
||||
"你的任务:严格按上面挂载的检索技能(SKILL.md)的说明,用相应工具检索与「写作意图」相关的**真实素材**。\n"
|
||||
"- 必须按技能说明实际调用工具完成检索(读取知识库文件 / 调用内网检索接口 / MCP 等),**不得凭记忆编造**。\n"
|
||||
"- **某个检索词返回 0 条或报「请求超时」时,不要就此放弃**:请把它换成更核心、更简短的检索词再试一次"
|
||||
"(去掉「竞选/诉讼/政策/动向」等话题后缀,只保留核心实体,例如「特朗普竞选」→「特朗普」、"
|
||||
"「特朗普法律诉讼」→「特朗普」;多词时只留最关键的那个词)。\n"
|
||||
"- **只要有任何检索词成功拿到了数据,就必须把这些数据全部收下并输出**,绝不能因为别的检索词没出结果/超时就丢弃已经检索到的素材。\n"
|
||||
"- 检索完成后,**只输出一个 JSON 对象**(不要解释、不要 markdown 代码块、用英文双引号),把检索到的真实素材列出:\n"
|
||||
'{"materials": [{"title": "标题", "content": "素材正文摘录(尽量保留原文, <=500字)", "source": "来源", "url": "原文链接(可空)", "published_at": "日期(可空)"}]}\n'
|
||||
"- content 必须来自检索到的真实内容;若确实没检索到任何素材,输出 {\"materials\": []}。"
|
||||
"- content 必须来自检索到的真实内容;只有当**所有**检索词(含简化后重试)都没拿到任何素材时,才输出 {\"materials\": []}。"
|
||||
)
|
||||
|
||||
|
||||
def _scan_balanced_json_objects(text: str) -> list:
|
||||
"""从可能被**中间截断**的文本里扫出所有「括号平衡且完整」的 ``{...}`` 子串(含各嵌套层级)。
|
||||
|
||||
根因:bash 工具对超过 ~20000 字的输出做**中间截断**(插入「... [middle truncated …] ...」),
|
||||
使整段大 JSON(如 count=30 的技能返回)无法解析;但被截断点**两侧的单条结果对象**通常仍是
|
||||
完整合法的 JSON,可逐个抢救出来。带字符串状态机(正确处理引号内的 {}/转义),逐个返回闭合的
|
||||
对象子串,调用方再逐个 json.loads + 字段识别。
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
out: list = []
|
||||
stack: list[int] = []
|
||||
in_str = False
|
||||
esc = False
|
||||
for i, ch in enumerate(text):
|
||||
if in_str:
|
||||
if esc:
|
||||
esc = False
|
||||
elif ch == "\\":
|
||||
esc = True
|
||||
elif ch == '"':
|
||||
in_str = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
elif ch == "{":
|
||||
stack.append(i)
|
||||
elif ch == "}":
|
||||
if stack:
|
||||
s = stack.pop()
|
||||
frag = text[s : i + 1]
|
||||
try:
|
||||
parsed = _json.loads(frag)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
out.append(parsed)
|
||||
return out
|
||||
|
||||
|
||||
def _describe_tool_output(raw: str, skill_name: str) -> str:
|
||||
"""从单次技能输出里提炼一句步骤条文案:**检索词 + 本次命中条数**(用户要求实时显示每次拿到多少条)。
|
||||
|
||||
返回空串 = 这次工具输出不是可识别的检索结果(如 read_file 读 SKILL.md),不发步骤。
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
from deerflow.runtime import references as _ref
|
||||
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return ""
|
||||
payload = _ref._parse_json_like(raw)
|
||||
query = ""
|
||||
if isinstance(payload, dict):
|
||||
query = str(payload.get("query") or "").strip()
|
||||
# 明确失败(超时/报错):也给一条「0 条 + 原因」,让用户看到这次没出数据
|
||||
if payload.get("success") is False:
|
||||
err = str(payload.get("error") or "").strip()
|
||||
q = f"「{query}」" if query else ""
|
||||
return f"技能「{skill_name}」检索{q}未获得数据(0 条){(':' + err) if err else ''}"
|
||||
|
||||
count: int | None = None
|
||||
if isinstance(payload, dict) and isinstance(payload.get("count"), int):
|
||||
count = payload["count"]
|
||||
items = _ref._first_list(payload) if payload is not None else []
|
||||
if count is None and items:
|
||||
count = len(items)
|
||||
if count is None:
|
||||
# 整段没解析出来(最常见:bash 输出被中间截断)→ 从头部正则抢救 query/count,
|
||||
# 再退化为「抢救出的完整记录对象」计数。
|
||||
if not query:
|
||||
m = _re.search(r'"query"\s*:\s*"([^"]{1,80})"', raw)
|
||||
query = m.group(1).strip() if m else ""
|
||||
m2 = _re.search(r'"count"\s*:\s*(\d+)', raw)
|
||||
if m2:
|
||||
count = int(m2.group(1))
|
||||
else:
|
||||
salvaged = _scan_balanced_json_objects(raw)
|
||||
n = sum(1 for o in salvaged if o.get("content") or o.get("content_preview") or o.get("title") or o.get("m_title"))
|
||||
count = n if n > 0 else None
|
||||
if count is None:
|
||||
return "" # 非检索类输出,不发步骤
|
||||
q = f"「{query}」" if query else ""
|
||||
return f"技能「{skill_name}」检索{q}获得 {count} 条数据"
|
||||
|
||||
|
||||
def _harvest_tool_results(tool_outputs: list, skill_name: str, limit: int) -> list:
|
||||
"""**直接从技能 stdout 收割真实检索结果**(不依赖模型最后的 JSON 汇总)。
|
||||
|
||||
**复用主智能体「参考文献」模式同一套久经考验的提取逻辑**(`deerflow.runtime.references`):
|
||||
那套逻辑在 ChatPage/AgentChatPage 已 100% 稳定地把技能 JSON 解析成参考文献——它对结果列表
|
||||
字段(results/data/items/documents/...)、单条字段(title/m_title、content/page_content/
|
||||
content_preview/snippet/...、recUuid/uuid/docId/...)都做了超大并集兼容,并尊重每个技能的
|
||||
展示配置。ai 写作直接复用它,确保和参考文献模式「解析能力完全一致」。
|
||||
|
||||
在此基础上再叠加两层本地容错:
|
||||
- 跳过 ``success:false`` 的输出(某检索词超时不影响其它成功的);
|
||||
- 整段 JSON 解析不出结果时(最常见:bash 输出被**中间截断**),退化为「逐个抢救完整的
|
||||
记录对象」(`_scan_balanced_json_objects`),截断点两侧的单条记录仍能救回。
|
||||
"""
|
||||
from ..search.base import SearchResult
|
||||
from deerflow.runtime import references as _ref
|
||||
|
||||
tool_context = {"skill_name": skill_name, "tool_name": skill_name}
|
||||
harvested: list = []
|
||||
seen: set[str] = set()
|
||||
for raw in tool_outputs:
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
payload = _ref._parse_json_like(raw)
|
||||
# 明确标记失败的(超时 / 报错)跳过,但不影响其它成功的输出
|
||||
if isinstance(payload, dict) and payload.get("success") is False:
|
||||
continue
|
||||
items = _ref._first_list(payload) if payload is not None else []
|
||||
if not items:
|
||||
# 整段解析不出结果(bash 中间截断把大 JSON 剪断)→ 逐个抢救完整的记录对象
|
||||
items = _scan_balanced_json_objects(raw)
|
||||
for idx, it in enumerate(items):
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
# 用参考文献模式的归一化(含字段并集 + 每技能展示配置),与右侧参考文献完全一致
|
||||
norm = _ref._normalize_reference_item(
|
||||
it, len(harvested) + 1, payload=payload, tool_context=tool_context
|
||||
)
|
||||
if not norm:
|
||||
continue
|
||||
content = str(norm.get("content") or norm.get("snippet") or "").strip()
|
||||
title = str(norm.get("title") or "").strip()
|
||||
if not content and not title:
|
||||
continue
|
||||
raw_rec = norm.get("raw") if isinstance(norm.get("raw"), dict) else {}
|
||||
rec = str(raw_rec.get("recUuid") or raw_rec.get("uuid") or raw_rec.get("docId") or "").strip()
|
||||
url = str(norm.get("url") or "").strip()
|
||||
key = url or rec or title
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
try:
|
||||
score = float(norm.get("score"))
|
||||
except (TypeError, ValueError):
|
||||
score = 0.75
|
||||
harvested.append(SearchResult(
|
||||
title=title or "(无标题)",
|
||||
content=content[:500],
|
||||
source=str(norm.get("source") or skill_name).strip() or skill_name,
|
||||
published_at=str(norm.get("time") or "").strip(),
|
||||
url=url,
|
||||
relevance_score=score or 0.75,
|
||||
source_type="skill",
|
||||
recUuid=rec,
|
||||
))
|
||||
if len(harvested) >= limit:
|
||||
return harvested
|
||||
return harvested
|
||||
|
||||
|
||||
def _summarize_tool_args(args: dict) -> str:
|
||||
"""把工具调用参数压成一句简短摘要,放进步骤条文案(取最能代表本次调用的字段)。"""
|
||||
if isinstance(args, dict):
|
||||
@@ -102,7 +262,7 @@ async def run_skill_via_real_agent(
|
||||
SearchResult。返回 ``(results, error_detail)``:results 为空且 error 非空 = 该技能失败
|
||||
(researcher 据此回退直连检索)。
|
||||
"""
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.subagents.config import SubagentConfig
|
||||
@@ -188,7 +348,10 @@ async def run_skill_via_real_agent(
|
||||
logger.warning("skill real-agent[%s]: init failed: %s", skill_name, e)
|
||||
return [], f"运行时初始化失败: {e}"
|
||||
|
||||
run_config: dict = {"recursion_limit": _REAL_AGENT_MAX_TURNS}
|
||||
# recursion_limit 计的是 graph 超级步(每个「模型→工具」回合≈2 步),不能直接等于 max_turns,
|
||||
# 否则模型做了 N 个检索词后还没来得及写最终汇总就被 GraphRecursionError 截断(实测 10 个
|
||||
# 检索词≈20 步 > 16,结果汇总丢失)。给到 ~2×turns + 余量,让模型正常情况下能写完汇总。
|
||||
run_config: dict = {"recursion_limit": _REAL_AGENT_MAX_TURNS * 2 + 4}
|
||||
run_context: dict = {}
|
||||
if thread_id:
|
||||
run_config["configurable"] = {"thread_id": thread_id}
|
||||
@@ -206,11 +369,15 @@ async def run_skill_via_real_agent(
|
||||
# 这样 custom 事件能被 LangGraph **实时**刷给前端 SSE;若在嵌套 astream 里直接 emit,
|
||||
# 事件会被攒到整段跑完才一次性吐出(后端在跑、前端却一直 loading)。
|
||||
final_text = ""
|
||||
# 技能每次工具调用的 stdout(如 search.py 打印的 {success,results} JSON),用于**直接收割**
|
||||
# 真实检索结果,不依赖模型最后是否把它们汇总进 {materials}。
|
||||
tool_outputs: list[str] = []
|
||||
seen_msg_ids: set[str] = set()
|
||||
step_queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
async def _drive() -> None:
|
||||
nonlocal final_text
|
||||
reached_limit = False
|
||||
try:
|
||||
async for chunk in agent.astream(
|
||||
init_state, config=run_config, context=run_context, stream_mode="values"
|
||||
@@ -229,6 +396,22 @@ async def run_skill_via_real_agent(
|
||||
text = _content_to_text(m.content)
|
||||
if text.strip():
|
||||
final_text = text
|
||||
elif isinstance(m, ToolMessage):
|
||||
out = _content_to_text(m.content)
|
||||
if out.strip():
|
||||
tool_outputs.append(out)
|
||||
# 实时步骤条:这次检索拿到多少条数据(含失败/截断的诚实计数)
|
||||
desc = _describe_tool_output(out, skill_name)
|
||||
if desc:
|
||||
step_queue.put_nowait(desc)
|
||||
# **早停**:已收割素材达上限就停止驱动模型——否则弱模型会无限换检索词
|
||||
# (步骤条爆 100+ 条、素材远超上限)。停掉嵌套 astream 即不再检索。
|
||||
if max_results and len(_harvest_tool_results(tool_outputs, skill_name, max_results)) >= max_results:
|
||||
step_queue.put_nowait(f"已获取 {max_results} 条素材,达到上限,停止本技能检索")
|
||||
reached_limit = True
|
||||
break
|
||||
if reached_limit:
|
||||
break
|
||||
finally:
|
||||
step_queue.put_nowait(None) # sentinel:跑完/出错都让 pump 收尾
|
||||
|
||||
@@ -242,10 +425,62 @@ async def run_skill_via_real_agent(
|
||||
if emit_query:
|
||||
emit_query(item) # 本协程上下文调用 writer → custom 事件实时刷给前端
|
||||
|
||||
def _finalize() -> list:
|
||||
"""把「模型最终汇总的 materials」与「技能 stdout 直接收割的结果」合并去重。
|
||||
|
||||
**关键**:无论本次运行是正常结束、超时、还是抛异常,只要已经收到过技能的工具输出,
|
||||
就必须把这些真实结果收割下来——绝不能因为模型还没来得及汇总、或整段被切断,就把已经
|
||||
拿到的数据整段丢掉(用户实测:技能明明检到了 30+ 条,却因没汇总而全丢)。
|
||||
"""
|
||||
# 1) 模型最终汇总的 materials(它可能已按相关度筛选/去重)
|
||||
model_results: list = []
|
||||
parsed = parse_json(final_text or "", {"materials": []})
|
||||
raw_materials = parsed.get("materials")
|
||||
if isinstance(raw_materials, list):
|
||||
for m in raw_materials:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
content = str(m.get("content") or "").strip()
|
||||
title = str(m.get("title") or "").strip()
|
||||
if not content and not title:
|
||||
continue
|
||||
model_results.append(SearchResult(
|
||||
title=title or "(无标题)",
|
||||
content=content[:500],
|
||||
source=str(m.get("source") or skill_name).strip() or skill_name,
|
||||
published_at=str(m.get("published_at") or "").strip(),
|
||||
url=str(m.get("url") or "").strip(),
|
||||
relevance_score=0.8,
|
||||
source_type="skill",
|
||||
))
|
||||
|
||||
# 2) **直接从技能 stdout 收割真实检索结果**——哪怕模型最后没汇总、或别的检索词超时,
|
||||
# 成功检索词拿回的数据也照单全收。
|
||||
harvested = _harvest_tool_results(tool_outputs, skill_name, max_results)
|
||||
|
||||
# 3) 合并去重(模型汇总优先、技能原始结果补齐),按 url/recUuid/title 去重,截断到上限。
|
||||
merged: list = []
|
||||
seen_keys: set[str] = set()
|
||||
for r in model_results + harvested:
|
||||
key = (r.url or "").strip() or (r.recUuid or "").strip() or (r.title or "").strip()
|
||||
if key and key in seen_keys:
|
||||
continue
|
||||
if key:
|
||||
seen_keys.add(key)
|
||||
merged.append(r)
|
||||
if len(merged) >= max_results:
|
||||
break
|
||||
return merged
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_pump(), timeout=max(timeout_s, _REAL_AGENT_TIMEOUT_SECONDS))
|
||||
except asyncio.TimeoutError:
|
||||
task.cancel()
|
||||
# 超时也要收割已拿到的技能数据:有就返回(无错误),实在没有才报超时。
|
||||
salvaged = _finalize()
|
||||
if salvaged:
|
||||
logger.info("skill real-agent[%s]: timed out but salvaged %d results", skill_name, len(salvaged))
|
||||
return salvaged, ""
|
||||
return [], f"技能检索超时(>{max(timeout_s, _REAL_AGENT_TIMEOUT_SECONDS)}s)"
|
||||
|
||||
# pump 正常结束(收到 sentinel)后等后台任务收尾,拿 final_text / 抛出的异常
|
||||
@@ -253,31 +488,14 @@ async def run_skill_via_real_agent(
|
||||
await task
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("skill real-agent[%s]: astream failed: %s", skill_name, e, exc_info=True)
|
||||
# 递归上限 / 中途异常同样要收割已拿到的技能数据,不整段丢。
|
||||
salvaged = _finalize()
|
||||
if salvaged:
|
||||
logger.info("skill real-agent[%s]: errored but salvaged %d results", skill_name, len(salvaged))
|
||||
return salvaged, ""
|
||||
return [], f"技能检索失败: {e}"
|
||||
|
||||
parsed = parse_json(final_text or "", {"materials": []})
|
||||
raw_materials = parsed.get("materials")
|
||||
if not isinstance(raw_materials, list):
|
||||
return [], ""
|
||||
|
||||
results: list = []
|
||||
for m in raw_materials[:max_results]:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
content = str(m.get("content") or "").strip()
|
||||
title = str(m.get("title") or "").strip()
|
||||
if not content and not title:
|
||||
continue
|
||||
results.append(SearchResult(
|
||||
title=title or "(无标题)",
|
||||
content=content[:500],
|
||||
source=str(m.get("source") or skill_name).strip() or skill_name,
|
||||
published_at=str(m.get("published_at") or "").strip(),
|
||||
url=str(m.get("url") or "").strip(),
|
||||
relevance_score=0.8,
|
||||
source_type="skill",
|
||||
))
|
||||
return results, ""
|
||||
return _finalize(), ""
|
||||
|
||||
|
||||
def _read_skill_body(skill) -> str:
|
||||
|
||||
+73
-27
@@ -767,6 +767,60 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
# 早停上限:通常为 max_materials;用户显式要「再追加 N 条」时放宽到 N,确保凑够
|
||||
collect_limit = max(max_materials, requested_append_count or 0)
|
||||
|
||||
# ── 内网检索接口:与技能检索**并行**预跑 ──
|
||||
# 用户诉求:调用技能的同时就把内网检索接口跑起来;技能没拿到结果时,直接用这里
|
||||
# 预跑好的内网结果兜底(省去"技能全失败后再串行等一遍内网"的时间)。需在 config.yaml
|
||||
# 的 ai_writing.intranet_search_url 配地址才启用;技能拿到结果则取消这个任务、不浪费。
|
||||
intranet_url = (ai_writing_config.get("intranet_search_url") or "").strip()
|
||||
intranet_task: "asyncio.Task | None" = None
|
||||
intranet_holder: dict = {}
|
||||
|
||||
async def _run_intranet_search() -> list[SearchResult]:
|
||||
# 全程吞异常:并行内网任务**绝不**能把主流程带挂(实测内网接口可能瞬间失败)。
|
||||
# 失败只记 search_errors + 日志,返回空列表,交由技能结果或空结果文案兜底。
|
||||
try:
|
||||
from ..search.intranet_stub import IntranetSearchProvider
|
||||
|
||||
intranet = IntranetSearchProvider(
|
||||
base_url=intranet_url,
|
||||
knowledge_base_name=ai_writing_config.get("intranet_knowledge_base", "samples"),
|
||||
# 兜底接口超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,
|
||||
# 默认 600s(10 分钟),给内网慢检索留足时间。
|
||||
timeout=ai_writing_config.get("intranet_search_timeout", 600),
|
||||
)
|
||||
intranet_holder["provider"] = intranet
|
||||
fb_keywords = keywords[:keyword_count] or [intent]
|
||||
gathered = await asyncio.gather(
|
||||
*[intranet.search(kw, max_results=per_skill) for kw in fb_keywords],
|
||||
return_exceptions=True,
|
||||
)
|
||||
collected: list[SearchResult] = []
|
||||
for res in gathered:
|
||||
if isinstance(res, Exception):
|
||||
logger.warning("[intranet-parallel] one keyword failed: %s", res)
|
||||
search_errors.append(f"{type(res).__name__}: {res}")
|
||||
continue
|
||||
collected.extend(res or [])
|
||||
return collected
|
||||
except asyncio.CancelledError:
|
||||
raise # 取消要正常传播(技能已拿到结果时主动取消)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[intranet-parallel] intranet fallback crashed: %s", e, exc_info=True)
|
||||
search_errors.append(f"内网兜底异常:{type(e).__name__}: {e}")
|
||||
return []
|
||||
|
||||
if intranet_url:
|
||||
intranet_task = asyncio.create_task(_run_intranet_search())
|
||||
|
||||
async def _cancel_intranet_task() -> None:
|
||||
"""技能已拿到素材:取消并行的内网检索任务,避免无谓占用 / 未取回异常告警。"""
|
||||
if intranet_task is not None and not intranet_task.done():
|
||||
intranet_task.cancel()
|
||||
try:
|
||||
await intranet_task
|
||||
except BaseException: # noqa: BLE001 — CancelledError / 任务内异常都吞掉
|
||||
pass
|
||||
|
||||
for sk in ordered:
|
||||
added, error_detail = await _execute_skill(sk)
|
||||
tried_names.add(sk.name)
|
||||
@@ -802,42 +856,34 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
# 兜底已拿到素材即止,不再无谓尝试后续技能
|
||||
break
|
||||
|
||||
# ── 最终兜底:技能(含兜底技能)都没检索到 → 直连内网知识库接口(ES)兜一把 ──
|
||||
# 用户诉求:技能因网络/配置连不上而 0 条时,至少用内网 ES 接口直检,避免素材全空。
|
||||
# 需在 config.yaml 的 ai_writing 段配 intranet_search_url 才启用;接口契约见
|
||||
# IntranetSearchProvider(POST {query,knowledge_base_name,ts} → [{page_content,metadata}])。
|
||||
# ── 最终兜底:技能(含兜底技能)都没检索到 → 用并行预跑的内网知识库接口结果兜底 ──
|
||||
# 用户诉求:调用技能时已同时跑了内网检索接口;技能因网络/配置连不上而 0 条时,直接用
|
||||
# 那份并行结果,避免素材全空、也省去串行等待。需在 config.yaml 配 intranet_search_url。
|
||||
if not new_results:
|
||||
intranet_url = (ai_writing_config.get("intranet_search_url") or "").strip()
|
||||
if intranet_url:
|
||||
from ..search.intranet_stub import IntranetSearchProvider
|
||||
|
||||
if intranet_task is not None:
|
||||
_emit_skill_step({
|
||||
"type": "fallback",
|
||||
"message": "技能均未检索到素材,改为直连内网知识库接口兜底检索…",
|
||||
"message": "技能均未检索到素材,使用并行预跑的内网知识库接口结果兜底…",
|
||||
})
|
||||
intranet = IntranetSearchProvider(
|
||||
base_url=intranet_url,
|
||||
knowledge_base_name=ai_writing_config.get("intranet_knowledge_base", "samples"),
|
||||
)
|
||||
fb_keywords = keywords[:keyword_count] or [intent]
|
||||
fb_gathered = await asyncio.gather(
|
||||
*[intranet.search(kw, max_results=per_skill) for kw in fb_keywords],
|
||||
return_exceptions=True,
|
||||
)
|
||||
fb_added = 0
|
||||
for fb_results in fb_gathered:
|
||||
if isinstance(fb_results, Exception):
|
||||
search_errors.append(f"{type(fb_results).__name__}: {fb_results}")
|
||||
continue
|
||||
fb_added += _collect(fb_results)
|
||||
search_errors.extend(intranet.errors)
|
||||
# 让下方 notice 拿到内网接口的原始响应体(解析失败时便于定位)
|
||||
provider = intranet
|
||||
try:
|
||||
fb_results = await intranet_task
|
||||
except BaseException as e: # noqa: BLE001 — 任务内异常/取消都按"无结果"处理
|
||||
fb_results = []
|
||||
search_errors.append(f"{type(e).__name__}: {e}")
|
||||
fb_added = _collect(fb_results)
|
||||
intranet = intranet_holder.get("provider")
|
||||
if intranet is not None:
|
||||
search_errors.extend(getattr(intranet, "errors", []))
|
||||
# 让下方 notice 拿到内网接口的原始响应体(解析失败时便于定位)
|
||||
provider = intranet
|
||||
_emit_skill_step({
|
||||
"type": "skill_result",
|
||||
"message": f"内网知识库接口兜底检索到 {fb_added} 条新素材",
|
||||
"matched_count": fb_added,
|
||||
})
|
||||
else:
|
||||
# 技能已拿到素材:取消并行的内网检索,不再浪费内网接口调用
|
||||
await _cancel_intranet_task()
|
||||
|
||||
# 合并去重
|
||||
# - 首次检索 / 普通 re_search(无输入,用户只想重出):旧素材丢弃,覆盖式替换
|
||||
|
||||
+44
@@ -1,7 +1,51 @@
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
# 复合检索词里常见的「话题/类别后缀」——核心实体后面跟的修饰词。内网 ES 对
|
||||
# 「实体+话题」这类长词常检不出(如「特朗普竞选」0 条),但「实体」本身能检出
|
||||
# (「特朗普」30 条)。剥掉这些后缀回退到核心实体重试,能救回大量检索词。
|
||||
_TOPIC_SUFFIXES = (
|
||||
"竞选", "诉讼", "法律", "政策", "主张", "纲领", "动向", "动态", "影响", "分析",
|
||||
"进展", "现状", "情况", "问题", "事件", "活动", "关系", "布局", "态势", "格局",
|
||||
"趋势", "背景", "历史", "观点", "评价", "报道", "新闻", "研究", "报告", "概况",
|
||||
"走势", "前景", "措施", "方案", "计划", "战略", "改革", "发展",
|
||||
)
|
||||
|
||||
|
||||
def simplify_query(query: str) -> str:
|
||||
"""把一个「检不出」的复合检索词简化成更核心、更短的检索词,供 0 结果时重试。
|
||||
|
||||
规则(无需大模型,纯启发式):
|
||||
1. 多词(含空格)→ 取第一个词(通常是核心实体),如「美国政局 特朗普」→「美国政局」;
|
||||
2. 单词复合 → 反复剥掉结尾的话题后缀,保留核心实体,如「特朗普法律诉讼」→「特朗普」、
|
||||
「特朗普竞选」→「特朗普」。
|
||||
返回空串 = 无法进一步简化(调用方据此不再重试,避免空转)。
|
||||
"""
|
||||
s = (query or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
# 1) 多词:取第一个词
|
||||
if re.search(r"\s", s):
|
||||
first = re.split(r"\s+", s)[0].strip()
|
||||
if first and first != s and len(first) >= 2:
|
||||
return first
|
||||
# 2) 单词复合:循环剥离结尾话题后缀
|
||||
changed = s
|
||||
while True:
|
||||
stripped = changed
|
||||
for suf in sorted(_TOPIC_SUFFIXES, key=len, reverse=True):
|
||||
if stripped.endswith(suf) and len(stripped) - len(suf) >= 2:
|
||||
stripped = stripped[: -len(suf)]
|
||||
break
|
||||
if stripped == changed:
|
||||
break
|
||||
changed = stripped
|
||||
if changed != s and len(changed) >= 2:
|
||||
return changed
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
|
||||
+61
-15
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from .base import BaseSearchProvider, SearchResult
|
||||
from .base import BaseSearchProvider, SearchResult, simplify_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,6 +15,17 @@ _DEFAULT_KB = "samples"
|
||||
_RAW_SNIPPET_LIMIT = 2000
|
||||
|
||||
|
||||
def _pick(d: dict, keys: tuple) -> str:
|
||||
"""从结果项里按候选字段名取第一个非空值(两套接口字段名不同,做并集兼容)。"""
|
||||
for k in keys:
|
||||
v = d.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
if v not in (None, "") and not isinstance(v, (dict, list)):
|
||||
return str(v).strip()
|
||||
return ""
|
||||
|
||||
|
||||
class IntranetSearchProvider(BaseSearchProvider):
|
||||
"""内网知识库搜索接口适配器。
|
||||
|
||||
@@ -27,13 +38,17 @@ class IntranetSearchProvider(BaseSearchProvider):
|
||||
self,
|
||||
base_url: str = "",
|
||||
knowledge_base_name: str = _DEFAULT_KB,
|
||||
timeout: int = 300,
|
||||
timeout: int = 600,
|
||||
**_ignored,
|
||||
):
|
||||
self.endpoint = base_url or _DEFAULT_ENDPOINT
|
||||
self.knowledge_base_name = knowledge_base_name or _DEFAULT_KB
|
||||
# 内网 ES 接口很慢,超时硬编码放宽到 5 分钟(不依赖 env 配置)
|
||||
self.timeout = timeout
|
||||
# 内网 ES 接口很慢,兜底检索超时放宽到 10 分钟(不依赖 env 配置);构造方可传更大值覆盖。
|
||||
# 「咱们先把自己的做好」:我方兜底超时给足,避免因我方过早超时被甩锅成「检不出」。
|
||||
try:
|
||||
self.timeout = max(int(timeout), 1)
|
||||
except (TypeError, ValueError):
|
||||
self.timeout = 600
|
||||
# 收集本次运行的检索异常,供 researcher 节点取出并提示用户(ES 波动等)
|
||||
self.errors: list[str] = []
|
||||
# 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示
|
||||
@@ -49,6 +64,17 @@ class IntranetSearchProvider(BaseSearchProvider):
|
||||
self.raw_responses.append(body)
|
||||
|
||||
async def search(self, query: str, max_results: int = 5, **kwargs) -> List[SearchResult]:
|
||||
results = await self._search_once(query, max_results)
|
||||
# 0 结果兜底:复合检索词常检不出(如「特朗普竞选」),换成更核心的检索词重试一次
|
||||
# (「特朗普竞选」→「特朗普」)。仅在首检为空、且能简化出不同的词时才重试。
|
||||
if not results:
|
||||
simpler = simplify_query(query)
|
||||
if simpler and simpler != query:
|
||||
logger.info("intranet search empty for %r, retry with simplified %r", query, simpler)
|
||||
results = await self._search_once(simpler, max_results)
|
||||
return results
|
||||
|
||||
async def _search_once(self, query: str, max_results: int = 5) -> List[SearchResult]:
|
||||
import httpx
|
||||
|
||||
payload = {
|
||||
@@ -77,11 +103,17 @@ class IntranetSearchProvider(BaseSearchProvider):
|
||||
self._record_raw(raw_text)
|
||||
return []
|
||||
|
||||
# 正常情况下接口直接返回数组;兼容个别情况下包了一层 data/results 的结构
|
||||
# 正常情况下接口直接返回数组;兼容包了一层 data/results 的结构(含工作正常的
|
||||
# knowledge-search-v2:{"success":true,"results":[...]})。
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
items = data.get("data") or data.get("results") or []
|
||||
# 接口显式标失败时把整体回传前端便于定位
|
||||
if data.get("success") is False:
|
||||
self.errors.append(f"接口返回 success=false:{str(data.get('error') or '')[:200]}")
|
||||
self._record_raw(raw_text)
|
||||
return []
|
||||
items = data.get("data") or data.get("results") or data.get("documents") or []
|
||||
else:
|
||||
# 返回结构既不是数组也不是已知 dict 包装:视为异常,把原始响应回传前端
|
||||
logger.warning("intranet search unexpected response type for query=%r: %s", query, type(data).__name__)
|
||||
@@ -91,18 +123,32 @@ class IntranetSearchProvider(BaseSearchProvider):
|
||||
|
||||
results: List[SearchResult] = []
|
||||
for i, item in enumerate(items[:max_results]):
|
||||
md = (item or {}).get("metadata") or {}
|
||||
content = md.get("content") or (item or {}).get("page_content") or ""
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
# 字段名做并集兼容两套接口:
|
||||
# 老 ES: {page_content, metadata:{m_title, content, recUuid, m_publish, source1}}
|
||||
# knowledge-search-v2:{title, content_preview, url, recUuid, source_category, publish_time, relevance_score}
|
||||
md = item.get("metadata") if isinstance(item.get("metadata"), dict) else {}
|
||||
merged = {**md, **item} if md else item
|
||||
content = _pick(merged, ("content", "content_preview", "page_content", "text", "summary"))
|
||||
title = _pick(merged, ("title", "m_title", "name", "doc_title"))
|
||||
if not content and not title:
|
||||
continue
|
||||
try:
|
||||
score = float(merged.get("relevance_score"))
|
||||
except (TypeError, ValueError):
|
||||
# 接口未返回相关度分值:按返回顺序赋递减分以保留排序
|
||||
score = max(0.5, 0.95 - i * 0.03)
|
||||
results.append(SearchResult(
|
||||
title=str(md.get("m_title") or "").strip() or "(无标题)",
|
||||
title=title or "(无标题)",
|
||||
content=str(content)[:500],
|
||||
source=str(md.get("source1") or "").strip() or "内网知识库",
|
||||
published_at=str(md.get("m_publish") or "").strip(),
|
||||
url="", # 内网素材的原文地址由前端依据 recUuid 拼接
|
||||
# 接口未返回相关度分值,按 ES 返回顺序赋递减分以保留排序
|
||||
relevance_score=max(0.5, 0.95 - i * 0.03),
|
||||
source=_pick(merged, ("source_category", "source1", "source", "source_name", "kb_name")) or "内网知识库",
|
||||
published_at=_pick(merged, ("publish_time", "m_publish", "published_at", "date")),
|
||||
# 工作接口直接给了 url;老 ES 没有 url(由前端依据 recUuid 拼接),保持空串
|
||||
url=_pick(merged, ("url",)),
|
||||
relevance_score=score,
|
||||
source_type="intranet",
|
||||
recUuid=str(md.get("recUuid") or "").strip(),
|
||||
recUuid=_pick(merged, ("recUuid", "rec_uuid", "id")),
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"""Add login_params_json column to task_buttons.
|
||||
|
||||
按钮管理「外部链接」按钮新增「追加登录参数」能力(与应用管理一致):``url`` 外链在
|
||||
拼好地址后,可再依次追加 ``?{key}={value}``,``value`` 由前端运行时从 ``userInfo``
|
||||
按 ``source`` 解析(``other`` 时取字面 ``custom_value``)。整列以 JSON 存一个
|
||||
``[{key, source, custom_value}, ...]`` 列表,默认空 → 旧按钮行为不变。
|
||||
|
||||
ORM: ``TaskButtonRow.login_params_json = PortableJSON()``(SQLite/PG 原生 JSON,
|
||||
MySQL 落 TEXT)。``create_all`` 给全新库建列;已有库由启动期
|
||||
``_ensure_orm_columns_sync`` 补列。本 revision 保持 alembic 图一致并留档。
|
||||
|
||||
Revision ID: 20260625_01
|
||||
Revises: 20260620_02
|
||||
Create Date: 2026-06-25
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260625_01"
|
||||
down_revision = "20260620_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:
|
||||
if _has_table("task_buttons") and not _has_column("task_buttons", "login_params_json"):
|
||||
op.add_column(
|
||||
"task_buttons",
|
||||
sa.Column("login_params_json", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("task_buttons", "login_params_json"):
|
||||
op.drop_column("task_buttons", "login_params_json")
|
||||
+1
@@ -19,6 +19,7 @@ def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
|
||||
"id_param": str(data.get("id_param") or "id"),
|
||||
"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 []),
|
||||
"enabled": bool(data.get("enabled", True)),
|
||||
"sort_order": int(data.get("sort_order", 0)),
|
||||
"updated_by": updated_by,
|
||||
|
||||
+6
-1
@@ -29,7 +29,7 @@ from sqlalchemy import Boolean, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableJSON
|
||||
|
||||
|
||||
class TaskButtonRow(Base):
|
||||
@@ -48,6 +48,11 @@ class TaskButtonRow(Base):
|
||||
id_kind: Mapped[str | None] = mapped_column(String(16), default="task")
|
||||
# ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。可空,读时归一化为 ``blank``。
|
||||
open_mode: Mapped[str | None] = mapped_column(String(16), default="blank")
|
||||
# ``url`` 外链追加的登录参数(与应用管理一致):在拼好的地址后再依次追加
|
||||
# ``?{key}={value}``,``value`` 由前端运行时从 ``userInfo`` 按 ``source`` 解析
|
||||
# (``other`` 时取 ``custom_value`` 字面值)。每项 ``{key, source, custom_value}``。
|
||||
# 旧行 / NULL 读时归一化为 ``[]``。
|
||||
login_params_json: Mapped[list] = mapped_column(PortableJSON(), default=list)
|
||||
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))
|
||||
|
||||
+3
@@ -21,6 +21,8 @@ def _to_dict(row: TaskButtonRow) -> dict[str, Any]:
|
||||
data["id_param"] = data.get("id_param") or "id"
|
||||
data["id_kind"] = data.get("id_kind") or "task"
|
||||
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 []
|
||||
return data
|
||||
|
||||
|
||||
@@ -35,6 +37,7 @@ def _row_from(data: dict[str, Any], updated_by: str | None) -> TaskButtonRow:
|
||||
id_param=str(data.get("id_param") or "id"),
|
||||
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 []),
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
sort_order=int(data.get("sort_order", 0)),
|
||||
updated_by=updated_by,
|
||||
|
||||
@@ -117,6 +117,12 @@ CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFA
|
||||
-- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。
|
||||
CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
|
||||
|
||||
-- ---- 按钮管理「外部链接」追加登录参数,对应 migration 20260625_01 ----------------
|
||||
-- login_params_json: url 外链追加的登录参数列表 JSON([{key,source,custom_value},...]),
|
||||
-- 由前端运行时从 userInfo 解析后拼到地址尾部;NULL/空 = 不追加(旧行为)。
|
||||
-- ORM: TaskButtonRow.login_params_json = PortableJSON()(MySQL 落 TEXT)。
|
||||
CALL _add_column_if_missing('task_buttons', 'login_params_json', 'TEXT NULL DEFAULT NULL');
|
||||
|
||||
-- ---- 清理临时存储过程 --------------------------------------------------------
|
||||
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||
|
||||
|
||||
Reference in new issue
Block a user