Files
deerflow-code/frontend-web/src/report-collaboration/components/NodeInspectorDrawer.tsx
T
董宏凯andCursor db333a4c33 feat: 重构多轮RAG展示并完善报告协作、业务链审核与 agentfx 种子
抽出专用多轮RAG消息列表并升级会话模型;强化报告协作画布与检查器;业务链普通用户改为提交审核;扩展 agentfx 报告技能种子,并将含 admin 的用户名自动提权为管理员。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 17:34:16 +08:00

281 lines
10 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Ban, ChevronDown, Target, X } from "lucide-react";
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
import type { NodeContractDetail } from "@/report-collaboration/api/types";
import { roleIcon, type ReportFlowNodeKind } from "@/report-collaboration/components/ReportFlowNode";
export interface NodeInspectorFallback {
label: string;
status: string;
role?: string | null;
angle?: string | null;
kind?: ReportFlowNodeKind;
mission?: string | null;
input_artifact_types?: string[];
output_artifact_type?: string | null;
allowed_tools?: string[];
acceptance_criteria?: string[];
upstream?: string[];
downstream?: string[];
}
interface NodeInspectorDrawerProps {
open: boolean;
nodeId: string | null;
runId: string | null;
/** 节点基础信息(画布侧已有,预览方案时也能填抽屉,不发起结果请求)。 */
fallback?: NodeInspectorFallback | null;
onClose: () => void;
/** 「在对话中对此节点提出意见」:关闭抽屉 + 聚焦 Composer + 绑定目标。 */
onComment: (nodeId: string, nodeName: string) => void;
}
const SECTION_KEYS = ["mission", "input", "output", "tools", "graph"] as const;
type SectionKey = (typeof SECTION_KEYS)[number];
/**
* 只读节点说明抽屉:覆盖在画布右侧,不占第三栏。
* 只读取节点合同(职责/输入/产出/工具/验收),不请求节点执行结果;
* 执行过程统一在左侧 MessageList 查看。
*/
export function NodeInspectorDrawer({
open,
nodeId,
runId,
fallback,
onClose,
onComment,
}: NodeInspectorDrawerProps) {
const [contract, setContract] = useState<NodeContractDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
mission: true,
input: true,
output: true,
tools: true,
graph: true,
});
useEffect(() => {
if (!open || !nodeId || !runId) {
setContract(null);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
getReportCollaborationDriver()
.getNodeContract(runId, nodeId)
.then((detail) => {
if (!cancelled) setContract(detail);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, nodeId, runId]);
const view = useMemo(() => {
return {
label: contract?.label ?? fallback?.label ?? nodeId ?? "",
status: contract?.status ?? fallback?.status ?? "planned",
role: contract?.role ?? fallback?.role ?? null,
angle: contract?.angle ?? fallback?.angle ?? null,
kind: fallback?.kind ?? "agent",
mission: contract?.mission ?? fallback?.mission ?? null,
input_artifact_types: contract?.input_artifact_types ?? fallback?.input_artifact_types ?? [],
output_artifact_type: contract?.output_artifact_type ?? fallback?.output_artifact_type ?? null,
allowed_tools: contract?.allowed_tools ?? fallback?.allowed_tools ?? [],
acceptance_criteria: contract?.acceptance_criteria ?? fallback?.acceptance_criteria ?? [],
upstream: contract?.upstream ?? fallback?.upstream ?? [],
downstream: contract?.downstream ?? fallback?.downstream ?? [],
forbidden: contract?.forbidden ?? [],
max_attempts: contract?.max_attempts ?? null,
time_budget_seconds: contract?.time_budget_seconds ?? null,
token_budget: contract?.token_budget ?? null,
source_policy: contract?.source_policy ?? null,
};
}, [contract, fallback, nodeId]);
if (!open || !nodeId) return null;
const hasBody = Boolean(contract) || Boolean(fallback?.mission) || Boolean(fallback?.label);
const showLoading = loading && !hasBody;
const toggle = (key: SectionKey) => {
setOpenSections((current) => ({ ...current, [key]: !current[key] }));
};
return (
<aside className="rc-drawer" role="dialog" aria-label={`节点说明:${view.label}`}>
<header className="rc-drawer-header">
<span className="rc-flow-node-icon" aria-hidden>
{roleIcon(view.role, view.kind)}
</span>
<div className="min-w-0 flex-1">
<div className="rc-drawer-title">{view.label}</div>
<div className="rc-drawer-subtitle">
{view.role ? <span>{view.role}</span> : null}
{view.angle ? <span>· {view.angle}</span> : null}
<span>· {statusText(view.status)}</span>
</div>
</div>
<button type="button" className="rc-drawer-icon-btn" onClick={onClose} aria-label="关闭节点说明">
<X className="size-4" />
</button>
</header>
<div className="rc-drawer-body">
{showLoading ? (
<div className="rc-empty py-10 text-[12px]">正在读取节点合同…</div>
) : error && !hasBody ? (
<div className="rc-empty py-10 text-[12px]" style={{ color: "var(--rc-danger)" }}>
读取节点合同失败:{error}
</div>
) : (
<>
<DrawerSection title="节点职责" open={openSections.mission} onToggle={() => toggle("mission")}>
<p className="rc-drawer-text">{view.mission || "暂无职责说明"}</p>
</DrawerSection>
<DrawerSection title="输入" open={openSections.input} onToggle={() => toggle("input")}>
<ul className="rc-drawer-list">
{view.input_artifact_types.length > 0 ? (
view.input_artifact_types.map((type) => (
<li key={type}>
<span className="rc-flow-pill rc-flow-pill-input">{shortArtifact(type)}</span>
</li>
))
) : (
<li className="text-[var(--rc-text-secondary)]">无上游成果要求</li>
)}
{view.upstream.length > 0 ? <li>上游节点:{view.upstream.join("、")}</li> : null}
{view.source_policy ? <li>资料策略:{view.source_policy}</li> : null}
</ul>
</DrawerSection>
<DrawerSection title="输出" open={openSections.output} onToggle={() => toggle("output")}>
<ul className="rc-drawer-list">
<li>
成果类型:
{view.output_artifact_type ? (
<span className="rc-flow-pill rc-flow-pill-output">{shortArtifact(view.output_artifact_type)}</span>
) : (
"未指定"
)}
</li>
{view.acceptance_criteria.map((criterion) => (
<li key={criterion}>验收:{criterion}</li>
))}
</ul>
</DrawerSection>
<DrawerSection title="技能" open={openSections.tools} onToggle={() => toggle("tools")}>
<div className="flex flex-wrap gap-1">
{view.allowed_tools.length > 0 ? (
view.allowed_tools.map((tool) => (
<span key={tool} className="rc-flow-pill rc-flow-pill-neutral">
{tool}
</span>
))
) : (
<span className="text-[12px] text-[var(--rc-text-secondary)]">未配置技能</span>
)}
</div>
{view.forbidden.length > 0 ? (
<ul className="rc-drawer-list mt-2">
{view.forbidden.map((rule) => (
<li key={rule}>
<Ban className="mr-1 inline size-3" aria-hidden />
{rule}
</li>
))}
</ul>
) : null}
{view.max_attempts != null ? (
<p className="mt-2 text-[12px] text-[var(--rc-text-secondary)]">
预算:至多 {view.max_attempts} 次尝试
{view.time_budget_seconds ? ` · ${view.time_budget_seconds}s` : ""}
{view.token_budget ? ` · ${view.token_budget} tokens` : ""}
</p>
) : null}
</DrawerSection>
<DrawerSection title="上下游" open={openSections.graph} onToggle={() => toggle("graph")}>
<p className="rc-drawer-text">
{view.downstream.length > 0 ? `下游:${view.downstream.join("、")}` : "终端节点(无下游)"}
</p>
</DrawerSection>
</>
)}
</div>
<footer className="rc-drawer-footer">
<button
type="button"
className="rc-btn rc-btn-primary w-full justify-center"
onClick={() => onComment(nodeId, view.label)}
>
<Target className="size-3.5" aria-hidden />
在对话中对此节点提出意见
</button>
<p className="mt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
重跑、补充或纠偏通过对话完成(例如「重新检索」「重新分析」);抽屉不直接发起变更。
</p>
</footer>
</aside>
);
}
function DrawerSection({
title,
open,
onToggle,
children,
}: {
title: string;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<section className="rc-drawer-section" data-open={open}>
<button type="button" className="rc-drawer-section-toggle" onClick={onToggle} aria-expanded={open}>
<ChevronDown className="rc-drawer-chevron" aria-hidden />
<span>{title}</span>
</button>
{open ? <div className="rc-drawer-section-body">{children}</div> : null}
</section>
);
}
function shortArtifact(value: string): string {
const trimmed = value.trim();
if (!trimmed) return value;
return trimmed.includes(":") ? trimmed.slice(trimmed.lastIndexOf(":") + 1) : trimmed;
}
function statusText(status: string): string {
const map: Record<string, string> = {
planned: "规划中",
queued: "排队中",
running: "运行中",
validating: "校验中",
completed: "已完成",
failed: "失败",
awaiting_input: "等待用户",
cancel_requested: "取消中",
cancelled: "已取消",
superseded: "已被替代",
reopened: "已重开",
interrupted: "已中断",
};
return map[status] ?? status;
}