Merge branch 'ji' into master

# Conflicts:
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/app/gateway/deps.py
This commit is contained in:
吉鑫升 committed 2026-06-18 16:36:30 +08:00
commit 77aa895a70
30 files changed
+1710 -176

No files matched your search

+246
View File
@@ -0,0 +1,246 @@
/**
* 任务工作区(任务深链 rwfx / 3qfx / xdfx,见 ``TaskFxWorkspace``)左侧栏的
* **可配置跳转按钮**。
*
* 数据源优先级:后端 ``/api/task-buttons``(全局共享、admin 整体替换)。**仅当整张表
* 为空(全新部署、从未保存过)**时回退到代码种子默认(``defaultTaskButtons``,从实时
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
*
* 按钮两类(``linkType``):``url`` 外链(``window.open``,可选追加 ``?id=任务id``)/
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
*/
import {
appendQueryParam,
type TaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import {
readCopTaskDetail,
resolveTaskDeeplinkTarget,
} from "@/core/auth/task-deeplink";
/** 任务工作区业务码(与路由 taskBase 一一对应)。 */
export type TaskBusiness = "rwfx" | "3qfx" | "xdfx";
/** 业务元数据:每个 = 一个任务深链页面。``goPath`` 为深链 ``?goPath=`` 值;``desc`` 给管理页说明。 */
export const TASK_BUSINESSES: {
code: TaskBusiness;
/** 页面名(tab 主标题)。 */
name: string;
/** 深链 goPath 值。 */
goPath: string;
/** 页面说明。 */
desc: string;
}[] = [
{
code: "rwfx",
name: "任务分析",
goPath: "rwfx",
desc: "完整版任务工作区(带目的树闸门),通过 ?goPath=rwfx 进入。",
},
{
code: "3qfx",
name: "三情分析",
goPath: "3qfx",
desc: "精简版工作区(无目的树闸门),通过 ?goPath=3qfx 进入。",
},
{
code: "xdfx",
name: "行动分析",
goPath: "xdfx",
desc: "精简版工作区,通过 ?goPath=xdfx 进入(深链 taskId 实为行动id)。",
},
];
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格(无需 target)。 */
export type TaskButtonLinkType = "url" | "business" | "purpose";
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
export type TaskButtonIdKind = "task" | "action";
/** 一个可配置跳转按钮。 */
export interface TaskButton {
id: string;
business: TaskBusiness;
label: string;
linkType: TaskButtonLinkType;
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */
target: string;
/** ``url`` 类型是否追加 id 查询参数。 */
appendTaskId: boolean;
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
idParam: string;
/**
* ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=``
* 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
*/
idKind: TaskButtonIdKind;
enabled: boolean;
sortOrder: number;
}
/** 内部切换可选的目标业务(``business`` 类型按钮的下拉项)。 */
export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [
{ code: "3qfx", label: "三情分析" },
{ code: "rwfx", label: "任务分析" },
{ code: "xdfx", label: "行动分析" },
];
/**
* 代码种子默认按钮(仅在后端整张表为空时使用):
* - rwfx:历史 4 个按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+ 切换到三情/行动分析;
* - 3qfx / xdfx:互相切换到另外两个业务。
* 即「三个业务都带切换按钮」。已保存过则用页面「导入默认」把缺失项补进来。
*/
export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
const sw = (
id: string,
business: TaskBusiness,
label: string,
target: TaskBusiness,
sortOrder: number,
): TaskButton => ({
id,
business,
label,
linkType: "business",
target,
appendTaskId: false,
idParam: "id",
idKind: "task",
enabled: true,
sortOrder,
});
// 目的树弹框按钮(无需 target)。
const pz = (id: string, business: TaskBusiness, sortOrder: number): TaskButton => ({
id,
business,
label: "弹框查看",
linkType: "purpose",
target: "",
appendTaskId: false,
idParam: "id",
idKind: "task",
enabled: true,
sortOrder,
});
return [
// rwfx(任务分析):外链 + 切换
{
id: "rwfx-edit",
business: "rwfx",
label: "编辑",
linkType: "url",
target: cfg.treemapEditUrl,
appendTaskId: true,
idParam: "id",
idKind: "task",
enabled: true,
sortOrder: 0,
},
{
id: "rwfx-next",
business: "rwfx",
label: "下一步",
linkType: "url",
target: cfg.treemapNextUrl,
appendTaskId: true,
idParam: "id",
idKind: "task",
enabled: true,
sortOrder: 1,
},
{
id: "rwfx-detail3q",
business: "rwfx",
label: "3q详情",
linkType: "url",
target: cfg.detail3qUrl,
appendTaskId: true,
idParam: "id",
idKind: "task",
enabled: true,
sortOrder: 2,
},
sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 3),
sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 4),
pz("rwfx-purpose", "rwfx", 5),
// 3qfx(三情分析):切换到任务/行动分析
sw("3qfx-switchrw", "3qfx", "切换到任务分析", "rwfx", 0),
sw("3qfx-switchxd", "3qfx", "切换到行动分析", "xdfx", 1),
// xdfx(行动分析):切换到任务/三情分析 + 弹框查看
sw("xdfx-switchrw", "xdfx", "切换到任务分析", "rwfx", 0),
sw("xdfx-switch3q", "xdfx", "切换到三情分析", "3qfx", 1),
pz("xdfx-purpose", "xdfx", 2),
];
}
/**
* 实际生效的按钮列表:后端有任意一条记录时即以后端为准;整张表为空才回退种子默认。
* (与 [[敏感词管理]] 的种子语义一致:空=从未配置→用种子;非空=已配置→后端说了算,
* 哪怕某业务被清空也不回退。)
*/
export function resolveTaskButtons(
stored: TaskButton[],
cfg: TaskDeeplinkConfig,
): TaskButton[] {
return stored.length > 0 ? stored : defaultTaskButtons(cfg);
}
/** 取某业务下、启用的按钮,按 sortOrder 升序。 */
export function buttonsForBusiness(
buttons: TaskButton[],
business: TaskBusiness,
): TaskButton[] {
return buttons
.filter((b) => b.business === business && b.enabled)
.sort((a, b) => a.sortOrder - b.sortOrder);
}
export interface RunTaskButtonCtx {
/** 任务id(xdfx 为 action 接口解析后的真实任务id;其它业务即深链原始 id)。 */
taskId: string;
/** 行动id(xdfx 深链原始 ?taskId=;其它业务通常等于 taskId)。 */
actionId?: string;
navigate: (path: string, opts?: { state?: Record<string, unknown> }) => void;
/** 跳转/打开失败时的提示回调(默认 noop)。 */
onError?: (message: string) => void;
}
/**
* 执行一个跳转按钮:
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
*/
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
const fail = (m: string) => ctx.onError?.(m);
// purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。
if (btn.linkType === "purpose") return;
if (btn.linkType === "url") {
const base = btn.target.trim();
if (!base) {
fail(`未配置「${btn.label}」的跳转地址`);
return;
}
// 行动分析(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;
window.open(url, "_blank", "noopener,noreferrer");
return;
}
// business:内部切换到另一业务
const detail = readCopTaskDetail();
if (!detail || !ctx.taskId) {
fail("缺少任务信息,无法跳转");
return;
}
try {
const target = await resolveTaskDeeplinkTarget(btn.target, detail, ctx.taskId);
if (target) ctx.navigate(target.path, { state: target.state });
else fail(`未配置「${btn.label}」对应的业务智能体`);
} catch {
fail("跳转失败,请稍后再试");
}
}
@@ -19,6 +19,8 @@ export interface TaskDeeplinkConfig {
test: boolean;
/** 删除接口走假数据(该接口未开发完时打开)。 */
deleteTest: boolean;
/** 行动→任务解析走假数据(xdfx 行动id→任务id)。 */
actionTest: boolean;
/** 目的树查询完整 URL(调用追加 ?taskId=)。 */
purposeDetailUrl: string;
/** 删除完整 URL(调用追加 ?taskId=)。 */
@@ -27,10 +29,16 @@ export interface TaskDeeplinkConfig {
treemapEditUrl: string;
/** B 系统树形下一步页 URL(跳转追加 ?id=)。 */
treemapNextUrl: string;
/** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}。 */
/** 行动详情接口基址(xdfx 行动→任务):调用追加 /{actionId},返回 data.taskId 为真正任务id。 */
actionDetailUrl: string;
/** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */
detail3qUrl: string;
/** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success};
* actionDetail = 原始 {state,msg,data:{taskId,...}}。 */
mock: {
purposeDetail?: unknown;
delete?: { message?: string; success?: boolean };
actionDetail?: unknown;
};
}
@@ -38,10 +46,13 @@ export interface TaskDeeplinkConfig {
export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = {
test: false,
deleteTest: false,
actionTest: false,
purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail",
deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete",
treemapEditUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit",
treemapNextUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next",
actionDetailUrl: "https://ch1.b.uat.4.cn/consumer/plan/action",
detail3qUrl: "",
mock: {},
};
@@ -55,16 +66,21 @@ function normalize(body: unknown): TaskDeeplinkConfig {
return {
test: o.test === true,
deleteTest: o.delete_test === true,
actionTest: o.action_test === true,
purposeDetailUrl: str(o.purpose_detail_url, d.purposeDetailUrl),
deleteUrl: str(o.delete_url, d.deleteUrl),
treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl),
treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl),
actionDetailUrl: str(o.action_detail_url, d.actionDetailUrl),
// detail_3q_url 允许为空(地址待定),不回退默认;只在配了非空时才生效。
detail3qUrl: typeof o.detail_3q_url === "string" ? o.detail_3q_url : d.detail3qUrl,
mock: {
purposeDetail: mock.purpose_detail,
delete:
mock.delete && typeof mock.delete === "object"
? (mock.delete as { message?: string; success?: boolean })
: undefined,
actionDetail: mock.action_detail,
},
};
}
+58 -23
View File
@@ -17,10 +17,11 @@
import { requestCrudService } from "@/service/request-axios-service";
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
/** rwfx 业务码:单智能体走「任务工作区 + 目的树闸门」(其它业务保持原问答逻辑)。 */
const RWFX_BUSINESS_CODE = "6BF";
/** xdfx 业务码:深链传入的是「行动id」,需先调 action 接口解析出真正的「任务id」。 */
const XDFX_BUSINESS_CODE = "7BF";
/** consumer 接口返回的任务详情(data 字段)。 */
export interface CopTaskDetail {
@@ -110,6 +111,44 @@ export function readCopTaskDetail(): CopTaskDetail | null {
}
}
/**
* xdfx 深链:用「行动id」调行动详情接口 ``{action_detail_url}/{actionId}``,取出 ``data.taskId``
* 作为真正的「任务id」。``action_test=true`` 时直接用后端配置里的假数据(接口未通时用)。
* 缺地址 / 返回不合法 → 返回 ""(调用方据此回退)。
*/
export async function fetchTaskIdFromAction(actionId: string): Promise<string> {
if (!actionId) return "";
const cfg = await fetchTaskDeeplinkConfig();
let body: unknown;
if (cfg.actionTest) {
body = cfg.mock.actionDetail;
} else if (cfg.actionDetailUrl) {
const base = cfg.actionDetailUrl.replace(/\/+$/, "");
// service 拦截器已统一注入 Authorization: Bearer admin 并返回整包 body;行动id 走路径段。
body = await requestCrudService.get(`${base}/${encodeURIComponent(actionId)}`);
} else {
return "";
}
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return "";
const taskId = (data as Record<string, unknown>).taskId;
return taskId != null && taskId !== "" ? String(taskId) : "";
}
/**
* 据 goPath 把深链 URL 里的原始 id 解析成真正的「任务id」:
* - xdfx(行动分析):URL 传入的是「行动id」,调 action 接口换成 ``data.taskId``;
* 解析失败返回 ""(让调用方回退默认首页,避免拿行动id当任务id污染后续查询/绑定)。
* - 其它业务:URL 传入的本就是任务id,原样返回。
*/
export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise<string> {
const parsed = parseGoPath(goPath);
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
return fetchTaskIdFromAction(rawId);
}
return rawId;
}
export interface TaskDeeplinkTarget {
path: string;
/** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */
@@ -135,20 +174,23 @@ function buildOpeningText(detail: CopTaskDetail, taskId: string): string {
* ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。
* 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。
*
* @param taskId 深链 URL 的原始 ``?taskId=``(外部任务 id,用于按任务隔离草稿;与
* consumer 返回的 detail.id 区分——后者不保证等于外部任务 id)。
* @param taskId 真实任务 id(xdfx 已由 action 接口解析;其它业务即深链原始 ?taskId=)。
* 用于按任务隔离草稿/对话;与 consumer 返回的 detail.id 区分。
* @param rawId 深链 URL 的原始 ``?taskId=``(xdfx 为「行动id」)。供需追加行动id 的按钮用;
* 省略时回退 taskId。
*/
export async function resolveTaskDeeplinkTarget(
goPath: string,
detail: CopTaskDetail,
taskId: string,
rawId?: string,
): Promise<TaskDeeplinkTarget | null> {
const parsed = parseGoPath(goPath);
if (!parsed) return null;
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
if (!taskBase) return null;
// 清掉上一次深链可能残留的任务上下文(仅 rwfx 单智能体会重新写入)。
// 清掉上一次深链可能残留的任务上下文(单智能体分支会用本次任务重新写入)。
clearTaskContext();
if (parsed.roundtable) {
@@ -177,22 +219,15 @@ export async function resolveTaskDeeplinkTarget(
}
if (!agentId) return null;
if (parsed.businessCode === RWFX_BUSINESS_CODE) {
// rwfx:走「任务工作区」——暂存任务上下文(taskId + 开场信息),跳到 chats 索引,
// 由工作区按 taskId 决定开最新历史对话 / 新建(新建走目的树闸门)。不设 pendingAgentMessage:
// 开场信息在闸门通过后由目的树面板发出。
persistTaskContext({ taskId, agentId, openingText: buildOpeningText(detail, taskId) });
return { path: `${taskBase}/agent/${agentId}/chats` };
}
// 其它单智能体问答:保持原逻辑(pendingAgentMessage 自动发出 + chats/new)。
try {
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({ agentId, text: buildOpeningText(detail, taskId) }),
);
} catch {
/* noop */
}
return { path: `${taskBase}/agent/${agentId}/chats/new` };
// 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表):
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
// 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表,
// 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。
persistTaskContext({
taskId,
agentId,
openingText: buildOpeningText(detail, taskId),
actionId: rawId || taskId,
});
return { path: `${taskBase}/agent/${agentId}/chats` };
}
@@ -33,6 +33,8 @@ export interface TaskContext {
agentId: string;
/** 自动发出的开场信息(任务名称/内容)。 */
openingText: string;
/** 深链原始 id(xdfx 为「行动id」,需追加行动id 的按钮用;其它业务等于 taskId)。 */
actionId?: string;
}
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
@@ -190,6 +192,12 @@ export function buildTreemapNextUrl(taskId: string): string {
return url && taskId ? appendQueryParam(url, "id", taskId) : "";
}
/** 「3q详情」详情页地址(目的树面板链接,新标签打开);追加 ?id=任务id。地址未配置时返回 ""。 */
export function buildDetail3qUrl(taskId: string): string {
const url = getCachedTaskDeeplinkConfig().detail3qUrl;
return url && taskId ? appendQueryParam(url, "id", taskId) : "";
}
/* --------------------------- 任务上下文暂存 --------------------------- */
export function persistTaskContext(ctx: TaskContext): void {
@@ -210,6 +218,7 @@ export function readTaskContext(): TaskContext | null {
taskId: String(parsed.taskId),
agentId: String(parsed.agentId),
openingText: typeof parsed.openingText === "string" ? parsed.openingText : "",
actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined,
};
} catch {
return null;
@@ -4,19 +4,27 @@
* 新建对话时由 [[task-purpose]] 流程给 thread 打 ``metadata.taskId`` 标签
* (见 useThreadStream 的 ``threadMetadata``);这里复用服务端分页搜索按该标签过滤,
* 后端默认按 ``updated_at desc`` 排序,所以 ``threads[0]`` 即最新一条。
*
* 同一个 taskId 可能挂在多条业务链上(rwfx / 3qfx / xdfx 共用任务 id),每条业务用各自的
* 智能体。传入 ``agentId`` 时额外按 ``metadata.agent_id`` 过滤(后端 metadata 过滤是 AND/子集
* 匹配),把不同业务的对话列表隔离开——否则三种业务的对话会混在一起。
*/
import { useThreadsPage } from "@/core/threads/hooks";
import type { AgentThread } from "@/core/threads/types";
export function useTaskThreads(taskId: string | null | undefined) {
export function useTaskThreads(
taskId: string | null | undefined,
agentId?: string | null | undefined,
) {
const enabled = !!taskId;
// 任务工作区只在有 taskId 时渲染;无 taskId 时传一个不可能命中的过滤,返回空列表。
const query = useThreadsPage({
page: 1,
pageSize: 100,
metadata: enabled ? { taskId } : { taskId: "__none__" },
});
const metadata = enabled
? agentId
? { taskId, agent_id: agentId }
: { taskId }
: { taskId: "__none__" };
const query = useThreadsPage({ page: 1, pageSize: 100, metadata });
const threads: AgentThread[] = enabled ? (query.data?.threads ?? []) : [];
return {
...query,