抽出专用多轮RAG消息列表并升级会话模型;强化报告协作画布与检查器;业务链普通用户改为提交审核;扩展 agentfx 报告技能种子,并将含 admin 的用户名自动提权为管理员。 Co-authored-by: Cursor <cursoragent@cursor.com>
281 lines
10 KiB
TypeScript
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;
|
|
}
|