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:
吉鑫升 committed 2026-08-24 14:21:19 +08:00
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,
};
}
+51 -5
View File
@@ -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) {
+41 -16
View File
@@ -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 决定