Merge remote-tracking branch 'origin/master' into ji
# Conflicts: # .gitignore # frontend-web/src/components/workspace/artifacts/context.tsx # frontend-web/src/components/workspace/messages/message-group.tsx # offline-backend-20260512/backend/CLAUDE.md # offline-backend-20260512/backend/README.md # offline-backend-20260512/backend/app/gateway/app.py # offline-backend-20260512/backend/app/gateway/deps.py # offline-backend-20260512/backend/app/gateway/routers/llmwiki.py
This commit is contained in:
commit
e818c24769
262 files changed
+29527
-5236
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
HOST_NAVIGATE_TYPE,
|
||||
buildHostNavigateMessage,
|
||||
isHostNavigatePath,
|
||||
parseHostTaskId,
|
||||
resolveHostNavigateOrigin,
|
||||
} from "./host-navigate";
|
||||
|
||||
test("isHostNavigatePath accepts a host route", () => {
|
||||
assert.equal(isHostNavigatePath("/task/item-aiceh/step-analysis-audience"), true);
|
||||
assert.equal(isHostNavigatePath(" /task/foo "), true);
|
||||
});
|
||||
|
||||
test("isHostNavigatePath rejects urls and protocol-relative paths", () => {
|
||||
assert.equal(isHostNavigatePath(""), false);
|
||||
assert.equal(isHostNavigatePath("https://localhost:4912/task"), false);
|
||||
assert.equal(isHostNavigatePath("//evil.example/task"), false);
|
||||
assert.equal(isHostNavigatePath("task/foo"), false);
|
||||
assert.equal(isHostNavigatePath("/task\\foo"), false);
|
||||
});
|
||||
|
||||
test("parseHostTaskId returns a number", () => {
|
||||
assert.equal(parseHostTaskId("108"), 108);
|
||||
assert.equal(parseHostTaskId(" 108 "), 108);
|
||||
assert.equal(parseHostTaskId("0108"), 108);
|
||||
});
|
||||
|
||||
test("parseHostTaskId rejects non-integers", () => {
|
||||
assert.equal(parseHostTaskId(""), null);
|
||||
assert.equal(parseHostTaskId("abc"), null);
|
||||
assert.equal(parseHostTaskId("108.5"), null);
|
||||
assert.equal(parseHostTaskId("12e3"), null);
|
||||
assert.equal(parseHostTaskId(null), null);
|
||||
});
|
||||
|
||||
test("resolveHostNavigateOrigin prefers configured origin", () => {
|
||||
assert.equal(
|
||||
resolveHostNavigateOrigin({
|
||||
configured: "https://localhost:4912",
|
||||
ancestorOrigin: "https://other.example",
|
||||
referrer: "https://referrer.example/page",
|
||||
selfOrigin: "http://localhost:5174",
|
||||
}),
|
||||
"https://localhost:4912",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostNavigateOrigin strips a path from configured origin", () => {
|
||||
assert.equal(
|
||||
resolveHostNavigateOrigin({ configured: "https://localhost:4912/app" }),
|
||||
"https://localhost:4912",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostNavigateOrigin falls back to ancestorOrigins then referrer", () => {
|
||||
assert.equal(
|
||||
resolveHostNavigateOrigin({
|
||||
ancestorOrigin: "https://host.example",
|
||||
referrer: "https://referrer.example/",
|
||||
selfOrigin: "http://localhost:5174",
|
||||
}),
|
||||
"https://host.example",
|
||||
);
|
||||
assert.equal(
|
||||
resolveHostNavigateOrigin({
|
||||
referrer: "https://referrer.example/page",
|
||||
selfOrigin: "http://localhost:5174",
|
||||
}),
|
||||
"https://referrer.example",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostNavigateOrigin never uses * or same-origin referrer", () => {
|
||||
assert.equal(resolveHostNavigateOrigin({ configured: "*" }), null);
|
||||
assert.equal(
|
||||
resolveHostNavigateOrigin({
|
||||
referrer: "http://localhost:5174/page/foo",
|
||||
selfOrigin: "http://localhost:5174",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveHostNavigateOrigin({}), null);
|
||||
});
|
||||
|
||||
test("buildHostNavigateMessage matches the host contract", () => {
|
||||
assert.deepEqual(
|
||||
buildHostNavigateMessage("/task/item-aiceh/step-analysis-audience", { id: 108 }),
|
||||
{
|
||||
type: HOST_NAVIGATE_TYPE,
|
||||
path: "/task/item-aiceh/step-analysis-audience",
|
||||
queryParams: { id: 108 },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* iframe 嵌入宿主系统时,任务跳转按钮通过 ``window.parent.postMessage`` 让
|
||||
* **父页面**改路由(宿主协议 ``HOST_NAVIGATE``)。不改 iframe 自己的地址。
|
||||
*
|
||||
* 消息形状对齐宿主约定:
|
||||
* ``{ type: 'HOST_NAVIGATE', path, queryParams: { id: number } }``
|
||||
* 第二参必须是宿主 origin,禁止 ``'*'``。
|
||||
*/
|
||||
|
||||
export const HOST_NAVIGATE_TYPE = "HOST_NAVIGATE";
|
||||
|
||||
export interface HostNavigateMessage {
|
||||
type: typeof HOST_NAVIGATE_TYPE;
|
||||
path: string;
|
||||
queryParams: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 宿主路由:以单个 ``/`` 开头的路径,不含 ``//`` / 反斜杠。 */
|
||||
export function isHostNavigatePath(path: string): boolean {
|
||||
const value = path.trim();
|
||||
if (!value || value.length > 2048) return false;
|
||||
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 任务 id 转数字。只接受整数字符串(可带正负号),否则返回 null。 */
|
||||
export function parseHostTaskId(raw: string | null | undefined): number | null {
|
||||
const value = (raw ?? "").trim();
|
||||
if (!value || !/^-?\d+$/.test(value)) return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function normalizeOrigin(value: string | null | undefined): string | null {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw || raw === "*") return null;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 postMessage 的 targetOrigin。
|
||||
* 优先级:显式配置 → ``ancestorOrigins[0]`` → 跨源 ``document.referrer``。
|
||||
* 解析失败返回 null(调用方应中止,不得退回 ``'*'``)。
|
||||
*/
|
||||
export function resolveHostNavigateOrigin(input: {
|
||||
configured?: string | null;
|
||||
ancestorOrigin?: string | null;
|
||||
referrer?: string | null;
|
||||
selfOrigin?: string | null;
|
||||
}): string | null {
|
||||
const configured = normalizeOrigin(input.configured);
|
||||
if (configured) return configured;
|
||||
const ancestor = normalizeOrigin(input.ancestorOrigin);
|
||||
if (ancestor) return ancestor;
|
||||
const referrer = normalizeOrigin(input.referrer);
|
||||
if (!referrer) return null;
|
||||
const selfOrigin = normalizeOrigin(input.selfOrigin);
|
||||
if (selfOrigin && referrer === selfOrigin) return null;
|
||||
return referrer;
|
||||
}
|
||||
|
||||
export function readAncestorOrigin(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.location.ancestorOrigins?.[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmbeddedInParent(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.parent !== window;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildHostNavigateMessage(
|
||||
path: string,
|
||||
queryParams: Record<string, number> = {},
|
||||
): HostNavigateMessage {
|
||||
return {
|
||||
type: HOST_NAVIGATE_TYPE,
|
||||
path,
|
||||
queryParams,
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,11 @@
|
||||
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
|
||||
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
|
||||
*
|
||||
* 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
|
||||
* 按钮类型(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
|
||||
* 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/
|
||||
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
|
||||
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)/
|
||||
* ``purpose`` 弹出任务目的树 / ``host`` iframe 内向父页面 ``postMessage({ type:'HOST_NAVIGATE' })``
|
||||
* 让宿主系统跳转(见 [[host-navigate]])。
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -25,6 +27,15 @@ import {
|
||||
readLoginToken,
|
||||
readLoginUsername,
|
||||
} from "@/core/auth/login-credentials";
|
||||
import {
|
||||
buildHostNavigateMessage,
|
||||
isEmbeddedInParent,
|
||||
isHostNavigatePath,
|
||||
parseHostTaskId,
|
||||
readAncestorOrigin,
|
||||
resolveHostNavigateOrigin,
|
||||
} from "@/core/auth/host-navigate";
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
|
||||
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
|
||||
@@ -62,8 +73,8 @@ export const TASK_BUSINESSES: {
|
||||
},
|
||||
];
|
||||
|
||||
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格(无需 target)。 */
|
||||
export type TaskButtonLinkType = "url" | "business" | "purpose";
|
||||
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格 / ``host``=宿主路由(postMessage)。 */
|
||||
export type TaskButtonLinkType = "url" | "business" | "purpose" | "host";
|
||||
|
||||
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
|
||||
export type TaskButtonIdKind = "task" | "action";
|
||||
@@ -91,7 +102,7 @@ export interface TaskButton {
|
||||
business: TaskBusiness;
|
||||
label: string;
|
||||
linkType: TaskButtonLinkType;
|
||||
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */
|
||||
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``);``host``=宿主系统路由(如 ``/task/...``)。 */
|
||||
target: string;
|
||||
/** ``url`` 类型是否追加 id 查询参数。 */
|
||||
appendTaskId: boolean;
|
||||
@@ -316,11 +327,46 @@ export interface RunTaskButtonCtx {
|
||||
* 执行一个跳转按钮:
|
||||
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
|
||||
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
|
||||
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
|
||||
*/
|
||||
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 === "host") {
|
||||
const path = btn.target.trim();
|
||||
if (!isHostNavigatePath(path)) {
|
||||
fail(`未配置「${btn.label}」的宿主路由`);
|
||||
return;
|
||||
}
|
||||
if (!isEmbeddedInParent()) {
|
||||
fail("当前不在嵌入页,无法跳转宿主系统");
|
||||
return;
|
||||
}
|
||||
const origin = resolveHostNavigateOrigin({
|
||||
configured: readRuntimeEnv("VITE_HOST_NAVIGATE_ORIGIN"),
|
||||
ancestorOrigin: readAncestorOrigin(),
|
||||
referrer: typeof document !== "undefined" ? document.referrer : null,
|
||||
selfOrigin: typeof window !== "undefined" ? window.location.origin : null,
|
||||
});
|
||||
if (!origin) {
|
||||
fail("未配置宿主地址(VITE_HOST_NAVIGATE_ORIGIN),无法安全跳转");
|
||||
return;
|
||||
}
|
||||
const queryParams: Record<string, number> = {};
|
||||
if (btn.appendTaskId) {
|
||||
const id = parseHostTaskId(ctx.taskId);
|
||||
if (id === null) {
|
||||
fail("任务id不是数字,无法跳转宿主系统");
|
||||
return;
|
||||
}
|
||||
queryParams[(btn.idParam || "id").trim() || "id"] = id;
|
||||
}
|
||||
const message = buildHostNavigateMessage(path, queryParams);
|
||||
console.info(`[task-button] 「${btn.label}」宿主路由 →`, message, origin);
|
||||
window.parent.postMessage(message, origin);
|
||||
return;
|
||||
}
|
||||
if (btn.linkType === "url") {
|
||||
const base = btn.target.trim();
|
||||
if (!base) {
|
||||
|
||||
@@ -95,25 +95,41 @@ function mockCopTaskDetail(taskId: string): CopTaskDetail {
|
||||
};
|
||||
}
|
||||
|
||||
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法时返回 null。 */
|
||||
/** 详情接口不可用时仍能继续深链跳转:至少带上任务 id,标题/简介可空。 */
|
||||
export function stubCopTaskDetail(taskId: string): CopTaskDetail {
|
||||
return {
|
||||
id: taskId,
|
||||
overview: "",
|
||||
taskDirection: "",
|
||||
taskName: "",
|
||||
taskContent: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法 / 网络失败时返回 null。 */
|
||||
export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
|
||||
if (copTaskDetailMockEnabled()) {
|
||||
return mockCopTaskDetail(taskId);
|
||||
}
|
||||
const base = consumerBaseUrl();
|
||||
if (!base || !taskId) return null;
|
||||
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
|
||||
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
|
||||
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
return {
|
||||
id: (d.id as string | number | null) ?? null,
|
||||
overview: typeof d.overview === "string" ? d.overview : "",
|
||||
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
|
||||
taskName: typeof d.taskName === "string" ? d.taskName : "",
|
||||
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
|
||||
};
|
||||
try {
|
||||
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
|
||||
const body = await requestCrudService.get(`${base}/taskAnalyseSearch/cop-task-detail`, { taskId });
|
||||
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
return {
|
||||
id: (d.id as string | number | null) ?? null,
|
||||
overview: typeof d.overview === "string" ? d.overview : "",
|
||||
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
|
||||
taskName: typeof d.taskName === "string" ? d.taskName : "",
|
||||
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] cop-task-detail failed", { taskId, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */
|
||||
@@ -223,7 +239,12 @@ export async function fetchTaskIdFromAction(actionId: string): Promise<string> {
|
||||
export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise<string> {
|
||||
const parsed = parseGoPath(goPath);
|
||||
if (parsed?.businessCode === XDFX_BUSINESS_CODE) {
|
||||
return fetchTaskIdFromAction(rawId);
|
||||
try {
|
||||
return (await fetchTaskIdFromAction(rawId)) || "";
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] action→taskId resolve failed", { rawId, error });
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return rawId;
|
||||
}
|
||||
@@ -332,10 +353,14 @@ export async function resolveTaskDeeplinkTarget(
|
||||
try {
|
||||
const mappings = await listBusinessMappings();
|
||||
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn("[task-deeplink] business mapping failed", error);
|
||||
agentId = null;
|
||||
}
|
||||
if (!agentId) return null;
|
||||
if (!agentId) {
|
||||
console.warn("[task-deeplink] no agent mapped for", parsed.businessCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表):
|
||||
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
|
||||
|
||||
Reference in new issue
Block a user