feat(position-roundtable): add delivery rejection workflow
This commit is contained in:
1 parent
2ca8a23102
commit
1cd99a5a90
9 files changed
+1317
-43
No files matched your search
@@ -30,6 +30,9 @@ export interface PositionRoundtableNode {
|
|||||||
artifactManifest: Array<Record<string, unknown>>;
|
artifactManifest: Array<Record<string, unknown>>;
|
||||||
revision: number;
|
revision: number;
|
||||||
upstreamRevision: Record<string, unknown> | null;
|
upstreamRevision: Record<string, unknown> | null;
|
||||||
|
rejectionCount: number;
|
||||||
|
lastRejection: Record<string, unknown> | null;
|
||||||
|
invalidatedBy: string | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
@@ -42,6 +45,8 @@ export interface PositionRoundtableSession {
|
|||||||
chainSnapshot: Record<string, unknown> | null;
|
chainSnapshot: Record<string, unknown> | null;
|
||||||
intentThreadId: string | null;
|
intentThreadId: string | null;
|
||||||
intentSnapshot: Record<string, unknown> | null;
|
intentSnapshot: Record<string, unknown> | null;
|
||||||
|
summarySnapshot: Record<string, unknown> | null;
|
||||||
|
actionPlanSnapshot: Record<string, unknown> | null;
|
||||||
status: "intent_pending" | "active" | "completed" | "archived";
|
status: "intent_pending" | "active" | "completed" | "archived";
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@@ -62,6 +67,9 @@ interface ServerNode {
|
|||||||
artifact_manifest?: Array<Record<string, unknown>>;
|
artifact_manifest?: Array<Record<string, unknown>>;
|
||||||
revision?: number;
|
revision?: number;
|
||||||
upstream_revision?: Record<string, unknown> | null;
|
upstream_revision?: Record<string, unknown> | null;
|
||||||
|
rejection_count?: number;
|
||||||
|
last_rejection?: Record<string, unknown> | null;
|
||||||
|
invalidated_by?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
@@ -74,6 +82,8 @@ interface ServerSession {
|
|||||||
chain_snapshot?: Record<string, unknown> | null;
|
chain_snapshot?: Record<string, unknown> | null;
|
||||||
intent_thread_id: string | null;
|
intent_thread_id: string | null;
|
||||||
intent_snapshot?: Record<string, unknown> | null;
|
intent_snapshot?: Record<string, unknown> | null;
|
||||||
|
summary_snapshot?: Record<string, unknown> | null;
|
||||||
|
action_plan_snapshot?: Record<string, unknown> | null;
|
||||||
status: PositionRoundtableSession["status"];
|
status: PositionRoundtableSession["status"];
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
@@ -105,6 +115,9 @@ function toNode(node: ServerNode): PositionRoundtableNode {
|
|||||||
artifactManifest: node.artifact_manifest ?? [],
|
artifactManifest: node.artifact_manifest ?? [],
|
||||||
revision: node.revision ?? 0,
|
revision: node.revision ?? 0,
|
||||||
upstreamRevision: node.upstream_revision ?? null,
|
upstreamRevision: node.upstream_revision ?? null,
|
||||||
|
rejectionCount: node.rejection_count ?? 0,
|
||||||
|
lastRejection: node.last_rejection ?? null,
|
||||||
|
invalidatedBy: node.invalidated_by ?? null,
|
||||||
createdAt: node.created_at,
|
createdAt: node.created_at,
|
||||||
updatedAt: node.updated_at,
|
updatedAt: node.updated_at,
|
||||||
};
|
};
|
||||||
@@ -119,6 +132,8 @@ function toSession(session: ServerSession): PositionRoundtableSession {
|
|||||||
chainSnapshot: session.chain_snapshot ?? null,
|
chainSnapshot: session.chain_snapshot ?? null,
|
||||||
intentThreadId: session.intent_thread_id,
|
intentThreadId: session.intent_thread_id,
|
||||||
intentSnapshot: session.intent_snapshot ?? null,
|
intentSnapshot: session.intent_snapshot ?? null,
|
||||||
|
summarySnapshot: session.summary_snapshot ?? null,
|
||||||
|
actionPlanSnapshot: session.action_plan_snapshot ?? null,
|
||||||
status: session.status,
|
status: session.status,
|
||||||
createdAt: session.created_at,
|
createdAt: session.created_at,
|
||||||
updatedAt: session.updated_at,
|
updatedAt: session.updated_at,
|
||||||
@@ -263,3 +278,45 @@ export async function completePositionNodeTurn(
|
|||||||
);
|
);
|
||||||
return toNode(result);
|
return toNode(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function rejectPositionNode(
|
||||||
|
sessionId: string,
|
||||||
|
nodeKey: string,
|
||||||
|
input: { title: string; reason: string },
|
||||||
|
): Promise<PositionRoundtableSession> {
|
||||||
|
const result = await readJson<ServerSession>(
|
||||||
|
await apiFetch(
|
||||||
|
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: input.title,
|
||||||
|
reason: input.reason,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"reject position node",
|
||||||
|
);
|
||||||
|
return toSession(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePositionSummary(sessionId: string): Promise<PositionRoundtableSession> {
|
||||||
|
const result = await readJson<ServerSession>(
|
||||||
|
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/summary/start`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
"generate position summary",
|
||||||
|
);
|
||||||
|
return toSession(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePositionActionPlan(sessionId: string): Promise<PositionRoundtableSession> {
|
||||||
|
const result = await readJson<ServerSession>(
|
||||||
|
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/action-plan/start`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
"generate position action plan",
|
||||||
|
);
|
||||||
|
return toSession(result);
|
||||||
|
}
|
||||||
@@ -78,7 +78,8 @@ export function PositionNodeChat({
|
|||||||
sessionStatus !== "archived" &&
|
sessionStatus !== "archived" &&
|
||||||
node.positionId === currentPositionId &&
|
node.positionId === currentPositionId &&
|
||||||
node.status !== "locked" &&
|
node.status !== "locked" &&
|
||||||
node.status !== "error";
|
node.status !== "error" &&
|
||||||
|
!node.invalidatedBy;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBoundThreadId(node.threadId);
|
setBoundThreadId(node.threadId);
|
||||||
@@ -152,6 +153,8 @@ export function PositionNodeChat({
|
|||||||
toast.info(
|
toast.info(
|
||||||
sessionStatus === "archived"
|
sessionStatus === "archived"
|
||||||
? "该会话已归档,仅可查看历史记录"
|
? "该会话已归档,仅可查看历史记录"
|
||||||
|
: node.invalidatedBy
|
||||||
|
? "上游产物已被驳回或重做,当前节点需等待前序节点重新完成"
|
||||||
: node.status === "locked"
|
: node.status === "locked"
|
||||||
? "该节点尚未解锁"
|
? "该节点尚未解锁"
|
||||||
: "当前岗位只能查看该节点对话",
|
: "当前岗位只能查看该节点对话",
|
||||||
@@ -181,6 +184,8 @@ export function PositionNodeChat({
|
|||||||
const readOnlyHint =
|
const readOnlyHint =
|
||||||
sessionStatus === "archived"
|
sessionStatus === "archived"
|
||||||
? "该会话已归档,仅可查看历史对话和产物"
|
? "该会话已归档,仅可查看历史对话和产物"
|
||||||
|
: node.invalidatedBy
|
||||||
|
? "上游产物已变更,需等待前序节点重新完成后再重做"
|
||||||
: node.status === "locked"
|
: node.status === "locked"
|
||||||
? "等待上游岗位完成后解锁"
|
? "等待上游岗位完成后解锁"
|
||||||
: node.positionId !== currentPositionId
|
: node.positionId !== currentPositionId
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ import {
|
|||||||
Circle,
|
Circle,
|
||||||
CircleDashed,
|
CircleDashed,
|
||||||
Clock3,
|
Clock3,
|
||||||
Eye,
|
|
||||||
FileOutput,
|
FileOutput,
|
||||||
|
FileText,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Layers3,
|
Layers3,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Plus,
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -40,9 +41,11 @@ import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-m
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
import { useAgent } from "@/core/agents";
|
import { useAgent } from "@/core/agents";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent } from "@/core/agents";
|
||||||
@@ -67,12 +70,16 @@ import {
|
|||||||
activatePositionSession,
|
activatePositionSession,
|
||||||
archivePositionSession,
|
archivePositionSession,
|
||||||
createPositionSession,
|
createPositionSession,
|
||||||
|
generatePositionActionPlan,
|
||||||
|
generatePositionSummary,
|
||||||
getPositionSession,
|
getPositionSession,
|
||||||
listPositionSessions,
|
listPositionSessions,
|
||||||
|
rejectPositionNode,
|
||||||
savePositionIntent,
|
savePositionIntent,
|
||||||
type PositionRoundtableNode,
|
type PositionRoundtableNode,
|
||||||
type PositionRoundtableSession,
|
type PositionRoundtableSession,
|
||||||
} from "@/position-roundtable/api/sessions";
|
} from "@/position-roundtable/api/sessions";
|
||||||
|
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MOCK_POSITION_TASK_ID,
|
DEFAULT_MOCK_POSITION_TASK_ID,
|
||||||
getMockPositionTask,
|
getMockPositionTask,
|
||||||
@@ -174,6 +181,59 @@ function allExecutableNodesDone(nodes: PositionRoundtableNode[]): boolean {
|
|||||||
return nodes.length > 0 && nodes.every((node) => node.status === "done");
|
return nodes.length > 0 && nodes.every((node) => node.status === "done");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nodeHasDelivery(node: PositionRoundtableNode): boolean {
|
||||||
|
return (
|
||||||
|
node.revision > 0 ||
|
||||||
|
Boolean(node.latestAnswer?.trim()) ||
|
||||||
|
node.artifactManifest.some((artifact) => typeof artifact.path === "string" && artifact.path.trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRejectNodeDelivery(node: PositionRoundtableNode): boolean {
|
||||||
|
return node.status === "done" && nodeHasDelivery(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordString(record: Record<string, unknown> | null | undefined, key: string): string {
|
||||||
|
const value = record?.[key];
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeRejectionTitle(node: PositionRoundtableNode): string {
|
||||||
|
return recordString(node.lastRejection, "title");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeRejectionReason(node: PositionRoundtableNode): string {
|
||||||
|
return recordString(node.lastRejection, "reason");
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotStatus(snapshot: Record<string, unknown> | null | undefined): string {
|
||||||
|
const value = snapshot?.status;
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotText(snapshot: Record<string, unknown> | null | undefined, key: string): string {
|
||||||
|
const value = snapshot?.[key];
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function builtinNodeStatus(
|
||||||
|
snapshot: Record<string, unknown> | null | undefined,
|
||||||
|
ready: boolean,
|
||||||
|
): NodeVisualStatus {
|
||||||
|
const status = snapshotStatus(snapshot);
|
||||||
|
if (status === "done") return "done";
|
||||||
|
if (status === "stale") return "stale";
|
||||||
|
if (status === "running") return "running";
|
||||||
|
if (status === "error") return "error";
|
||||||
|
return ready ? "ready" : "locked";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultPositionName(positionId: string | null): string {
|
||||||
|
if (positionId === "summary") return "内置总结节点";
|
||||||
|
if (positionId === "action_plan") return "内置规划节点";
|
||||||
|
return positionNameById(positionId);
|
||||||
|
}
|
||||||
|
|
||||||
function parseIntentSummary(snapshot: Record<string, unknown> | null | undefined): IntentSummary | null {
|
function parseIntentSummary(snapshot: Record<string, unknown> | null | undefined): IntentSummary | null {
|
||||||
if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) {
|
if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -558,6 +618,8 @@ export default function PositionRoundtablePage() {
|
|||||||
>
|
>
|
||||||
{positionSession.status === "archived"
|
{positionSession.status === "archived"
|
||||||
? "会话已归档"
|
? "会话已归档"
|
||||||
|
: positionSession.status === "completed"
|
||||||
|
? "行动规划已生成"
|
||||||
: positionSession.status === "active"
|
: positionSession.status === "active"
|
||||||
? "业务链已启动"
|
? "业务链已启动"
|
||||||
: "意图识别中"}
|
: "意图识别中"}
|
||||||
@@ -635,6 +697,7 @@ export default function PositionRoundtablePage() {
|
|||||||
<BusinessChainFlow
|
<BusinessChainFlow
|
||||||
chain={activeChain}
|
chain={activeChain}
|
||||||
nodes={positionSession?.nodes ?? []}
|
nodes={positionSession?.nodes ?? []}
|
||||||
|
session={positionSession}
|
||||||
activeNodeKey={activeNodeKey}
|
activeNodeKey={activeNodeKey}
|
||||||
onSelectNode={(node) => {
|
onSelectNode={(node) => {
|
||||||
setActiveNodeKey(node.nodeKey);
|
setActiveNodeKey(node.nodeKey);
|
||||||
@@ -726,6 +789,7 @@ export default function PositionRoundtablePage() {
|
|||||||
intentCompletedAt={intentCompletedAt}
|
intentCompletedAt={intentCompletedAt}
|
||||||
assignedAgentIds={assignedAgentIds}
|
assignedAgentIds={assignedAgentIds}
|
||||||
hasSelectedChain={Boolean(selectedChain)}
|
hasSelectedChain={Boolean(selectedChain)}
|
||||||
|
session={positionSession}
|
||||||
onConfigureChain={
|
onConfigureChain={
|
||||||
selectedChain ? () => openPositionConfig(selectedChain.id) : undefined
|
selectedChain ? () => openPositionConfig(selectedChain.id) : undefined
|
||||||
}
|
}
|
||||||
@@ -735,6 +799,7 @@ export default function PositionRoundtablePage() {
|
|||||||
setActiveNodeKey(node.nodeKey);
|
setActiveNodeKey(node.nodeKey);
|
||||||
setSelectedAgentId(node.agentId);
|
setSelectedAgentId(node.agentId);
|
||||||
}}
|
}}
|
||||||
|
onSessionUpdate={setPositionSession}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -1109,11 +1174,13 @@ const FLOW_NODE_GAP = 10;
|
|||||||
function BusinessChainFlow({
|
function BusinessChainFlow({
|
||||||
chain,
|
chain,
|
||||||
nodes,
|
nodes,
|
||||||
|
session,
|
||||||
activeNodeKey,
|
activeNodeKey,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
}: {
|
}: {
|
||||||
chain: BusinessChain | null;
|
chain: BusinessChain | null;
|
||||||
nodes: PositionRoundtableNode[];
|
nodes: PositionRoundtableNode[];
|
||||||
|
session: PositionRoundtableSession | null;
|
||||||
activeNodeKey: string | null;
|
activeNodeKey: string | null;
|
||||||
onSelectNode: (node: PositionRoundtableNode) => void;
|
onSelectNode: (node: PositionRoundtableNode) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -1134,11 +1201,11 @@ function BusinessChainFlow({
|
|||||||
<TButton
|
<TButton
|
||||||
size="small"
|
size="small"
|
||||||
variant="text"
|
variant="text"
|
||||||
icon={<Eye className="size-3.5" />}
|
shape="circle"
|
||||||
|
icon={<Search className="size-3.5" />}
|
||||||
onClick={() => setFlowDialogOpen(true)}
|
onClick={() => setFlowDialogOpen(true)}
|
||||||
>
|
title="查看完整流程图"
|
||||||
查看
|
/>
|
||||||
</TButton>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!chain ? (
|
{!chain ? (
|
||||||
@@ -1150,12 +1217,13 @@ function BusinessChainFlow({
|
|||||||
<ChainFlowCanvas
|
<ChainFlowCanvas
|
||||||
chain={chain}
|
chain={chain}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
|
session={session}
|
||||||
activeNodeKey={activeNodeKey}
|
activeNodeKey={activeNodeKey}
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
className="h-[456px]"
|
className="h-[456px]"
|
||||||
/>
|
/>
|
||||||
<Dialog open={flowDialogOpen} onOpenChange={setFlowDialogOpen}>
|
<Dialog open={flowDialogOpen} onOpenChange={setFlowDialogOpen}>
|
||||||
<DialogContent className="flex h-[82vh] max-w-[1040px] flex-col gap-0 overflow-hidden p-0">
|
<DialogContent className="flex h-[88vh] w-[96vw] !max-w-[96vw] flex-col gap-0 overflow-hidden p-0">
|
||||||
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
|
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
|
||||||
<DialogTitle className="flex items-center gap-2 text-base">
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
<Layers3 className="size-4 text-primary" />
|
<Layers3 className="size-4 text-primary" />
|
||||||
@@ -1166,6 +1234,7 @@ function BusinessChainFlow({
|
|||||||
<ChainFlowCanvas
|
<ChainFlowCanvas
|
||||||
chain={chain}
|
chain={chain}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
|
session={session}
|
||||||
activeNodeKey={activeNodeKey}
|
activeNodeKey={activeNodeKey}
|
||||||
onSelectNode={(node) => {
|
onSelectNode={(node) => {
|
||||||
onSelectNode(node);
|
onSelectNode(node);
|
||||||
@@ -1186,6 +1255,7 @@ function BusinessChainFlow({
|
|||||||
function ChainFlowCanvas({
|
function ChainFlowCanvas({
|
||||||
chain,
|
chain,
|
||||||
nodes,
|
nodes,
|
||||||
|
session,
|
||||||
activeNodeKey,
|
activeNodeKey,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
className,
|
className,
|
||||||
@@ -1193,6 +1263,7 @@ function ChainFlowCanvas({
|
|||||||
}: {
|
}: {
|
||||||
chain: BusinessChain;
|
chain: BusinessChain;
|
||||||
nodes: PositionRoundtableNode[];
|
nodes: PositionRoundtableNode[];
|
||||||
|
session: PositionRoundtableSession | null;
|
||||||
activeNodeKey: string | null;
|
activeNodeKey: string | null;
|
||||||
onSelectNode: (node: PositionRoundtableNode) => void;
|
onSelectNode: (node: PositionRoundtableNode) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -1210,6 +1281,11 @@ function ChainFlowCanvas({
|
|||||||
const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH;
|
const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH;
|
||||||
const nodeGap = expanded ? 16 : FLOW_NODE_GAP;
|
const nodeGap = expanded ? 16 : FLOW_NODE_GAP;
|
||||||
const allDone = allExecutableNodesDone(nodes);
|
const allDone = allExecutableNodesDone(nodes);
|
||||||
|
const summaryStatus = builtinNodeStatus(session?.summarySnapshot, allDone);
|
||||||
|
const actionPlanStatus = builtinNodeStatus(
|
||||||
|
session?.actionPlanSnapshot,
|
||||||
|
snapshotStatus(session?.summarySnapshot) === "done",
|
||||||
|
);
|
||||||
|
|
||||||
const onPanStart = (event: React.PointerEvent<HTMLDivElement>) => {
|
const onPanStart = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
@@ -1322,18 +1398,18 @@ function ChainFlowCanvas({
|
|||||||
title="方案总结智能体"
|
title="方案总结智能体"
|
||||||
description="内置总结节点"
|
description="内置总结节点"
|
||||||
accent="#6366f1"
|
accent="#6366f1"
|
||||||
nodeStatus={allDone ? "ready" : "locked"}
|
nodeStatus={summaryStatus}
|
||||||
width={nodeWidth}
|
width={nodeWidth}
|
||||||
titleAttr="待接入:对所有有效交付产物进行方案总结"
|
titleAttr="对所有有效交付产物进行方案总结"
|
||||||
/>
|
/>
|
||||||
<VerticalFlowConnector seatCount={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
|
<VerticalFlowConnector seatCount={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
|
||||||
<CompactChainNode
|
<CompactChainNode
|
||||||
title="行动规划智能体"
|
title="行动规划智能体"
|
||||||
description="内置规划节点"
|
description="内置规划节点"
|
||||||
accent="#0ea5e9"
|
accent="#0ea5e9"
|
||||||
nodeStatus="locked"
|
nodeStatus={actionPlanStatus}
|
||||||
width={nodeWidth}
|
width={nodeWidth}
|
||||||
titleAttr="待接入:基于方案总结和岗位产物拆解子任务"
|
titleAttr="基于方案总结和岗位产物拆解子任务"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1353,7 +1429,11 @@ function VerticalFlowConnector({
|
|||||||
}) {
|
}) {
|
||||||
const nodeCenter = nodeWidth / 2;
|
const nodeCenter = nodeWidth / 2;
|
||||||
if (seatCount <= 1) {
|
if (seatCount <= 1) {
|
||||||
return <span className="h-7 w-px bg-slate-300 dark:bg-[#2b8ffe]/55" />;
|
return (
|
||||||
|
<span className="relative h-7 w-px bg-slate-300 dark:bg-[#2b8ffe]/55">
|
||||||
|
<span className="absolute -bottom-0.5 left-1/2 size-1.5 -translate-x-1/2 rotate-45 border-b border-r border-slate-300 dark:border-[#2b8ffe]/55" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1370,7 +1450,9 @@ function VerticalFlowConnector({
|
|||||||
style={{
|
style={{
|
||||||
left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`,
|
left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<span className="absolute -bottom-0.5 left-1/2 size-1.5 -translate-x-1/2 rotate-45 border-b border-r border-slate-300 dark:border-[#2b8ffe]/55" />
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1442,10 +1524,12 @@ function RightWorkspace({
|
|||||||
intentCompletedAt,
|
intentCompletedAt,
|
||||||
assignedAgentIds,
|
assignedAgentIds,
|
||||||
hasSelectedChain,
|
hasSelectedChain,
|
||||||
|
session,
|
||||||
onConfigureChain,
|
onConfigureChain,
|
||||||
nodes,
|
nodes,
|
||||||
chain,
|
chain,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
|
onSessionUpdate,
|
||||||
}: {
|
}: {
|
||||||
selectedAgent: Agent | null;
|
selectedAgent: Agent | null;
|
||||||
selectedAgentId: string | null;
|
selectedAgentId: string | null;
|
||||||
@@ -1455,10 +1539,12 @@ function RightWorkspace({
|
|||||||
intentCompletedAt: string;
|
intentCompletedAt: string;
|
||||||
assignedAgentIds: Set<string>;
|
assignedAgentIds: Set<string>;
|
||||||
hasSelectedChain: boolean;
|
hasSelectedChain: boolean;
|
||||||
|
session: PositionRoundtableSession | null;
|
||||||
onConfigureChain?: () => void;
|
onConfigureChain?: () => void;
|
||||||
nodes: PositionRoundtableNode[];
|
nodes: PositionRoundtableNode[];
|
||||||
chain: BusinessChain | null;
|
chain: BusinessChain | null;
|
||||||
onSelectNode: (node: PositionRoundtableNode) => void;
|
onSelectNode: (node: PositionRoundtableNode) => void;
|
||||||
|
onSessionUpdate: (session: PositionRoundtableSession) => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<WorkspaceTab>("agents");
|
const [tab, setTab] = useState<WorkspaceTab>("agents");
|
||||||
|
|
||||||
@@ -1540,7 +1626,9 @@ function RightWorkspace({
|
|||||||
intentCompletedAt={intentCompletedAt}
|
intentCompletedAt={intentCompletedAt}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
chain={chain}
|
chain={chain}
|
||||||
|
session={session}
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
|
onSessionUpdate={onSessionUpdate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1706,21 +1794,9 @@ function PositionAgentList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AnalysisResults({
|
type ResultKind = "intent" | "delivery" | "artifact" | "summary" | "action_plan";
|
||||||
intentObjective,
|
|
||||||
intentCompletedAt,
|
interface ResultItem {
|
||||||
nodes,
|
|
||||||
chain,
|
|
||||||
onSelectNode,
|
|
||||||
}: {
|
|
||||||
intentObjective?: string;
|
|
||||||
intentCompletedAt: string;
|
|
||||||
nodes: PositionRoundtableNode[];
|
|
||||||
chain: BusinessChain | null;
|
|
||||||
onSelectNode: (node: PositionRoundtableNode) => void;
|
|
||||||
}) {
|
|
||||||
type ResultKind = "intent" | "delivery" | "artifact";
|
|
||||||
interface ResultItem {
|
|
||||||
id: string;
|
id: string;
|
||||||
kind: ResultKind;
|
kind: ResultKind;
|
||||||
node: PositionRoundtableNode | null;
|
node: PositionRoundtableNode | null;
|
||||||
@@ -1730,15 +1806,52 @@ function AnalysisResults({
|
|||||||
positionId: string | null;
|
positionId: string | null;
|
||||||
updatedAt: string | undefined;
|
updatedAt: string | undefined;
|
||||||
status?: NodeVisualStatus;
|
status?: NodeVisualStatus;
|
||||||
}
|
artifactPath?: string;
|
||||||
|
artifactMimeType?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalysisResults({
|
||||||
|
intentObjective,
|
||||||
|
intentCompletedAt,
|
||||||
|
nodes,
|
||||||
|
chain,
|
||||||
|
session,
|
||||||
|
onSelectNode,
|
||||||
|
onSessionUpdate,
|
||||||
|
}: {
|
||||||
|
intentObjective?: string;
|
||||||
|
intentCompletedAt: string;
|
||||||
|
nodes: PositionRoundtableNode[];
|
||||||
|
chain: BusinessChain | null;
|
||||||
|
session: PositionRoundtableSession | null;
|
||||||
|
onSelectNode: (node: PositionRoundtableNode) => void;
|
||||||
|
onSessionUpdate: (session: PositionRoundtableSession) => void;
|
||||||
|
}) {
|
||||||
const [positionFilter, setPositionFilter] = useState("all");
|
const [positionFilter, setPositionFilter] = useState("all");
|
||||||
const [nodeFilter, setNodeFilter] = useState("all");
|
const [nodeFilter, setNodeFilter] = useState("all");
|
||||||
const [kindFilter, setKindFilter] = useState<"all" | ResultKind>("all");
|
const [kindFilter, setKindFilter] = useState<"all" | ResultKind>("all");
|
||||||
|
const [detailItem, setDetailItem] = useState<ResultItem | null>(null);
|
||||||
|
const [rejectNode, setRejectNode] = useState<PositionRoundtableNode | null>(null);
|
||||||
|
const [rejectTitle, setRejectTitle] = useState("");
|
||||||
|
const [rejectReason, setRejectReason] = useState("");
|
||||||
|
const [rejectSubmitting, setRejectSubmitting] = useState(false);
|
||||||
|
const [summarySubmitting, setSummarySubmitting] = useState(false);
|
||||||
|
const [actionPlanSubmitting, setActionPlanSubmitting] = useState(false);
|
||||||
|
const [previewArtifact, setPreviewArtifact] = useState<{
|
||||||
|
threadId: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string | null;
|
||||||
|
} | null>(null);
|
||||||
const seatsByAgentId = useMemo(
|
const seatsByAgentId = useMemo(
|
||||||
() => new Map((chain?.seats ?? []).map((seat) => [seat.agent_id, seat])),
|
() => new Map((chain?.seats ?? []).map((seat) => [seat.agent_id, seat])),
|
||||||
[chain],
|
[chain],
|
||||||
);
|
);
|
||||||
|
const allDelivered = allExecutableNodesDone(nodes);
|
||||||
|
const summaryDone = snapshotStatus(session?.summarySnapshot) === "done";
|
||||||
|
const summaryStale = snapshotStatus(session?.summarySnapshot) === "stale";
|
||||||
|
const actionPlanDone = snapshotStatus(session?.actionPlanSnapshot) === "done";
|
||||||
|
const actionPlanStale = snapshotStatus(session?.actionPlanSnapshot) === "stale";
|
||||||
const resultItems = useMemo<ResultItem[]>(() => {
|
const resultItems = useMemo<ResultItem[]>(() => {
|
||||||
const items: ResultItem[] = [];
|
const items: ResultItem[] = [];
|
||||||
if (intentObjective) {
|
if (intentObjective) {
|
||||||
@@ -1773,7 +1886,9 @@ function AnalysisResults({
|
|||||||
const summary =
|
const summary =
|
||||||
node.latestAnswer ||
|
node.latestAnswer ||
|
||||||
(node.status === "locked"
|
(node.status === "locked"
|
||||||
? "等待上游阶段完成后解锁。"
|
? node.invalidatedBy
|
||||||
|
? "上游产物已被驳回或重做,等待前序节点重新完成。"
|
||||||
|
: "等待上游阶段完成后解锁。"
|
||||||
: node.status === "running"
|
: node.status === "running"
|
||||||
? "智能体正在生成回答与产物。"
|
? "智能体正在生成回答与产物。"
|
||||||
: node.status === "stale"
|
: node.status === "stale"
|
||||||
@@ -1814,11 +1929,49 @@ function AnalysisResults({
|
|||||||
positionId: node.positionId,
|
positionId: node.positionId,
|
||||||
updatedAt: node.updatedAt,
|
updatedAt: node.updatedAt,
|
||||||
status: node.status,
|
status: node.status,
|
||||||
|
artifactPath: path,
|
||||||
|
artifactMimeType: mimeType,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const summarySnapshot = session?.summarySnapshot;
|
||||||
|
if (summarySnapshot) {
|
||||||
|
items.push({
|
||||||
|
id: "builtin:summary",
|
||||||
|
kind: "summary",
|
||||||
|
node: null,
|
||||||
|
agentName: "方案总结智能体",
|
||||||
|
productName: snapshotText(summarySnapshot, "title") || "岗位会商方案总结报告",
|
||||||
|
summary:
|
||||||
|
snapshotText(summarySnapshot, "content") ||
|
||||||
|
(snapshotStatus(summarySnapshot) === "stale"
|
||||||
|
? "前序岗位产物已变更,需要重新生成方案总结。"
|
||||||
|
: "方案总结已生成。"),
|
||||||
|
positionId: "summary",
|
||||||
|
updatedAt: snapshotText(summarySnapshot, "generated_at") || undefined,
|
||||||
|
status: builtinNodeStatus(summarySnapshot, true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const actionPlanSnapshot = session?.actionPlanSnapshot;
|
||||||
|
if (actionPlanSnapshot) {
|
||||||
|
items.push({
|
||||||
|
id: "builtin:action-plan",
|
||||||
|
kind: "action_plan",
|
||||||
|
node: null,
|
||||||
|
agentName: "行动规划智能体",
|
||||||
|
productName: snapshotText(actionPlanSnapshot, "title") || "岗位会商行动规划",
|
||||||
|
summary:
|
||||||
|
snapshotText(actionPlanSnapshot, "content") ||
|
||||||
|
(snapshotStatus(actionPlanSnapshot) === "stale"
|
||||||
|
? "前序岗位产物已变更,需要重新生成行动规划。"
|
||||||
|
: "行动规划已生成。"),
|
||||||
|
positionId: "action_plan",
|
||||||
|
updatedAt: snapshotText(actionPlanSnapshot, "generated_at") || undefined,
|
||||||
|
status: builtinNodeStatus(actionPlanSnapshot, true),
|
||||||
|
});
|
||||||
|
}
|
||||||
return items.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""));
|
return items.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""));
|
||||||
}, [intentObjective, nodes, seatsByAgentId]);
|
}, [intentObjective, nodes, seatsByAgentId, session?.actionPlanSnapshot, session?.summarySnapshot]);
|
||||||
const positionOptions = useMemo(
|
const positionOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ label: "全部岗位", value: "all" },
|
{ label: "全部岗位", value: "all" },
|
||||||
@@ -1851,6 +2004,91 @@ function AnalysisResults({
|
|||||||
[kindFilter, nodeFilter, positionFilter, resultItems],
|
[kindFilter, nodeFilter, positionFilter, resultItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openDetail = useCallback(
|
||||||
|
(item: ResultItem) => {
|
||||||
|
setDetailItem(item);
|
||||||
|
if (item.node) {
|
||||||
|
onSelectNode(item.node);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelectNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openRejectDialog = useCallback((node: PositionRoundtableNode) => {
|
||||||
|
setRejectNode(node);
|
||||||
|
setRejectTitle(nodeRejectionTitle(node));
|
||||||
|
setRejectReason("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRejectSubmit = useCallback(async () => {
|
||||||
|
if (!rejectNode) return;
|
||||||
|
const title = rejectTitle.trim();
|
||||||
|
const reason = rejectReason.trim();
|
||||||
|
if (!title || !reason) {
|
||||||
|
toast.warning("请填写驳回标题和理由");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRejectSubmitting(true);
|
||||||
|
try {
|
||||||
|
const session = await rejectPositionNode(rejectNode.sessionId, rejectNode.nodeKey, {
|
||||||
|
title,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
onSessionUpdate(session);
|
||||||
|
setRejectNode(null);
|
||||||
|
setRejectTitle("");
|
||||||
|
setRejectReason("");
|
||||||
|
setDetailItem(null);
|
||||||
|
toast.success("已驳回该产物,后续阶段已标记为需重新完成");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`驳回失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setRejectSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [onSessionUpdate, rejectNode, rejectReason, rejectTitle]);
|
||||||
|
|
||||||
|
const handleGenerateSummary = useCallback(async () => {
|
||||||
|
if (!session) {
|
||||||
|
toast.warning("请先启动业务链");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!allDelivered) {
|
||||||
|
toast.warning("所有岗位产物完成后才能生成方案总结");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSummarySubmitting(true);
|
||||||
|
try {
|
||||||
|
const next = await generatePositionSummary(session.id);
|
||||||
|
onSessionUpdate(next);
|
||||||
|
toast.success("方案总结已生成");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`生成方案总结失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setSummarySubmitting(false);
|
||||||
|
}
|
||||||
|
}, [allDelivered, onSessionUpdate, session]);
|
||||||
|
|
||||||
|
const handleGenerateActionPlan = useCallback(async () => {
|
||||||
|
if (!session) {
|
||||||
|
toast.warning("请先启动业务链");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!summaryDone) {
|
||||||
|
toast.warning("请先生成有效的方案总结");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionPlanSubmitting(true);
|
||||||
|
try {
|
||||||
|
const next = await generatePositionActionPlan(session.id);
|
||||||
|
onSessionUpdate(next);
|
||||||
|
toast.success("行动规划已生成");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`生成行动规划失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setActionPlanSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [onSessionUpdate, session, summaryDone]);
|
||||||
|
|
||||||
if (resultItems.length === 0) {
|
if (resultItems.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[260px] flex-col items-center justify-center rounded-xl border border-dashed p-6 text-center">
|
<div className="flex h-full min-h-[260px] flex-col items-center justify-center rounded-xl border border-dashed p-6 text-center">
|
||||||
@@ -1864,6 +2102,7 @@ function AnalysisResults({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="space-y-3 rounded-xl border border-slate-200 p-3.5 dark:border-border">
|
<div className="space-y-3 rounded-xl border border-slate-200 p-3.5 dark:border-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -1885,10 +2124,62 @@ function AnalysisResults({
|
|||||||
{ label: "意图理解", value: "intent" },
|
{ label: "意图理解", value: "intent" },
|
||||||
{ label: "节点交付", value: "delivery" },
|
{ label: "节点交付", value: "delivery" },
|
||||||
{ label: "文件产物", value: "artifact" },
|
{ label: "文件产物", value: "artifact" },
|
||||||
|
{ label: "方案总结", value: "summary" },
|
||||||
|
{ label: "行动规划", value: "action_plan" },
|
||||||
]}
|
]}
|
||||||
onChange={(value) => setKindFilter(String(value) as "all" | ResultKind)}
|
onChange={(value) => setKindFilter(String(value) as "all" | ResultKind)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-3 dark:border-[#2b8ffe]/30 dark:bg-[#032650]/65">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-bold text-slate-800 dark:text-[#e8edf5]">
|
||||||
|
内置收口智能体
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate text-[11px] text-muted-foreground dark:text-[#8aa0c4]">
|
||||||
|
全部岗位交付后生成方案总结,再生成行动规划
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold",
|
||||||
|
allDelivered
|
||||||
|
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "bg-slate-500/10 text-slate-500 dark:text-[#8aa0c4]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{nodes.filter((node) => node.status === "done").length}/{nodes.length} 已交付
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<TButton
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
theme={summaryStale ? "warning" : "primary"}
|
||||||
|
variant={summaryDone ? "outline" : "base"}
|
||||||
|
loading={summarySubmitting}
|
||||||
|
disabled={!allDelivered || summarySubmitting}
|
||||||
|
icon={<Sparkles className="size-3.5" />}
|
||||||
|
onClick={() => void handleGenerateSummary()}
|
||||||
|
>
|
||||||
|
{summaryDone ? "重新生成方案总结" : summaryStale ? "重新生成失效总结" : "生成方案总结"}
|
||||||
|
</TButton>
|
||||||
|
<TButton
|
||||||
|
block
|
||||||
|
size="small"
|
||||||
|
theme={actionPlanStale ? "warning" : "primary"}
|
||||||
|
variant={actionPlanDone ? "outline" : "base"}
|
||||||
|
loading={actionPlanSubmitting}
|
||||||
|
disabled={!summaryDone || actionPlanSubmitting}
|
||||||
|
icon={<Target className="size-3.5" />}
|
||||||
|
onClick={() => void handleGenerateActionPlan()}
|
||||||
|
>
|
||||||
|
{actionPlanDone ? "重新生成行动规划" : actionPlanStale ? "重新生成失效规划" : "生成行动规划"}
|
||||||
|
</TButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{visibleItems.length === 0 ? (
|
{visibleItems.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed p-5 text-center text-xs text-muted-foreground">
|
<div className="rounded-xl border border-dashed p-5 text-center text-xs text-muted-foreground">
|
||||||
当前筛选条件下没有结果
|
当前筛选条件下没有结果
|
||||||
@@ -1905,6 +2196,7 @@ function AnalysisResults({
|
|||||||
}).format(new Date(item.updatedAt))
|
}).format(new Date(item.updatedAt))
|
||||||
: intentCompletedAt || "刚刚";
|
: intentCompletedAt || "刚刚";
|
||||||
const stale = item.node?.status === "stale";
|
const stale = item.node?.status === "stale";
|
||||||
|
const invalidated = Boolean(item.node?.invalidatedBy);
|
||||||
const status = item.status ? nodeStatusMeta(item.status) : null;
|
const status = item.status ? nodeStatusMeta(item.status) : null;
|
||||||
const StatusIcon = status?.Icon;
|
const StatusIcon = status?.Icon;
|
||||||
const contents = (
|
const contents = (
|
||||||
@@ -1939,7 +2231,7 @@ function AnalysisResults({
|
|||||||
{status.label}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{stale && (
|
{(stale || invalidated) && (
|
||||||
<span className="shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700 dark:text-amber-300">
|
<span className="shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold text-amber-700 dark:text-amber-300">
|
||||||
上游已更新
|
上游已更新
|
||||||
</span>
|
</span>
|
||||||
@@ -1956,7 +2248,7 @@ function AnalysisResults({
|
|||||||
<footer className="mt-4 flex items-center justify-between border-t pt-3 text-[11px] text-muted-foreground">
|
<footer className="mt-4 flex items-center justify-between border-t pt-3 text-[11px] text-muted-foreground">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<BriefcaseBusiness className="size-3.5" />
|
<BriefcaseBusiness className="size-3.5" />
|
||||||
{positionNameById(item.positionId)}
|
{resultPositionName(item.positionId)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Clock3 className="size-3.5" />
|
<Clock3 className="size-3.5" />
|
||||||
@@ -1969,18 +2261,272 @@ function AnalysisResults({
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectNode(item.node!)}
|
onClick={() => openDetail(item)}
|
||||||
className="w-full rounded-xl border border-slate-200 bg-card p-3.5 text-left shadow-sm transition hover:border-primary/45 hover:shadow-md dark:border-border"
|
className={cn(
|
||||||
|
"w-full rounded-xl border border-slate-200 bg-card p-3.5 text-left shadow-sm transition hover:border-primary/45 hover:shadow-md dark:border-border",
|
||||||
|
item.node.status === "rejected" && "border-rose-200 bg-rose-50/60 dark:border-rose-500/35 dark:bg-rose-950/20",
|
||||||
|
invalidated && "border-amber-200 bg-amber-50/60 dark:border-amber-500/35 dark:bg-amber-950/20",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{contents}
|
{contents}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<article key={item.id} className="rounded-xl border border-slate-200 bg-card p-3.5 shadow-sm dark:border-border">
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => openDetail(item)}
|
||||||
|
className="w-full rounded-xl border border-slate-200 bg-card p-3.5 text-left shadow-sm transition hover:border-primary/45 hover:shadow-md dark:border-border"
|
||||||
|
>
|
||||||
{contents}
|
{contents}
|
||||||
</article>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DeliverableDetailDialog
|
||||||
|
item={detailItem}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDetailItem(null);
|
||||||
|
}}
|
||||||
|
onReject={(node) => openRejectDialog(node)}
|
||||||
|
onPreviewArtifact={(item) => {
|
||||||
|
if (!item.node?.threadId || !item.artifactPath) {
|
||||||
|
toast.info("该文件产物暂无可预览路径");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewArtifact({
|
||||||
|
threadId: item.node.threadId,
|
||||||
|
path: item.artifactPath,
|
||||||
|
name: item.productName,
|
||||||
|
mimeType: item.artifactMimeType ?? null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RejectDeliveryDialog
|
||||||
|
node={rejectNode}
|
||||||
|
title={rejectTitle}
|
||||||
|
reason={rejectReason}
|
||||||
|
submitting={rejectSubmitting}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !rejectSubmitting) {
|
||||||
|
setRejectNode(null);
|
||||||
|
setRejectTitle("");
|
||||||
|
setRejectReason("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTitleChange={setRejectTitle}
|
||||||
|
onReasonChange={setRejectReason}
|
||||||
|
onSubmit={() => void handleRejectSubmit()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{previewArtifact && (
|
||||||
|
<ArtifactPreviewModal
|
||||||
|
threadId={previewArtifact.threadId}
|
||||||
|
path={previewArtifact.path}
|
||||||
|
name={previewArtifact.name}
|
||||||
|
mimeType={previewArtifact.mimeType}
|
||||||
|
onClose={() => setPreviewArtifact(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeliverableDetailDialog({
|
||||||
|
item,
|
||||||
|
onOpenChange,
|
||||||
|
onReject,
|
||||||
|
onPreviewArtifact,
|
||||||
|
}: {
|
||||||
|
item: ResultItem | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onReject: (node: PositionRoundtableNode) => void;
|
||||||
|
onPreviewArtifact: (item: ResultItem) => void;
|
||||||
|
}) {
|
||||||
|
const status = item?.status ? nodeStatusMeta(item.status) : null;
|
||||||
|
const StatusIcon = status?.Icon;
|
||||||
|
const canReject = item?.node ? canRejectNodeDelivery(item.node) : false;
|
||||||
|
const rejectionTitle = item?.node ? nodeRejectionTitle(item.node) : "";
|
||||||
|
const rejectionReason = item?.node ? nodeRejectionReason(item.node) : "";
|
||||||
|
const displayedTime = item?.updatedAt
|
||||||
|
? new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date(item.updatedAt))
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[82vh] w-[720px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0">
|
||||||
|
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileOutput className="size-4 text-primary" />
|
||||||
|
交付产物详情
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{!item ? null : (
|
||||||
|
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
|
||||||
|
{desensitize(item.productName)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||||
|
{desensitize(item.agentName)} · {resultPositionName(item.positionId)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{status && StatusIcon && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-1 text-[11px] font-semibold",
|
||||||
|
status.badgeClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusIcon className={cn("size-3.5", status.spin && "animate-spin")} />
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-2 text-[11px] text-muted-foreground">
|
||||||
|
<div className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-[#032650]">
|
||||||
|
岗位:{resultPositionName(item.positionId)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-[#032650]">
|
||||||
|
时间:{displayedTime || "暂无"}
|
||||||
|
</div>
|
||||||
|
{item.node && (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-[#032650]">
|
||||||
|
版本:第 {item.node.revision} 版
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-[#032650]">
|
||||||
|
驳回次数:{item.node.rejectionCount}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.node?.invalidatedBy && (
|
||||||
|
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500/35 dark:bg-amber-950/25 dark:text-amber-200">
|
||||||
|
该产物受上游节点「{item.node.invalidatedBy}」影响,已不再作为后续总结或问答输入。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rejectionTitle && (
|
||||||
|
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs leading-5 text-rose-800 dark:border-rose-500/35 dark:bg-rose-950/25 dark:text-rose-200">
|
||||||
|
<p className="font-semibold">最近驳回:{desensitize(rejectionTitle)}</p>
|
||||||
|
{rejectionReason && <p className="mt-1">{desensitize(rejectionReason)}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/65">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">
|
||||||
|
{item.kind === "artifact" ? "文件产物" : "产物内容"}
|
||||||
|
</p>
|
||||||
|
{item.kind === "artifact" && (
|
||||||
|
<TButton
|
||||||
|
size="small"
|
||||||
|
variant="outline"
|
||||||
|
icon={<FileText className="size-3.5" />}
|
||||||
|
onClick={() => onPreviewArtifact(item)}
|
||||||
|
>
|
||||||
|
查看文件
|
||||||
|
</TButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-xs leading-6 text-slate-600 dark:text-[#c5cfe0]">
|
||||||
|
{desensitize(item.summary || "暂无产物正文")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="shrink-0 border-t px-5 py-3 dark:border-[#2b8ffe]/25">
|
||||||
|
{item?.node && (
|
||||||
|
<TButton
|
||||||
|
theme="danger"
|
||||||
|
variant="outline"
|
||||||
|
icon={<RotateCcw className="size-3.5" />}
|
||||||
|
disabled={!canReject}
|
||||||
|
onClick={() => onReject(item.node!)}
|
||||||
|
>
|
||||||
|
驳回产物
|
||||||
|
</TButton>
|
||||||
|
)}
|
||||||
|
<TButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
关闭
|
||||||
|
</TButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RejectDeliveryDialog({
|
||||||
|
node,
|
||||||
|
title,
|
||||||
|
reason,
|
||||||
|
submitting,
|
||||||
|
onOpenChange,
|
||||||
|
onTitleChange,
|
||||||
|
onReasonChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
node: PositionRoundtableNode | null;
|
||||||
|
title: string;
|
||||||
|
reason: string;
|
||||||
|
submitting: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onTitleChange: (value: string) => void;
|
||||||
|
onReasonChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={Boolean(node)} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-[560px] !max-w-[calc(100vw-2rem)]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
<RotateCcw className="size-4 text-destructive" />
|
||||||
|
驳回交付产物
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500/35 dark:bg-amber-950/25 dark:text-amber-200">
|
||||||
|
驳回后,该节点会回到需重做状态;其后续阶段的产物会一起失效,但历史内容仍可查看。
|
||||||
|
</div>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-xs font-semibold text-slate-700 dark:text-[#c5cfe0]">驳回标题</span>
|
||||||
|
<TInput
|
||||||
|
value={title}
|
||||||
|
placeholder="例如:依据不足,需要补充约束条件"
|
||||||
|
onChange={onTitleChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-xs font-semibold text-slate-700 dark:text-[#c5cfe0]">驳回理由</span>
|
||||||
|
<Textarea
|
||||||
|
rows={5}
|
||||||
|
value={reason}
|
||||||
|
placeholder="请说明需要重做的原因、补充材料或调整方向"
|
||||||
|
onChange={(event) => onReasonChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<TButton variant="outline" disabled={submitting} onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</TButton>
|
||||||
|
<TButton theme="danger" loading={submitting} onClick={onSubmit}>
|
||||||
|
确认驳回
|
||||||
|
</TButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ messages and artifacts stay in the established LangGraph thread stores.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -52,6 +53,9 @@ class NodeResponse(BaseModel):
|
|||||||
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
|
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
revision: int = 0
|
revision: int = 0
|
||||||
upstream_revision: dict[str, Any] | None = None
|
upstream_revision: dict[str, Any] | None = None
|
||||||
|
rejection_count: int = 0
|
||||||
|
last_rejection: dict[str, Any] | None = None
|
||||||
|
invalidated_by: str | None = None
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
@@ -64,6 +68,8 @@ class SessionResponse(BaseModel):
|
|||||||
chain_snapshot: dict[str, Any] | None = None
|
chain_snapshot: dict[str, Any] | None = None
|
||||||
intent_thread_id: str | None = None
|
intent_thread_id: str | None = None
|
||||||
intent_snapshot: dict[str, Any] | None = None
|
intent_snapshot: dict[str, Any] | None = None
|
||||||
|
summary_snapshot: dict[str, Any] | None = None
|
||||||
|
action_plan_snapshot: dict[str, Any] | None = None
|
||||||
status: str
|
status: str
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
@@ -101,6 +107,11 @@ class CompleteTurnRequest(BaseModel):
|
|||||||
upstream_revision: dict[str, Any] | None = None
|
upstream_revision: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RejectNodeRequest(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=120)
|
||||||
|
reason: str = Field(min_length=1, max_length=5_000)
|
||||||
|
|
||||||
|
|
||||||
def _current_user_id(request: Request) -> str:
|
def _current_user_id(request: Request) -> str:
|
||||||
user = getattr(request.state, "user", None)
|
user = getattr(request.state, "user", None)
|
||||||
return str(user.id) if user is not None else get_effective_user_id()
|
return str(user.id) if user is not None else get_effective_user_id()
|
||||||
@@ -229,6 +240,175 @@ def _truncate_text(value: Any, limit: int) -> str:
|
|||||||
return f"{text[:limit]}\n…(已按交接上下文长度截断)"
|
return f"{text[:limit]}\n…(已按交接上下文长度截断)"
|
||||||
|
|
||||||
|
|
||||||
|
def _text_from_keys(source: dict[str, Any], keys: list[str], fallback: str = "") -> str:
|
||||||
|
for key in keys:
|
||||||
|
value = source.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _seat_meta(session: dict[str, Any], node: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {}
|
||||||
|
seats = chain.get("seats") if isinstance(chain.get("seats"), list) else []
|
||||||
|
seat = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in seats
|
||||||
|
if isinstance(item, dict) and str(item.get("agent_id") or "") == str(node.get("agent_id") or "")
|
||||||
|
),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
agent_name = str(seat.get("name") or node.get("agent_id") or "未命名智能体")
|
||||||
|
position_id = str(node.get("position_id") or seat.get("position_id") or "未配置岗位")
|
||||||
|
return agent_name, position_id
|
||||||
|
|
||||||
|
|
||||||
|
def _delivered_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return [node for node in nodes if node.get("status") == "done"]
|
||||||
|
|
||||||
|
|
||||||
|
def _all_nodes_delivered(nodes: list[dict[str, Any]]) -> bool:
|
||||||
|
return bool(nodes) and all(node.get("status") == "done" for node in nodes)
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_lines(node: dict[str, Any]) -> list[str]:
|
||||||
|
artifacts = node.get("artifact_manifest")
|
||||||
|
if not isinstance(artifacts, list) or not artifacts:
|
||||||
|
return []
|
||||||
|
lines: list[str] = []
|
||||||
|
for artifact in artifacts[:8]:
|
||||||
|
if not isinstance(artifact, dict):
|
||||||
|
continue
|
||||||
|
name = artifact.get("name") or artifact.get("filename") or artifact.get("path") or "未命名产物"
|
||||||
|
path = artifact.get("path") or artifact.get("artifact_url") or ""
|
||||||
|
lines.append(f" - {name}{f'({path})' if path else ''}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _build_summary_snapshot(session: dict[str, Any], nodes: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
|
||||||
|
intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
|
||||||
|
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
|
||||||
|
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
|
||||||
|
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
|
||||||
|
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
|
||||||
|
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# 岗位会商方案总结报告",
|
||||||
|
"",
|
||||||
|
f"生成时间:{now}",
|
||||||
|
"",
|
||||||
|
"## 一、任务概况",
|
||||||
|
"",
|
||||||
|
f"- 任务名称:{title}",
|
||||||
|
f"- 任务方向:{direction}",
|
||||||
|
f"- 任务描述:{description}",
|
||||||
|
"",
|
||||||
|
"## 二、意图理解",
|
||||||
|
"",
|
||||||
|
f"- 核心目标:{objective}",
|
||||||
|
]
|
||||||
|
if constraints:
|
||||||
|
lines.append("- 关键约束:")
|
||||||
|
lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
|
||||||
|
lines.extend(["", "## 三、岗位交付汇总", ""])
|
||||||
|
for index, node in enumerate(_delivered_nodes(nodes), start=1):
|
||||||
|
agent_name, position_id = _seat_meta(session, node)
|
||||||
|
answer = _truncate_text(node.get("latest_answer"), 1_200) or "暂无文本回答"
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"### {index}. {agent_name}",
|
||||||
|
"",
|
||||||
|
f"- 岗位:{position_id}",
|
||||||
|
f"- 节点版本:第 {node.get('revision', 0)} 版",
|
||||||
|
"",
|
||||||
|
answer,
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
artifacts = _artifact_lines(node)
|
||||||
|
if artifacts:
|
||||||
|
lines.extend(["产物文件:", *artifacts, ""])
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"## 四、综合判断",
|
||||||
|
"",
|
||||||
|
"本报告基于当前所有有效岗位交付产物自动汇总生成,可作为后续行动规划的输入材料。若任一前序岗位产物被驳回,本报告将自动标记为失效并需要重新生成。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"kind": "summary",
|
||||||
|
"status": "done",
|
||||||
|
"title": "岗位会商方案总结报告",
|
||||||
|
"content": "\n".join(lines),
|
||||||
|
"generated_at": now,
|
||||||
|
"source_revisions": {node["node_key"]: node.get("revision", 0) for node in _delivered_nodes(nodes)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_action_plan_snapshot(session: dict[str, Any], nodes: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {}
|
||||||
|
delivered = _delivered_nodes(nodes)
|
||||||
|
tasks: list[dict[str, Any]] = []
|
||||||
|
for index, node in enumerate(delivered, start=1):
|
||||||
|
agent_name, position_id = _seat_meta(session, node)
|
||||||
|
tasks.append(
|
||||||
|
{
|
||||||
|
"id": f"subtask-{index:02d}",
|
||||||
|
"title": f"落实{agent_name}交付建议",
|
||||||
|
"owner_position": position_id,
|
||||||
|
"basis_node": node.get("node_key"),
|
||||||
|
"deliverable": f"{agent_name}整改/执行清单",
|
||||||
|
"acceptance": "补齐输入依据,形成可验证的阶段成果并回填岗位会商产物。",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
lines = [
|
||||||
|
"# 岗位会商行动规划",
|
||||||
|
"",
|
||||||
|
f"生成时间:{now}",
|
||||||
|
"",
|
||||||
|
"## 一、规划依据",
|
||||||
|
"",
|
||||||
|
f"- 方案总结:{summary.get('title') or '岗位会商方案总结报告'}",
|
||||||
|
f"- 有效岗位产物数量:{len(delivered)}",
|
||||||
|
"",
|
||||||
|
"## 二、子任务拆解",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for task in tasks:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"### {task['id']} {task['title']}",
|
||||||
|
"",
|
||||||
|
f"- 建议责任岗位:{task['owner_position']}",
|
||||||
|
f"- 输入依据节点:{task['basis_node']}",
|
||||||
|
f"- 交付物:{task['deliverable']}",
|
||||||
|
f"- 验收标准:{task['acceptance']}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"## 三、执行提示",
|
||||||
|
"",
|
||||||
|
"若前序岗位产物被驳回,本行动规划会自动失效;请先完成被驳回节点及后续节点的重新交付,再重新生成行动规划。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"kind": "action_plan",
|
||||||
|
"status": "done",
|
||||||
|
"title": "岗位会商行动规划",
|
||||||
|
"content": "\n".join(lines),
|
||||||
|
"generated_at": now,
|
||||||
|
"source_revisions": {node["node_key"]: node.get("revision", 0) for node in delivered},
|
||||||
|
"subtasks": tasks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]:
|
def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]:
|
||||||
if not isinstance(artifacts, list):
|
if not isinstance(artifacts, list):
|
||||||
return []
|
return []
|
||||||
@@ -342,6 +522,8 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
|
|||||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||||
if current["status"] == "locked":
|
if current["status"] == "locked":
|
||||||
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
||||||
|
if current.get("invalidated_by"):
|
||||||
|
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
|
||||||
|
|
||||||
# Same-stage nodes are intentionally excluded. The business chain only
|
# Same-stage nodes are intentionally excluded. The business chain only
|
||||||
# hands off completed previous stages, in stage/seat order.
|
# hands off completed previous stages, in stage/seat order.
|
||||||
@@ -350,7 +532,7 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
|
|||||||
node
|
node
|
||||||
for node in nodes
|
for node in nodes
|
||||||
if node["stage_index"] < current["stage_index"]
|
if node["stage_index"] < current["stage_index"]
|
||||||
and node["status"] in {"done", "stale"}
|
and node["status"] == "done"
|
||||||
]
|
]
|
||||||
upstream_nodes: list[dict[str, Any]] = []
|
upstream_nodes: list[dict[str, Any]] = []
|
||||||
for index, node in enumerate(upstream_candidates):
|
for index, node in enumerate(upstream_candidates):
|
||||||
@@ -509,6 +691,8 @@ async def bind_node_thread(
|
|||||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||||
if current["status"] == "locked":
|
if current["status"] == "locked":
|
||||||
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
||||||
|
if current.get("invalidated_by"):
|
||||||
|
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
|
||||||
if current.get("thread_id") and current["thread_id"] != body.thread_id:
|
if current.get("thread_id") and current["thread_id"] != body.thread_id:
|
||||||
raise HTTPException(status_code=409, detail="该节点已绑定另一条对话线程")
|
raise HTTPException(status_code=409, detail="该节点已绑定另一条对话线程")
|
||||||
row = await store.update_node(
|
row = await store.update_node(
|
||||||
@@ -537,6 +721,8 @@ async def complete_node_turn(
|
|||||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||||
if node["status"] == "locked":
|
if node["status"] == "locked":
|
||||||
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
||||||
|
if node.get("invalidated_by"):
|
||||||
|
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
|
||||||
row = await store.update_node(
|
row = await store.update_node(
|
||||||
session_id,
|
session_id,
|
||||||
node_key,
|
node_key,
|
||||||
@@ -545,6 +731,7 @@ async def complete_node_turn(
|
|||||||
latest_answer=body.latest_answer,
|
latest_answer=body.latest_answer,
|
||||||
artifact_manifest=body.artifact_manifest,
|
artifact_manifest=body.artifact_manifest,
|
||||||
upstream_revision=body.upstream_revision,
|
upstream_revision=body.upstream_revision,
|
||||||
|
invalidated_by=None,
|
||||||
increment_revision=True,
|
increment_revision=True,
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
@@ -562,13 +749,19 @@ async def complete_node_turn(
|
|||||||
for downstream in nodes:
|
for downstream in nodes:
|
||||||
if (
|
if (
|
||||||
downstream["stage_index"] > row["stage_index"]
|
downstream["stage_index"] > row["stage_index"]
|
||||||
and downstream["status"] in {"ready", "running", "done", "stale"}
|
and downstream["status"] in {"ready", "running", "done", "stale", "rejected", "error"}
|
||||||
):
|
):
|
||||||
|
has_material = (
|
||||||
|
downstream.get("revision", 0) > 0
|
||||||
|
or bool(downstream.get("latest_answer"))
|
||||||
|
or bool(downstream.get("artifact_manifest"))
|
||||||
|
)
|
||||||
await store.update_node(
|
await store.update_node(
|
||||||
session_id,
|
session_id,
|
||||||
downstream["node_key"],
|
downstream["node_key"],
|
||||||
user_id,
|
user_id,
|
||||||
status="stale",
|
status="stale" if has_material else "locked",
|
||||||
|
invalidated_by=row["node_key"],
|
||||||
)
|
)
|
||||||
nodes = await store.list_nodes(session_id, user_id) or []
|
nodes = await store.list_nodes(session_id, user_id) or []
|
||||||
|
|
||||||
@@ -579,16 +772,93 @@ async def complete_node_turn(
|
|||||||
if current_stage and all(item["status"] == "done" for item in current_stage) and later_stages:
|
if current_stage and all(item["status"] == "done" for item in current_stage) and later_stages:
|
||||||
next_stage = later_stages[0]
|
next_stage = later_stages[0]
|
||||||
for next_node in nodes:
|
for next_node in nodes:
|
||||||
if next_node["stage_index"] == next_stage and next_node["status"] == "locked":
|
if next_node["stage_index"] == next_stage and next_node["status"] in {"locked", "stale"}:
|
||||||
await store.update_node(
|
await store.update_node(
|
||||||
session_id,
|
session_id,
|
||||||
next_node["node_key"],
|
next_node["node_key"],
|
||||||
user_id,
|
user_id,
|
||||||
status="ready",
|
status="ready" if next_node["status"] == "locked" else "stale",
|
||||||
|
invalidated_by=None,
|
||||||
)
|
)
|
||||||
return _project_node(row)
|
return _project_node(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/nodes/{node_key}/reject", response_model=SessionDetailResponse)
|
||||||
|
async def reject_node(
|
||||||
|
request: Request, session_id: str, node_key: str, body: RejectNodeRequest
|
||||||
|
) -> SessionDetailResponse:
|
||||||
|
title = body.title.strip()
|
||||||
|
reason = body.reason.strip()
|
||||||
|
if not title or not reason:
|
||||||
|
raise HTTPException(status_code=400, detail="驳回标题和理由不能为空")
|
||||||
|
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||||
|
current = await store.get_node(session_id, node_key, user_id)
|
||||||
|
if current is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||||
|
if current["status"] == "locked":
|
||||||
|
raise HTTPException(status_code=409, detail="当前节点尚未解锁,暂无可驳回的交付产物")
|
||||||
|
if current.get("revision", 0) <= 0 and not current.get("latest_answer") and not current.get("artifact_manifest"):
|
||||||
|
raise HTTPException(status_code=409, detail="当前节点暂无可驳回的交付产物")
|
||||||
|
rejected = await store.reject_node(
|
||||||
|
session_id,
|
||||||
|
node_key,
|
||||||
|
user_id,
|
||||||
|
title=title,
|
||||||
|
reason=reason,
|
||||||
|
rejected_by=user_id,
|
||||||
|
)
|
||||||
|
if rejected is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||||
|
row, node_rows = rejected
|
||||||
|
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/summary/start", response_model=SessionDetailResponse)
|
||||||
|
async def start_summary(request: Request, session_id: str) -> SessionDetailResponse:
|
||||||
|
store, user_id, session = await _require_writable_session(request, session_id)
|
||||||
|
nodes = await store.list_nodes(session_id, user_id)
|
||||||
|
if nodes is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||||
|
if not _all_nodes_delivered(nodes):
|
||||||
|
raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成方案总结")
|
||||||
|
snapshot = _build_summary_snapshot(session, nodes)
|
||||||
|
row = await store.update_session(
|
||||||
|
session_id,
|
||||||
|
user_id,
|
||||||
|
summary_snapshot=snapshot,
|
||||||
|
action_plan_snapshot=None,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||||
|
node_rows = await store.list_nodes(session_id, user_id) or []
|
||||||
|
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/action-plan/start", response_model=SessionDetailResponse)
|
||||||
|
async def start_action_plan(request: Request, session_id: str) -> SessionDetailResponse:
|
||||||
|
store, user_id, session = await _require_writable_session(request, session_id)
|
||||||
|
nodes = await store.list_nodes(session_id, user_id)
|
||||||
|
if nodes is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||||
|
if not _all_nodes_delivered(nodes):
|
||||||
|
raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成行动规划")
|
||||||
|
summary = session.get("summary_snapshot")
|
||||||
|
if not isinstance(summary, dict) or summary.get("status") != "done":
|
||||||
|
raise HTTPException(status_code=409, detail="请先生成有效的方案总结")
|
||||||
|
snapshot = _build_action_plan_snapshot(session, nodes)
|
||||||
|
row = await store.update_session(
|
||||||
|
session_id,
|
||||||
|
user_id,
|
||||||
|
action_plan_snapshot=snapshot,
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||||
|
node_rows = await store.list_nodes(session_id, user_id) or []
|
||||||
|
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions/{session_id}/results", response_model=list[NodeResponse])
|
@router.get("/sessions/{session_id}/results", response_model=list[NodeResponse])
|
||||||
async def list_results(request: Request, session_id: str) -> list[NodeResponse]:
|
async def list_results(request: Request, session_id: str) -> list[NodeResponse]:
|
||||||
rows = await _get_store(request).list_results(session_id, _current_user_id(request))
|
rows = await _get_store(request).list_results(session_id, _current_user_id(request))
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
"""Add rejection metadata to position roundtable nodes.
|
||||||
|
|
||||||
|
Revision ID: 20260721_02
|
||||||
|
Revises: 20260721_01
|
||||||
|
Create Date: 2026-07-21
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260721_02"
|
||||||
|
down_revision = "20260721_01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return table_name in inspect(op.get_bind()).get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
table = "position_roundtable_nodes"
|
||||||
|
if not _has_table(table):
|
||||||
|
return
|
||||||
|
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||||
|
if not _has_column(table, "rejection_count"):
|
||||||
|
op.add_column(
|
||||||
|
table,
|
||||||
|
sa.Column("rejection_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
if not _has_column(table, "last_rejection"):
|
||||||
|
op.add_column(table, sa.Column("last_rejection", long_text, nullable=True))
|
||||||
|
if not _has_column(table, "invalidated_by"):
|
||||||
|
op.add_column(table, sa.Column("invalidated_by", sa.String(256), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
table = "position_roundtable_nodes"
|
||||||
|
if not _has_table(table):
|
||||||
|
return
|
||||||
|
for column in ("invalidated_by", "last_rejection", "rejection_count"):
|
||||||
|
if _has_column(table, column):
|
||||||
|
op.drop_column(table, column)
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
"""Add builtin summary/action-plan snapshots to position roundtable sessions.
|
||||||
|
|
||||||
|
Revision ID: 20260721_03
|
||||||
|
Revises: 20260721_02
|
||||||
|
Create Date: 2026-07-21
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260721_03"
|
||||||
|
down_revision = "20260721_02"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return table_name in inspect(op.get_bind()).get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(table_name: str, column_name: str) -> bool:
|
||||||
|
if not _has_table(table_name):
|
||||||
|
return False
|
||||||
|
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
table = "position_roundtable_sessions"
|
||||||
|
if not _has_table(table):
|
||||||
|
return
|
||||||
|
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||||
|
if not _has_column(table, "summary_snapshot"):
|
||||||
|
op.add_column(table, sa.Column("summary_snapshot", long_text, nullable=True))
|
||||||
|
if not _has_column(table, "action_plan_snapshot"):
|
||||||
|
op.add_column(table, sa.Column("action_plan_snapshot", long_text, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
table = "position_roundtable_sessions"
|
||||||
|
if not _has_table(table):
|
||||||
|
return
|
||||||
|
for column in ("action_plan_snapshot", "summary_snapshot"):
|
||||||
|
if _has_column(table, column):
|
||||||
|
op.drop_column(table, column)
|
||||||
+6
-1
@@ -31,6 +31,8 @@ class PositionRoundtableSessionRow(Base):
|
|||||||
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
|
summary_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
|
action_plan_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
# intent_pending -> active -> completed / archived. The router owns legal
|
# intent_pending -> active -> completed / archived. The router owns legal
|
||||||
# transitions; the DB keeps it as a concise, portable string.
|
# transitions; the DB keeps it as a concise, portable string.
|
||||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
|
||||||
@@ -66,12 +68,15 @@ class PositionRoundtableNodeRow(Base):
|
|||||||
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
# The real messages remain in the standard LangGraph/checkpoint store.
|
# The real messages remain in the standard LangGraph/checkpoint store.
|
||||||
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
|
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
|
||||||
# locked / ready / running / done / stale / error
|
# locked / ready / running / done / stale / rejected / error
|
||||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True)
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True)
|
||||||
latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
|
rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
|
invalidated_by: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
||||||
)
|
)
|
||||||
|
|||||||
+105
@@ -37,6 +37,10 @@ def _time(value: Any) -> str | Any:
|
|||||||
return value.isoformat() if isinstance(value, datetime) else value
|
return value.isoformat() if isinstance(value, datetime) else value
|
||||||
|
|
||||||
|
|
||||||
|
def _has_material(row: PositionRoundtableNodeRow) -> bool:
|
||||||
|
return row.revision > 0 or bool(row.latest_answer) or bool(_loads(row.artifact_manifest))
|
||||||
|
|
||||||
|
|
||||||
class PositionRoundtableRepository:
|
class PositionRoundtableRepository:
|
||||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
self._sf = session_factory
|
self._sf = session_factory
|
||||||
@@ -51,6 +55,8 @@ class PositionRoundtableRepository:
|
|||||||
"chain_snapshot": _loads(row.chain_snapshot),
|
"chain_snapshot": _loads(row.chain_snapshot),
|
||||||
"intent_thread_id": row.intent_thread_id,
|
"intent_thread_id": row.intent_thread_id,
|
||||||
"intent_snapshot": _loads(row.intent_snapshot),
|
"intent_snapshot": _loads(row.intent_snapshot),
|
||||||
|
"summary_snapshot": _loads(row.summary_snapshot),
|
||||||
|
"action_plan_snapshot": _loads(row.action_plan_snapshot),
|
||||||
"status": row.status,
|
"status": row.status,
|
||||||
"created_at": _time(row.created_at),
|
"created_at": _time(row.created_at),
|
||||||
"updated_at": _time(row.updated_at),
|
"updated_at": _time(row.updated_at),
|
||||||
@@ -72,6 +78,9 @@ class PositionRoundtableRepository:
|
|||||||
"artifact_manifest": _loads(row.artifact_manifest) or [],
|
"artifact_manifest": _loads(row.artifact_manifest) or [],
|
||||||
"revision": row.revision,
|
"revision": row.revision,
|
||||||
"upstream_revision": _loads(row.upstream_revision),
|
"upstream_revision": _loads(row.upstream_revision),
|
||||||
|
"rejection_count": row.rejection_count or 0,
|
||||||
|
"last_rejection": _loads(row.last_rejection),
|
||||||
|
"invalidated_by": row.invalidated_by,
|
||||||
"created_at": _time(row.created_at),
|
"created_at": _time(row.created_at),
|
||||||
"updated_at": _time(row.updated_at),
|
"updated_at": _time(row.updated_at),
|
||||||
}
|
}
|
||||||
@@ -125,6 +134,8 @@ class PositionRoundtableRepository:
|
|||||||
task_snapshot: dict[str, Any] | object = _UNSET,
|
task_snapshot: dict[str, Any] | object = _UNSET,
|
||||||
intent_thread_id: str | None | object = _UNSET,
|
intent_thread_id: str | None | object = _UNSET,
|
||||||
intent_snapshot: dict[str, Any] | None | object = _UNSET,
|
intent_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||||
|
summary_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||||
|
action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||||
status: str | object = _UNSET,
|
status: str | object = _UNSET,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
@@ -137,6 +148,10 @@ class PositionRoundtableRepository:
|
|||||||
row.intent_thread_id = intent_thread_id or None
|
row.intent_thread_id = intent_thread_id or None
|
||||||
if intent_snapshot is not _UNSET:
|
if intent_snapshot is not _UNSET:
|
||||||
row.intent_snapshot = _dumps(intent_snapshot)
|
row.intent_snapshot = _dumps(intent_snapshot)
|
||||||
|
if summary_snapshot is not _UNSET:
|
||||||
|
row.summary_snapshot = _dumps(summary_snapshot)
|
||||||
|
if action_plan_snapshot is not _UNSET:
|
||||||
|
row.action_plan_snapshot = _dumps(action_plan_snapshot)
|
||||||
if status is not _UNSET:
|
if status is not _UNSET:
|
||||||
row.status = str(status)
|
row.status = str(status)
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
@@ -239,7 +254,10 @@ class PositionRoundtableRepository:
|
|||||||
latest_answer: str | None | object = _UNSET,
|
latest_answer: str | None | object = _UNSET,
|
||||||
artifact_manifest: list[dict[str, Any]] | object = _UNSET,
|
artifact_manifest: list[dict[str, Any]] | object = _UNSET,
|
||||||
upstream_revision: dict[str, Any] | object = _UNSET,
|
upstream_revision: dict[str, Any] | object = _UNSET,
|
||||||
|
last_rejection: dict[str, Any] | None | object = _UNSET,
|
||||||
|
invalidated_by: str | None | object = _UNSET,
|
||||||
increment_revision: bool = False,
|
increment_revision: bool = False,
|
||||||
|
increment_rejection: bool = False,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -265,13 +283,100 @@ class PositionRoundtableRepository:
|
|||||||
row.artifact_manifest = _dumps(artifact_manifest)
|
row.artifact_manifest = _dumps(artifact_manifest)
|
||||||
if upstream_revision is not _UNSET:
|
if upstream_revision is not _UNSET:
|
||||||
row.upstream_revision = _dumps(upstream_revision)
|
row.upstream_revision = _dumps(upstream_revision)
|
||||||
|
if last_rejection is not _UNSET:
|
||||||
|
row.last_rejection = _dumps(last_rejection)
|
||||||
|
if invalidated_by is not _UNSET:
|
||||||
|
row.invalidated_by = invalidated_by or None
|
||||||
if increment_revision:
|
if increment_revision:
|
||||||
row.revision += 1
|
row.revision += 1
|
||||||
|
if increment_rejection:
|
||||||
|
row.rejection_count += 1
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return self._node_dict(row)
|
return self._node_dict(row)
|
||||||
|
|
||||||
|
async def reject_node(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
node_key: str,
|
||||||
|
user_id: str,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
reason: str,
|
||||||
|
rejected_by: str,
|
||||||
|
) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
|
||||||
|
"""Reject one delivered node and invalidate every later stage.
|
||||||
|
|
||||||
|
Historical answers and artifact manifests are deliberately preserved.
|
||||||
|
``invalidated_by`` gates downstream rework until the rejected upstream
|
||||||
|
stage is completed again.
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
async with self._sf() as session:
|
||||||
|
parent = await session.get(PositionRoundtableSessionRow, session_id)
|
||||||
|
if parent is None or parent.user_id != user_id:
|
||||||
|
return None
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(PositionRoundtableNodeRow)
|
||||||
|
.where(PositionRoundtableNodeRow.session_id == session_id)
|
||||||
|
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
target = next((row for row in rows if row.node_key == node_key), None)
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rejection = {
|
||||||
|
"title": title,
|
||||||
|
"reason": reason,
|
||||||
|
"rejected_by": rejected_by,
|
||||||
|
"rejected_at": now.isoformat(),
|
||||||
|
}
|
||||||
|
summary_snapshot = _loads(parent.summary_snapshot)
|
||||||
|
if isinstance(summary_snapshot, dict) and summary_snapshot.get("status") == "done":
|
||||||
|
summary_snapshot = {
|
||||||
|
**summary_snapshot,
|
||||||
|
"status": "stale",
|
||||||
|
"invalidated_by": target.node_key,
|
||||||
|
"invalidated_at": now.isoformat(),
|
||||||
|
}
|
||||||
|
parent.summary_snapshot = _dumps(summary_snapshot)
|
||||||
|
action_plan_snapshot = _loads(parent.action_plan_snapshot)
|
||||||
|
if isinstance(action_plan_snapshot, dict) and action_plan_snapshot.get("status") == "done":
|
||||||
|
action_plan_snapshot = {
|
||||||
|
**action_plan_snapshot,
|
||||||
|
"status": "stale",
|
||||||
|
"invalidated_by": target.node_key,
|
||||||
|
"invalidated_at": now.isoformat(),
|
||||||
|
}
|
||||||
|
parent.action_plan_snapshot = _dumps(action_plan_snapshot)
|
||||||
|
if parent.status == "completed":
|
||||||
|
parent.status = "active"
|
||||||
|
target.status = "rejected"
|
||||||
|
target.last_rejection = _dumps(rejection)
|
||||||
|
target.rejection_count += 1
|
||||||
|
target.invalidated_by = None
|
||||||
|
target.updated_at = now
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.stage_index <= target.stage_index:
|
||||||
|
continue
|
||||||
|
if _has_material(row) or row.status in {"running", "done", "stale", "rejected", "error"}:
|
||||||
|
row.status = "stale"
|
||||||
|
else:
|
||||||
|
row.status = "locked"
|
||||||
|
row.invalidated_by = target.node_key
|
||||||
|
row.updated_at = now
|
||||||
|
|
||||||
|
parent.updated_at = now
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(parent)
|
||||||
|
for row in rows:
|
||||||
|
await session.refresh(row)
|
||||||
|
return self._session_dict(parent), [self._node_dict(row) for row in rows]
|
||||||
|
|
||||||
async def archive_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
|
async def archive_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
|
||||||
return await self.update_session(session_id, user_id, status="archived")
|
return await self.update_session(session_id, user_id, status="archived")
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,176 @@ async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(stor
|
|||||||
assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None
|
assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_node_marks_current_and_invalidates_later_stages(store):
|
||||||
|
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
|
||||||
|
_, nodes = await store.activate_session(
|
||||||
|
created["id"],
|
||||||
|
"user-a",
|
||||||
|
chain_id="chain-a",
|
||||||
|
chain_snapshot={"id": "chain-a"},
|
||||||
|
nodes=[
|
||||||
|
{
|
||||||
|
"node_key": "stage-1-seat-1-agent-a",
|
||||||
|
"stage_index": 0,
|
||||||
|
"seat_index": 0,
|
||||||
|
"agent_id": "agent-a",
|
||||||
|
"position_id": "planning",
|
||||||
|
"status": "ready",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_key": "stage-2-seat-1-agent-b",
|
||||||
|
"stage_index": 1,
|
||||||
|
"seat_index": 0,
|
||||||
|
"agent_id": "agent-b",
|
||||||
|
"position_id": "solution",
|
||||||
|
"status": "ready",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_key": "stage-3-seat-1-agent-c",
|
||||||
|
"stage_index": 2,
|
||||||
|
"seat_index": 0,
|
||||||
|
"agent_id": "agent-c",
|
||||||
|
"position_id": "command",
|
||||||
|
"status": "ready",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await store.update_node(
|
||||||
|
created["id"],
|
||||||
|
nodes[0]["node_key"],
|
||||||
|
"user-a",
|
||||||
|
status="done",
|
||||||
|
latest_answer="第一阶段产物",
|
||||||
|
increment_revision=True,
|
||||||
|
)
|
||||||
|
await store.update_node(
|
||||||
|
created["id"],
|
||||||
|
nodes[1]["node_key"],
|
||||||
|
"user-a",
|
||||||
|
status="done",
|
||||||
|
latest_answer="第二阶段产物",
|
||||||
|
increment_revision=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
rejected = await store.reject_node(
|
||||||
|
created["id"],
|
||||||
|
nodes[0]["node_key"],
|
||||||
|
"user-a",
|
||||||
|
title="依据不足",
|
||||||
|
reason="第一阶段判断依据需要补充。",
|
||||||
|
rejected_by="user-a",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rejected is not None
|
||||||
|
_, rejected_nodes = rejected
|
||||||
|
by_key = {node["node_key"]: node for node in rejected_nodes}
|
||||||
|
first = by_key[nodes[0]["node_key"]]
|
||||||
|
second = by_key[nodes[1]["node_key"]]
|
||||||
|
third = by_key[nodes[2]["node_key"]]
|
||||||
|
assert first["status"] == "rejected"
|
||||||
|
assert first["rejection_count"] == 1
|
||||||
|
assert first["last_rejection"]["title"] == "依据不足"
|
||||||
|
assert first["invalidated_by"] is None
|
||||||
|
assert second["status"] == "stale"
|
||||||
|
assert second["latest_answer"] == "第二阶段产物"
|
||||||
|
assert second["invalidated_by"] == nodes[0]["node_key"]
|
||||||
|
assert third["status"] == "locked"
|
||||||
|
assert third["invalidated_by"] == nodes[0]["node_key"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store):
|
||||||
|
created = await store.create_session(
|
||||||
|
"user-a",
|
||||||
|
external_task_id=None,
|
||||||
|
task_snapshot={
|
||||||
|
"title": "测试任务",
|
||||||
|
"description": "形成协同方案",
|
||||||
|
"direction": "态势研判",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await store.update_session(
|
||||||
|
created["id"],
|
||||||
|
"user-a",
|
||||||
|
intent_snapshot={"objective": "明确处置意图", "constraints": ["时间紧"]},
|
||||||
|
)
|
||||||
|
chain = {
|
||||||
|
"id": "chain-a",
|
||||||
|
"seats": [
|
||||||
|
{"agent_id": "agent-a", "name": "研判智能体", "position_id": "planning"},
|
||||||
|
{"agent_id": "agent-b", "name": "处置智能体", "position_id": "solution"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_, nodes = await store.activate_session(
|
||||||
|
created["id"],
|
||||||
|
"user-a",
|
||||||
|
chain_id="chain-a",
|
||||||
|
chain_snapshot=chain,
|
||||||
|
nodes=[
|
||||||
|
{
|
||||||
|
"node_key": "stage-1-seat-1-agent-a",
|
||||||
|
"stage_index": 0,
|
||||||
|
"seat_index": 0,
|
||||||
|
"agent_id": "agent-a",
|
||||||
|
"position_id": "planning",
|
||||||
|
"status": "ready",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_key": "stage-2-seat-1-agent-b",
|
||||||
|
"stage_index": 1,
|
||||||
|
"seat_index": 0,
|
||||||
|
"agent_id": "agent-b",
|
||||||
|
"position_id": "solution",
|
||||||
|
"status": "ready",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for node in nodes:
|
||||||
|
await store.update_node(
|
||||||
|
created["id"],
|
||||||
|
node["node_key"],
|
||||||
|
"user-a",
|
||||||
|
status="done",
|
||||||
|
latest_answer=f"{node['agent_id']} 产物",
|
||||||
|
increment_revision=True,
|
||||||
|
)
|
||||||
|
session = await store.get_session(created["id"], "user-a")
|
||||||
|
assert session is not None
|
||||||
|
nodes = await store.list_nodes(created["id"], "user-a") or []
|
||||||
|
summary = position_roundtable_router._build_summary_snapshot(session, nodes)
|
||||||
|
assert "岗位会商方案总结报告" in summary["content"]
|
||||||
|
assert "研判智能体" in summary["content"]
|
||||||
|
session = await store.update_session(created["id"], "user-a", summary_snapshot=summary)
|
||||||
|
assert session is not None
|
||||||
|
action_plan = position_roundtable_router._build_action_plan_snapshot(session, nodes)
|
||||||
|
session = await store.update_session(
|
||||||
|
created["id"],
|
||||||
|
"user-a",
|
||||||
|
action_plan_snapshot=action_plan,
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
|
assert session is not None
|
||||||
|
assert session["summary_snapshot"]["status"] == "done"
|
||||||
|
assert session["action_plan_snapshot"]["status"] == "done"
|
||||||
|
|
||||||
|
rejected = await store.reject_node(
|
||||||
|
created["id"],
|
||||||
|
nodes[0]["node_key"],
|
||||||
|
"user-a",
|
||||||
|
title="重做第一阶段",
|
||||||
|
reason="前序依据变化",
|
||||||
|
rejected_by="user-a",
|
||||||
|
)
|
||||||
|
assert rejected is not None
|
||||||
|
rejected_session, _ = rejected
|
||||||
|
assert rejected_session["status"] == "active"
|
||||||
|
assert rejected_session["summary_snapshot"]["status"] == "stale"
|
||||||
|
assert rejected_session["summary_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
|
||||||
|
assert rejected_session["action_plan_snapshot"]["status"] == "stale"
|
||||||
|
assert rejected_session["action_plan_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
|
async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
|
||||||
artifact = tmp_path / "handoff.md"
|
artifact = tmp_path / "handoff.md"
|
||||||
@@ -188,6 +358,18 @@ async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_p
|
|||||||
"latest_answer": None,
|
"latest_answer": None,
|
||||||
"artifact_manifest": [],
|
"artifact_manifest": [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"node_key": "stage-1-seat-2-agent-stale",
|
||||||
|
"stage_index": 0,
|
||||||
|
"seat_index": 1,
|
||||||
|
"agent_id": "agent-stale",
|
||||||
|
"position_id": "planning",
|
||||||
|
"thread_id": "stale-thread",
|
||||||
|
"status": "stale",
|
||||||
|
"revision": 1,
|
||||||
|
"latest_answer": "失效上游产物",
|
||||||
|
"artifact_manifest": [],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
async def resolve(thread_id: str, path: str):
|
async def resolve(thread_id: str, path: str):
|
||||||
@@ -207,6 +389,7 @@ async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_p
|
|||||||
)
|
)
|
||||||
|
|
||||||
upstream = context["upstream_nodes"][0]
|
upstream = context["upstream_nodes"][0]
|
||||||
|
assert len(context["upstream_nodes"]) == 1
|
||||||
assert upstream["latest_answer"] == "上游问答结果"
|
assert upstream["latest_answer"] == "上游问答结果"
|
||||||
assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。"
|
assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。"
|
||||||
assert "content" not in upstream["artifacts"][1]
|
assert "content" not in upstream["artifacts"][1]
|
||||||
|
|||||||
Reference in new issue
Block a user