抽出共用分析成果卡片,第一步展示第二、三步 Markdown 产物;智能体列表对齐岗位会商选择样式。第二步对话面板改为深蓝顶栏,第一步保留可配置返回按钮。 Co-authored-by: Cursor <cursoragent@cursor.com>
393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
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;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 外部任务系统在离线部署或本地联调时可能不可用。此回退仅用于页面展示与
|
||
* 意图上下文,保持与岗位会商示例一致,避免左上任务卡退化成接口错误文本。
|
||
*/
|
||
export function createEightBfFallbackTask(taskId: string): EightBfTask {
|
||
return {
|
||
id: taskId,
|
||
externalTaskId: taskId,
|
||
title: "暑期活动",
|
||
description: "围绕暑期活动的游泳夏令营开展宣传,结合线上线下渠道吸引目标人群参与,持续提升活动影响力。",
|
||
direction: "暑期活动",
|
||
overview: "面向家长群体传播暑期游泳夏令营活动信息,提升品牌曝光与触达声量。",
|
||
priority: "urgent",
|
||
taskPriority: "urgent",
|
||
targetDirection: "summer_activity",
|
||
taskStatus: "system_created",
|
||
source: "本系统创建",
|
||
raw: null,
|
||
};
|
||
}
|
||
|
||
const TASK_DETAIL_PATH = "/task/{taskId}";
|
||
const PROCESS_REPORT_ADD_PATH = "/task/process-report/add";
|
||
const PROCESS_REPORT_PATH = "/task/process-report/{id}";
|
||
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(/\/+$/, "")
|
||
);
|
||
}
|
||
|
||
/** 8BF 会商页右上角「返回」按钮的跳转地址,来自 runtime-config.js。 */
|
||
export function getEightBfRoundtableBackUrl(): string {
|
||
return readRuntimeEnv("VITE_8BF_ROUNDTABLE_BACK_URL") ?? "";
|
||
}
|
||
|
||
function externalUrl(path: string): string {
|
||
if (/^https?:\/\//i.test(path)) return path;
|
||
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
|
||
}
|
||
|
||
export interface EightBfTaskProcessReportInput {
|
||
taskId: string;
|
||
agentId: string;
|
||
agentName: string;
|
||
content: string;
|
||
/** 与岗位会商一致:测试分析岗的过程产物位置为 0。 */
|
||
position: number;
|
||
reportName: string;
|
||
submitStatus: 0 | 1;
|
||
}
|
||
|
||
export interface EightBfTaskProcessReportReceipt {
|
||
id: string;
|
||
}
|
||
|
||
/**
|
||
* 当前 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,
|
||
});
|
||
}
|
||
|
||
async function externalTaskError(response: Response, fallback: string): Promise<Error> {
|
||
const text = await response.text().catch(() => "");
|
||
try {
|
||
const payload = JSON.parse(text) as { message?: unknown; msg?: unknown; detail?: unknown };
|
||
const detail = firstText(payload.message, payload.msg, payload.detail);
|
||
if (detail) return new Error(`${fallback}(${response.status}):${detail}`);
|
||
} catch {
|
||
// Keep the HTTP status when this deployment returns a non-JSON error body.
|
||
}
|
||
return new Error(`${fallback}(${response.status})`);
|
||
}
|
||
|
||
function findProcessReportId(payload: unknown): string {
|
||
if (!payload || typeof payload !== "object") return "";
|
||
const source = payload as Record<string, unknown>;
|
||
const direct = firstText(
|
||
source.id,
|
||
source.processReportId,
|
||
source.process_report_id,
|
||
source.reportId,
|
||
);
|
||
if (direct) return direct;
|
||
return findProcessReportId(source.data);
|
||
}
|
||
|
||
/**
|
||
* Create the external task system's process report in its unsubmitted state.
|
||
* The local gateway stores the returned id so a refresh can later submit the
|
||
* same report instead of creating a duplicate one.
|
||
*/
|
||
export async function createEightBfTaskProcessReport(
|
||
input: EightBfTaskProcessReportInput,
|
||
): Promise<EightBfTaskProcessReportReceipt> {
|
||
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(input),
|
||
});
|
||
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
||
const id = findProcessReportId(await response.json().catch(() => null));
|
||
if (!id) throw new Error("过程产物登记成功,但接口未返回报告 ID");
|
||
return { id };
|
||
}
|
||
|
||
/** Submit an already-registered process report to the external task system. */
|
||
export async function updateEightBfTaskProcessReport(
|
||
reportId: string,
|
||
input: EightBfTaskProcessReportInput,
|
||
): Promise<void> {
|
||
const response = await fetchExternalTaskSystem(
|
||
PROCESS_REPORT_PATH.replace("{id}", encodeURIComponent(reportId)),
|
||
{
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(input),
|
||
},
|
||
);
|
||
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
||
}
|
||
|
||
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),
|
||
},
|
||
};
|
||
}
|