增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。
修复 MySQL 读写分离下的持久化与 SSE 重连问题,报告完成前先落库;8BF 嵌入页支持 taskId 任务卡与独立意图 metadata;前端统一 createClientId 并更新 runtime 配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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 步的任务理解面板与业务链流程图样式。 */
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user