增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。

修复 MySQL 读写分离下的持久化与 SSE 重连问题,报告完成前先落库;8BF 嵌入页支持 taskId 任务卡与独立意图 metadata;前端统一 createClientId 并更新 runtime 配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-17 18:16:05 +08:00
1 parent 47faa9bcdf
commit 771957429a
36 files changed
+2038 -300

No files matched your search

@@ -54,6 +54,7 @@ import type {
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { createClientId } from "@/lib/client-id";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return createClientId("shortcut");
}
function newShortcutParam(): CompactShortcutRouteParam {
@@ -43,6 +43,7 @@ import {
customThemeTextContrast,
type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme";
import { createClientId } from "@/lib/client-id";
import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12;
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
];
function makeThemeId(): `custom-theme-${string}` {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
return `custom-theme-${suffix}`;
}
@@ -6,6 +6,7 @@ import type {
DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
import { createClientId } from "@/lib/client-id";
export type DocumentRewritePresentation = {
id: string;
@@ -74,10 +75,7 @@ const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
return createClientId("drw-ui");
}
export function DocumentRewritePresentationProvider({
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { runSentimentAgent, toAgUiMessages } from "./api";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.sentiment-agent.";
@@ -53,9 +54,9 @@ export function useSentimentAgentChat(threadId: string) {
const question = query.trim();
if (!question) return;
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
const assistantId = createClientId("sentiment-ai");
const userMessage: Message = {
id: `sentiment-human-${crypto.randomUUID()}`,
id: createClientId("sentiment-human"),
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: {
@@ -115,7 +116,7 @@ export function useSentimentAgentChat(threadId: string) {
try {
await runSentimentAgent({
threadId,
runId: `run-${crypto.randomUUID()}`,
runId: createClientId("run"),
messages: history,
signal: controller.signal,
handlers: {
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.multi-round-rag.";
@@ -77,7 +78,7 @@ function writeStoredMessages(threadId: string, messages: Message[]) {
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
import { createClientId } from "@/lib/client-id";
const STORAGE_PREFIX = "workspace.single-round-rag.";
@@ -277,7 +278,7 @@ function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[]
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
return createClientId(prefix);
}
function isAbortError(error: unknown) {
@@ -0,0 +1,289 @@
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeEnv } from "@/env";
export interface EightBfTask {
id: string;
externalTaskId: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
taskPriority: string;
targetDirection: string;
taskStatus: string;
source: string;
raw: Record<string, unknown> | null;
}
export interface EightBfDictionaryOption {
label: string;
value: string;
alias?: string;
statusTone?: string;
}
export interface EightBfTaskDictionaries {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
}
export interface EightBfTaskContextPayload {
kind: "eightbf_roundtable_task";
usage: string;
task_id: string;
task: {
id: string;
title: string;
description: string;
direction: string;
overview: string;
priority: string;
task_priority: string;
target_direction: string;
task_status: string;
};
task_tags: {
priority: string;
direction: string;
status: string;
};
}
const TASK_DETAIL_PATH = "/task/{taskId}";
const DICTIONARY_TYPES = {
priority: "task_priority",
direction: "task_direction",
status: "task_status",
} as const;
function externalApiBase(): string {
// The position-roundtable Vue app calls this task system through
// VITE_API_BASE_URL (usually the /zncm gateway), not DeerFlow's own API.
return (
readRuntimeEnv("VITE_8BF_TASK_API_BASE_URL")?.replace(/\/+$/, "") ||
readRuntimeEnv("VITE_API_BASE_URL")?.replace(/\/+$/, "") ||
getBackendBaseURL().replace(/\/+$/, "")
);
}
function externalUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
}
/**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
* 两套 token 的用途不同,不能直接复用 `apiFetch` 的默认鉴权头;否则
* 任务详情/字典接口可能返回 401,页面只会看到空任务卡。
*
* 这里仍保留 include credentials,与原 Vue 请求保持一致;同时不走
* `apiFetch` 的 401 自动清理逻辑,避免外部任务系统的鉴权失败误登出
* DeerFlow 当前用户。
*/
async function fetchExternalTaskSystem(
path: string,
init: RequestInit = {},
): Promise<Response> {
const headers = new Headers(init.headers);
if (!headers.has("Authorization")) {
headers.set("Authorization", "Bearer admin");
}
if (!headers.has("Accept")) {
headers.set("Accept", "application/json");
}
return fetch(externalUrl(path), {
...init,
credentials: init.credentials ?? "include",
headers,
});
}
function firstText(...values: unknown[]): string {
for (const value of values) {
if (value === undefined || value === null) continue;
const text = String(value).trim();
if (text) return text;
}
return "";
}
function unwrapData(payload: unknown): Record<string, unknown> {
let current: unknown = payload;
// Task services in different deployments wrap the object as either
// { data: task } or { data: { data: task } }.
for (let index = 0; index < 3; index += 1) {
if (!current || typeof current !== "object" || Array.isArray(current)) break;
const next = (current as { data?: unknown }).data;
if (!next || typeof next !== "object" || Array.isArray(next)) break;
current = next;
}
return current && typeof current === "object" && !Array.isArray(current)
? (current as Record<string, unknown>)
: {};
}
export function normalizeEightBfTask(
raw: Record<string, unknown> | null | undefined,
fallbackTaskId = "",
): EightBfTask {
const source = raw ?? {};
const id = firstText(
source.id,
source.taskId,
source.externalTaskId,
source.external_task_id,
fallbackTaskId,
);
return {
id,
externalTaskId: firstText(source.externalTaskId, source.external_task_id, id),
title: firstText(source.title, source.taskName, source.task_name, source.name),
description: firstText(
source.description,
source.taskDescription,
source.task_description,
source.taskContent,
source.task_content,
source.content,
),
direction: firstText(
source.direction,
source.taskDirection,
source.task_direction,
),
overview: firstText(source.overview, source.summary),
priority: firstText(source.priority, source.urgency),
taskPriority: firstText(
source.taskPriority,
source.task_priority,
source.priority,
source.urgency,
),
targetDirection: firstText(
source.targetDirection,
source.target_direction,
source.direction,
source.taskDirection,
source.task_direction,
),
taskStatus: firstText(source.taskStatus, source.task_status, source.status),
source: "测试分析岗任务详情接口",
raw: source,
};
}
function dictionaryRows(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === "object") {
const directData = (payload as { data?: unknown }).data;
if (Array.isArray(directData)) return directData;
if (directData && typeof directData === "object") {
const nested = directData as { rows?: unknown; list?: unknown };
if (Array.isArray(nested.rows)) return nested.rows;
if (Array.isArray(nested.list)) return nested.list;
}
}
const data = unwrapData(payload);
if (Array.isArray(data.rows)) return data.rows;
if (Array.isArray(data.list)) return data.list;
return [];
}
function normalizeDictionary(payload: unknown): EightBfDictionaryOption[] {
return dictionaryRows(payload)
.map((item) => {
if (!item || typeof item !== "object") return null;
const row = item as Record<string, unknown>;
const label = firstText(row.dictLabel, row.label);
const value = firstText(row.dictValue, row.dict_value, row.value);
if (!label || !value) return null;
return {
label,
value,
alias: firstText(row.alias) || undefined,
statusTone: firstText(row.statusTone, row.status_tone) || undefined,
} as EightBfDictionaryOption;
})
.filter((item): item is EightBfDictionaryOption => item !== null);
}
export async function fetchEightBfTask(taskId: string): Promise<EightBfTask> {
const encodedId = encodeURIComponent(taskId);
const path = TASK_DETAIL_PATH.replaceAll("{taskId}", encodedId);
const response = await fetchExternalTaskSystem(path, { method: "GET" });
if (!response.ok) {
throw new Error(`任务详情接口请求失败(${response.status})`);
}
const payload = (await response.json()) as unknown;
const task = normalizeEightBfTask(unwrapData(payload), taskId);
// Keep the external route id as the stable scope even if a deployment's
// response includes a legacy/internal numeric id.
return { ...task, id: taskId, externalTaskId: taskId };
}
export async function fetchEightBfTaskDictionaries(): Promise<EightBfTaskDictionaries> {
const entries = await Promise.all(
(Object.entries(DICTIONARY_TYPES) as Array<[
keyof EightBfTaskDictionaries,
string,
]>).map(async ([key, path]) => {
try {
const response = await fetchExternalTaskSystem(
`/system/dict/data/type/${path}`,
{ method: "GET" },
);
if (!response.ok) throw new Error(`字典接口请求失败(${response.status})`);
return [key, normalizeDictionary(await response.json())] as const;
} catch (error) {
// A dictionary failure must not hide the task card; the card falls back
// to the raw task codes and the detail request remains usable.
console.warn(`[8bf] ${key} 字典加载失败`, error);
return [key, []] as const;
}
}),
);
return Object.fromEntries(entries) as EightBfTaskDictionaries;
}
export function dictionaryText(
options: EightBfDictionaryOption[] | undefined,
value: unknown,
preferredField: "label" | "alias" = "label",
): string {
const raw = firstText(value);
if (!raw) return "";
const match = options?.find((option) => option.value === raw);
return firstText(match?.[preferredField], match?.label, raw);
}
export function buildEightBfTaskContext(
task: EightBfTask | null,
dictionaries: EightBfTaskDictionaries,
): EightBfTaskContextPayload | null {
if (!task?.id) return null;
return {
kind: "eightbf_roundtable_task",
usage:
"这是 8BF 页面左上角任务卡片中的任务信息。请直接基于这些信息理解用户问题,不要要求用户重复提供已经存在的任务名称、任务描述、任务方向或任务状态。",
task_id: task.id,
task: {
id: task.id,
title: task.title,
description: task.description,
direction: task.direction,
overview: task.overview,
priority: task.priority,
task_priority: task.taskPriority,
target_direction: task.targetDirection,
task_status: task.taskStatus,
},
task_tags: {
priority: dictionaryText(dictionaries.priority, task.taskPriority),
direction: dictionaryText(dictionaries.direction, task.targetDirection, "alias"),
status: dictionaryText(dictionaries.status, task.taskStatus),
},
};
}
@@ -0,0 +1,58 @@
import { Clipboard, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
dictionaryText,
type EightBfDictionaryOption,
type EightBfTask,
} from "@/eightbf-roundtable/api/task-context";
interface EightBfTaskCardProps {
task: EightBfTask | null;
dictionaries: {
priority: EightBfDictionaryOption[];
direction: EightBfDictionaryOption[];
status: EightBfDictionaryOption[];
};
loading?: boolean;
error?: string | null;
}
export function EightBfTaskCard({
task,
dictionaries,
loading = false,
error,
}: EightBfTaskCardProps) {
const priority = dictionaryText(dictionaries.priority, task?.taskPriority);
const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias");
const status = dictionaryText(dictionaries.status, task?.taskStatus);
const tags = [priority, direction, status].filter(Boolean);
return (
<section className="eightbf-roundtable__task-card" aria-labelledby="eightbf-task-card-title">
<div className="eightbf-roundtable__task-card-heading">
<span className="eightbf-roundtable__task-card-icon" aria-hidden="true">
<Clipboard className="h-3.5 w-3.5" strokeWidth={2.5} />
</span>
<h2 id="eightbf-task-card-title">当前任务</h2>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-label="正在加载任务" />}
</div>
<div className="eightbf-roundtable__task-card-body">
<p className="eightbf-roundtable__task-card-title">
{task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")}
</p>
{tags.length > 0 && (
<div className="eightbf-roundtable__task-card-tags" aria-label="任务标签">
{tags.map((tag) => <span key={tag}>{tag}</span>)}
</div>
)}
<p className={cn("eightbf-roundtable__task-card-description", !task?.description && "is-empty")}>
{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}
</p>
{error && <p className="eightbf-roundtable__task-card-error" role="status">{error}</p>}
</div>
</section>
);
}
@@ -1,7 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Loader2, Save, Settings2, X } from "lucide-react";
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
import {
buildEightBfTaskContext,
fetchEightBfTask,
fetchEightBfTaskDictionaries,
normalizeEightBfTask,
type EightBfTask,
type EightBfTaskDictionaries,
} from "@/eightbf-roundtable/api/task-context";
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains";
@@ -26,10 +36,28 @@ const BUSINESS_CODE = "8BF";
*/
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
const auth = getStoredAuth();
const [searchParams] = useSearchParams();
const taskId = useMemo(
() => searchParams.get("taskId")?.trim() || "",
[searchParams],
);
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
const storageTaskId = useMemo(
() => `8bf:${auth?.user_id || "anonymous"}:${taskId || "anonymous"}`,
[auth?.user_id, taskId],
);
const [task, setTask] = useState<EightBfTask | null>(() => (
taskId ? normalizeEightBfTask({}, taskId) : null
));
const [taskDictionaries, setTaskDictionaries] = useState<EightBfTaskDictionaries>({
priority: [],
direction: [],
status: [],
});
const [taskLoading, setTaskLoading] = useState(Boolean(taskId));
const [taskError, setTaskError] = useState<string | null>(null);
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
const [chains, setChains] = useState<BusinessChain[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -41,6 +69,53 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId],
);
const taskContext = useMemo(
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
useEffect(() => {
if (!taskId) {
setTask(null);
setTaskError(null);
setTaskLoading(false);
setTaskDictionaries({ priority: [], direction: [], status: [] });
return;
}
let cancelled = false;
setTaskLoading(true);
setTaskError(null);
setTask(normalizeEightBfTask({}, taskId));
let pendingRequests = 2;
const markRequestFinished = () => {
pendingRequests -= 1;
if (!cancelled && pendingRequests === 0) setTaskLoading(false);
};
// Keep detail and dictionary requests independent: a missing dictionary
// must not prevent the task card or task context from being available.
void fetchEightBfTask(taskId)
.then((nextTask) => {
if (!cancelled) setTask(nextTask);
})
.catch((error) => {
if (cancelled) return;
setTaskError(error instanceof Error ? error.message : "任务详情加载失败");
})
.finally(markRequestFinished);
void fetchEightBfTaskDictionaries()
.then((nextDictionaries) => {
if (!cancelled) setTaskDictionaries(nextDictionaries);
})
.catch(() => {
// fetchEightBfTaskDictionaries already degrades each dictionary to an
// empty list; this guard protects future adapter changes.
})
.finally(markRequestFinished);
return () => {
cancelled = true;
};
}, [taskId]);
const loadConfiguration = useCallback(async () => {
setIsLoading(true);
@@ -162,6 +237,16 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
lockedChainId={mapping?.chainId ?? null}
lockBusinessChain
storageTaskId={storageTaskId}
taskContext={taskContext as Record<string, unknown> | null}
entryRailTaskId={taskId || null}
entryRailTaskCardSlot={taskId ? (
<EightBfTaskCard
task={task}
dictionaries={taskDictionaries}
loading={taskLoading}
error={taskError}
/>
) : undefined}
toolbarSlot={toolbarSlot}
entryRailBusinessChainSlot={
<PositionBusinessChainFlow
@@ -27,4 +27,100 @@
min-height: 460px;
}
/* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
.eightbf-roundtable .eightbf-roundtable__task-card {
flex: 0 0 auto;
border: 1px solid #d9e5ef;
border-radius: 16px;
background: linear-gradient(145deg, #ffffff 0%, #f8fbff 100%);
padding: 14px;
box-shadow: 0 4px 20px rgb(15 23 42 / 3%);
}
.eightbf-roundtable .eightbf-roundtable__task-card-heading {
display: flex;
align-items: center;
gap: 8px;
color: #1e293b;
font-size: 13px;
font-weight: 700;
}
.eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child {
margin-left: auto;
color: #0f766e;
}
.eightbf-roundtable .eightbf-roundtable__task-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 7px;
background: #0f766e;
color: #fff;
}
.eightbf-roundtable .eightbf-roundtable__task-card-body {
min-width: 0;
margin-top: 11px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-title {
margin: 0;
color: #0f172a;
font-size: 14px;
font-weight: 700;
line-height: 1.55;
overflow-wrap: anywhere;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
border: 1px solid #c7e6df;
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
padding: 2px 7px;
font-size: 10px;
line-height: 1.4;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description {
display: -webkit-box;
margin: 9px 0 0;
color: #475569;
font-size: 11px;
line-height: 1.65;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; }
.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #b91c1c; font-size: 10px; line-height: 1.5; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card {
border-color: rgb(92 156 219 / 45%);
background: linear-gradient(145deg, #0b315d 0%, #08264b 100%);
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading,
.dark .eightbf-roundtable .eightbf-roundtable__task-card-title { color: #f1f7ff; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child { color: #65d6c6; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-tags span { border-color: rgb(101 214 198 / 40%); background: rgb(15 118 110 / 24%); color: #9ef0df; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description { color: #c3d4e8; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
.dark .eightbf-roundtable .eightbf-roundtable__task-card-error { color: #fca5a5; }
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
+27
View File
@@ -0,0 +1,27 @@
/**
* Generate a client-only id without assuming a secure browser context.
*
* `crypto.randomUUID()` is unavailable in several browsers when the app is
* opened directly through an `http://<public-ip>:<port>` address. Request and
* UI ids need uniqueness, not a security token, so a timestamp/random fallback
* keeps those flows usable until the deployment is served over HTTPS.
*/
export function createClientId(prefix?: string): string {
const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined;
let id: string | undefined;
if (typeof cryptoApi?.randomUUID === "function") {
id = cryptoApi.randomUUID();
} else if (typeof cryptoApi?.getRandomValues === "function") {
try {
const values = new Uint32Array(4);
cryptoApi.getRandomValues(values);
id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
} catch {
// Some restricted browser contexts expose crypto but reject random data.
}
}
id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
return prefix ? `${prefix}-${id}` : id;
}
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useAgentSelector } from "@/core/agents";
import { createClientId } from "@/lib/client-id";
import {
type DomainAgentMapping,
normalizeDomainInput,
@@ -20,10 +21,7 @@ import {
function makeRow(): DomainAgentMapping {
return {
id:
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
id: createClientId(),
domain: "",
agentId: "",
enabled: true,
@@ -6,6 +6,7 @@
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
/**
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
@@ -147,10 +148,7 @@ const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
*/
function newCommandId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
return createClientId().replace(/-/g, "");
}
/**
@@ -24,6 +24,7 @@ import {
type IntentSummary,
} from "@/roundtable-planning/api/intent";
import { uploadFiles } from "@/core/uploads/api";
import { createClientId } from "@/lib/client-id";
import {
clarificationKey,
formatIntentClarificationAnswer,
@@ -449,9 +450,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
const clientTurnId =
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const clientTurnId = createClientId();
const res = await streamIntent({
threadId: intentThreadId,
@@ -298,6 +298,12 @@ export default function RoundtablePlanningPage({
toolbarSlot,
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
entryRailBusinessChainSlot,
/** 独立业务页在第一步左上角提供自己的任务卡片;不触发原深链任务接口。 */
entryRailTaskCardSlot,
/** 独立业务页传给意图智能体的隐藏任务上下文。 */
taskContext,
/** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */
entryRailTaskId,
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
rootClassName,
}: {
@@ -307,6 +313,9 @@ export default function RoundtablePlanningPage({
storageTaskId?: string | null;
toolbarSlot?: ReactNode;
entryRailBusinessChainSlot?: ReactNode;
entryRailTaskCardSlot?: ReactNode;
taskContext?: Record<string, unknown> | null;
entryRailTaskId?: string | null;
rootClassName?: string;
}) {
const { theme } = useTheme();
@@ -315,6 +324,10 @@ export default function RoundtablePlanningPage({
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
useEffect(() => {
taskContextRef.current = taskContext ?? null;
}, [taskContext]);
/** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */
const inWujieEmbed = isWujieEnv();
@@ -542,6 +555,38 @@ export default function RoundtablePlanningPage({
}>({ intentReady: null, interactiveMessages: [] });
const buildInitialIntent = useCallback((): string => {
const task = taskContextRef.current?.task as Record<string, unknown> | null | undefined;
const taskTags = taskContextRef.current?.task_tags as
| Record<string, unknown>
| undefined;
const taskValue = (value: unknown) =>
value === undefined || value === null ? "" : String(value).trim();
const taskLines = task
? [
["任务 ID", taskValue(task.id ?? taskContextRef.current?.task_id)],
["任务名称", taskValue(task.title)],
["任务描述", taskValue(task.description)],
["任务概览", taskValue(task.overview)],
["任务方向", taskValue(task.direction ?? task.target_direction)],
["优先级", taskValue(task.task_priority ?? task.priority)],
["任务状态", taskValue(task.task_status)],
["优先级标签", taskValue(taskTags?.priority)],
["方向标签", taskValue(taskTags?.direction)],
["状态标签", taskValue(taskTags?.status)],
]
.filter(([, value]) => value)
.map(([label, value]) => `${label}: ${value}`)
: [];
const taskCardContext = taskLines.length
? [
"【8BF 任务卡片上下文】",
...taskLines,
"请把以上任务卡片信息作为本轮研讨的既有背景,不要让用户重复提供。",
"",
].join("\n")
: "";
const withTaskCardContext = (value: string) =>
taskCardContext ? `${taskCardContext}${value}` : value;
const { intentReady, interactiveMessages } = step1StateRef.current;
if (intentReady) {
const parts: string[] = [intentReady.objective];
@@ -559,17 +604,17 @@ export default function RoundtablePlanningPage({
parts.push(
"用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。",
);
return parts.join("\n");
return withTaskCardContext(parts.join("\n"));
}
const userTurns = interactiveMessages
.filter((m) => m.sender === "user")
.map((m) => m.content?.trim())
.filter((s): s is string => !!s);
if (userTurns.length === 0) {
return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。";
return withTaskCardContext("用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。");
}
const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n");
return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`;
return withTaskCardContext(`${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`);
}, []);
const step2 = useStep2Orchestration({
@@ -593,6 +638,7 @@ export default function RoundtablePlanningPage({
currentStep,
selectedModel,
triggerToast,
taskContext,
});
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
@@ -2176,12 +2222,13 @@ export default function RoundtablePlanningPage({
onSelectChain={handleSelectChainFromSidebar}
onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId}
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
entryRailTaskId={entryRailTaskId ?? recordTaskIdOf(prefillRef.current)}
historyTaskId={draftStorageTaskId}
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
lockedChainId={lockedChainId}
lockBusinessChain={lockBusinessChain}
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
entryRailTaskCardSlot={entryRailTaskCardSlot}
/>
{/* MIDDLE VIEWPORT */}
@@ -2743,6 +2790,8 @@ interface LeftSidebarProps {
lockedChainId?: string | null;
/** 第一阶段替换默认业务链选择区的自定义内容。 */
entryRailBusinessChainSlot?: ReactNode;
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
entryRailTaskCardSlot?: ReactNode;
}
function Step2SandboxSidebarSync({
@@ -2794,6 +2843,7 @@ function LeftSidebar({
lockBusinessChain,
lockedChainId,
entryRailBusinessChainSlot,
entryRailTaskCardSlot,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2937,6 +2987,7 @@ function LeftSidebar({
lockBusinessChain={lockBusinessChain}
lockedChainId={lockedChainId ?? null}
businessChainSlot={entryRailBusinessChainSlot}
taskCardSlot={entryRailTaskCardSlot}
/>
)}
</section>
@@ -2992,6 +3043,7 @@ function RoundtableEntryRail({
lockedChainId,
lockBusinessChain = false,
businessChainSlot,
taskCardSlot,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
@@ -3007,6 +3059,7 @@ function RoundtableEntryRail({
lockedChainId?: string | null;
lockBusinessChain?: boolean;
businessChainSlot?: ReactNode;
taskCardSlot?: ReactNode;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
@@ -3093,7 +3146,12 @@ function RoundtableEntryRail({
)}
>
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
{deeplink ? (
{taskCardSlot ? (
<>
{taskCardSlot}
{lockBusinessChain && businessChainSlot}
</>
) : deeplink ? (
isXdfx ? (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
@@ -17,6 +17,7 @@ import {
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { createClientId } from "@/lib/client-id";
import type {
DocumentRewriteSummary,
DocumentRewriteStreamEvent,
@@ -355,7 +356,7 @@ export async function startJob(
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: opts.requestId ?? crypto.randomUUID(),
request_id: opts.requestId ?? createClientId(),
entry: opts.entry ?? "legacy",
config: opts.config ?? null,
}),