feat: 岗位会商独立意图智能体与四项意图 schema

岗位会商 Step 1 改用 position-roundtable-intent 智能体,输出 coreGoals/riskWarnings/keyPoints/strategicSignificance 四项数组;支持单轮多张协助卡、按轮次计澄清上限,并兼容旧会话 objective/constraints 映射。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-11 13:00:16 +08:00
1 parent ca32a22c0d
commit f8757d1a43
24 files changed
+1258 -257

No files matched your search

@@ -121,6 +121,31 @@ PositionRoundtablePage
后端会冻结链条快照并根据链条阶段创建节点。已启动后意图不可再修改;需要重新识别时必须新建会话。
The position workspace selects the dedicated built-in `position-roundtable-intent`
agent by sending `intent_agent_id` through the shared Step-1 init and stream
requests. The thread lifecycle, file upload, and SSE transport stay identical
to the generic multi-agent flow. The dedicated agent permits at most five
user-assistance rounds; one round may contain multiple independent
`ask_clarification` cards. `Step1Panel` renders every card separately and
submits the selected answers together, so cards must never be merged into one
numbered multi-question card. Only its final `[INTENT_READY]` schema changes to
the following four string arrays:
```json
{
"coreGoals": ["..."],
"riskWarnings": ["..."],
"keyPoints": ["..."],
"strategicSignificance": ["..."]
}
```
The position sidebar and edit dialog render these four fields. Business-chain
activation requires all four arrays and at least one core goal or strategic
significance. Historic `objective` / `constraints` / `assumptions` snapshots
are read through a compatibility mapping only; ordinary multi-agent Step 1 is
not changed.
### 4.2 普通节点问答
`PositionNodeChat` 在发送前调用 `getPositionNodeContext` 获取受长度控制的任务、意图和已交付上游内容,再以 `task_external_context` 作为隐藏上下文发送给当前智能体。
@@ -111,7 +111,11 @@ import {
type InteractiveMessage,
type TimelineEntry,
} from "@/roundtable-planning/hooks/useStep1Intent";
import { INTENT_AGENT_ID, type IntentSummary } from "@/roundtable-planning/api/intent";
import {
POSITION_INTENT_AGENT_ID,
type IntentSummary,
type PositionIntentSummary,
} from "@/roundtable-planning/api/intent";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
import {
@@ -197,9 +201,10 @@ function isSessionInTaskScope(
type ClosureStageKind = PositionBuiltinAgentKind;
type IntentEditDraft = {
objective: string;
constraints: string;
assumptions: string;
coreGoals: string;
riskWarnings: string;
keyPoints: string;
strategicSignificance: string;
};
type SandboxThreadSnapshot = {
threadId: string;
@@ -682,16 +687,38 @@ function mergePositionSession(
return { ...current, ...next, nodes: current.nodes };
}
function parseIntentSummary(snapshot: Record<string, unknown> | null | undefined): IntentSummary | null {
if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) {
function intentStringList(snapshot: Record<string, unknown>, key: string): string[] {
const value = snapshot[key];
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string" && Boolean(item.trim()))
: [];
}
function parsePositionIntentSummary(
snapshot: Record<string, unknown> | null | undefined,
): PositionIntentSummary | null {
if (!snapshot) return null;
const positionKeys = ["coreGoals", "riskWarnings", "keyPoints", "strategicSignificance"];
if (positionKeys.some((key) => key in snapshot)) {
if (!positionKeys.every((key) => Array.isArray(snapshot[key]))) return null;
return {
coreGoals: intentStringList(snapshot, "coreGoals"),
riskWarnings: intentStringList(snapshot, "riskWarnings"),
keyPoints: intentStringList(snapshot, "keyPoints"),
strategicSignificance: intentStringList(snapshot, "strategicSignificance"),
};
}
// Existing sessions created before the dedicated position agent used the
// shared Step-1 schema. Keep them readable and editable after the switch.
if (typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) {
return null;
}
return {
objective: snapshot.objective,
constraints: snapshot.constraints.filter((item): item is string => typeof item === "string"),
assumptions: Array.isArray(snapshot.assumptions)
? snapshot.assumptions.filter((item): item is string => typeof item === "string")
: undefined,
coreGoals: intentStringList(snapshot, "constraints"),
riskWarnings: [],
keyPoints: intentStringList(snapshot, "assumptions"),
strategicSignificance: snapshot.objective.trim() ? [snapshot.objective.trim()] : [],
};
}
@@ -973,9 +1000,10 @@ export default function PositionRoundtablePage({
const [taskSaving, setTaskSaving] = useState(false);
const [intentEditorOpen, setIntentEditorOpen] = useState(false);
const [intentDraft, setIntentDraft] = useState<IntentEditDraft>({
objective: "",
constraints: "",
assumptions: "",
coreGoals: "",
riskWarnings: "",
keyPoints: "",
strategicSignificance: "",
});
const [intentSaving, setIntentSaving] = useState(false);
const [intentCompletedAt, setIntentCompletedAt] = useState("");
@@ -1045,7 +1073,7 @@ export default function PositionRoundtablePage({
[],
);
const frozenIntent = useMemo(
() => parseIntentSummary(positionSession?.intentSnapshot),
() => parsePositionIntentSummary(positionSession?.intentSnapshot),
[positionSession?.intentSnapshot],
);
const task = useMemo(
@@ -1106,12 +1134,23 @@ export default function PositionRoundtablePage({
? 1
: 0,
selectedModel,
intentAgentId: POSITION_INTENT_AGENT_ID,
taskContext: intentTaskContext,
initialWelcomeText:
"我已读取左侧任务信息。你可以直接补充本次任务的特殊约束;如果没有补充,我会基于现有任务信息进行意图识别。",
showInitialWelcome: false,
triggerToast,
});
const livePositionIntent = useMemo(
() =>
parsePositionIntentSummary(
intent.intentReady as unknown as Record<string, unknown> | null,
),
[intent.intentReady],
);
const positionIntent = frozenIntent ?? livePositionIntent;
const positionIntentHeadline =
positionIntent?.strategicSignificance[0] ?? positionIntent?.coreGoals[0];
// `useStep1Intent` starts with an empty list for a new intent session. Keep
// the complete list here so a restored historical session can display its
// first agent turn as well as the user's follow-up turns. The old
@@ -1258,23 +1297,31 @@ export default function PositionRoundtablePage({
const openIntentEditor = useCallback(() => {
if (isArchivedSession) return;
const currentIntent = frozenIntent ?? intent.intentReady;
const currentIntent = positionIntent;
setIntentDraft({
objective: currentIntent?.objective ?? "",
constraints: (currentIntent?.constraints ?? []).join("\n"),
assumptions: (currentIntent?.assumptions ?? []).join("\n"),
coreGoals: (currentIntent?.coreGoals ?? []).join("\n"),
riskWarnings: (currentIntent?.riskWarnings ?? []).join("\n"),
keyPoints: (currentIntent?.keyPoints ?? []).join("\n"),
strategicSignificance: (currentIntent?.strategicSignificance ?? []).join("\n"),
});
setIntentEditorOpen(true);
}, [frozenIntent, intent.intentReady, isArchivedSession]);
}, [isArchivedSession, positionIntent]);
const handleSaveIntentEdit = useCallback(async () => {
if (isArchivedSession) {
toast.warning("已归档的会话不能编辑意图理解");
return;
}
const objective = intentDraft.objective.trim();
if (!objective) {
toast.warning("请填写任务意图");
const coreGoals = intentDraft.coreGoals
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
const strategicSignificance = intentDraft.strategicSignificance
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
if (coreGoals.length === 0 && strategicSignificance.length === 0) {
toast.warning("请至少填写核心目标或战略意义");
return;
}
const splitLines = (value: string) =>
@@ -1282,10 +1329,11 @@ export default function PositionRoundtablePage({
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
const nextIntent: IntentSummary = {
objective,
constraints: splitLines(intentDraft.constraints),
assumptions: splitLines(intentDraft.assumptions),
const nextIntent: PositionIntentSummary = {
coreGoals,
riskWarnings: splitLines(intentDraft.riskWarnings),
keyPoints: splitLines(intentDraft.keyPoints),
strategicSignificance,
};
setIntentSaving(true);
@@ -1309,7 +1357,7 @@ export default function PositionRoundtablePage({
});
intent.hydrateFromDraft({
intentThreadId: intentThreadId ?? intent.intentThreadId,
intentReady: nextIntent,
intentReady: nextIntent as unknown as IntentSummary,
interactiveMessages: intent.interactiveMessages,
timelineEntries: [],
});
@@ -1325,9 +1373,10 @@ export default function PositionRoundtablePage({
ensureEditableSession,
intent,
intentCompletedAt,
intentDraft.assumptions,
intentDraft.constraints,
intentDraft.objective,
intentDraft.coreGoals,
intentDraft.keyPoints,
intentDraft.riskWarnings,
intentDraft.strategicSignificance,
intentInputDraft,
isArchivedSession,
task,
@@ -1384,7 +1433,7 @@ export default function PositionRoundtablePage({
// multi-agent roundtable Step 1 flow. Do not derive this from a chain seat:
// older chains may have no seat agent id (or a removed legacy id), which
// would make the configuration panel request a non-existent agent.
const intentAgentId = INTENT_AGENT_ID;
const intentAgentId = POSITION_INTENT_AGENT_ID;
const isIntelligenceDeliveryOverview =
activePosition.isIntentPosition && positionSession !== null && positionSession.status !== "intent_pending";
const showIntentDeliveryOverview =
@@ -1620,7 +1669,11 @@ export default function PositionRoundtablePage({
view.timelineEntries,
view.inputDraft,
);
const intentSnapshot = view.intentReady ? { ...view.intentReady } : null;
// 岗位会商持久化的是专属四段式结果;useStep1Intent 为兼容普通会商
// 补出的 objective / constraints / assumptions 不应写回岗位会商快照。
const intentSnapshot = parsePositionIntentSummary(
view.intentReady as unknown as Record<string, unknown> | null,
);
const operation = async () => {
const baseSession = candidateSession ?? (await ensureEditableSession(task));
@@ -1772,7 +1825,8 @@ export default function PositionRoundtablePage({
candidates.sort((left, right) => right.length - left.length)[0] ?? [];
hydrateIntentFromDraft({
intentThreadId: intentThreadId ?? latest.intentThreadId,
intentReady: latestFrozenIntentRef.current ?? latest.intentReady,
intentReady:
(latestFrozenIntentRef.current as unknown as IntentSummary | null) ?? latest.intentReady,
interactiveMessages:
interactiveMessages.length > 0
? interactiveMessages
@@ -1799,7 +1853,8 @@ export default function PositionRoundtablePage({
: [];
hydrateIntentFromDraft({
intentThreadId: intentThreadId ?? latest.intentThreadId,
intentReady: latestFrozenIntentRef.current ?? latest.intentReady,
intentReady:
(latestFrozenIntentRef.current as unknown as IntentSummary | null) ?? latest.intentReady,
interactiveMessages: fallbackMessages,
timelineEntries: databaseTimeline,
});
@@ -1997,7 +2052,7 @@ export default function PositionRoundtablePage({
};
}, [searchParamsString, sessionIdFromUrl, setSearchParams, taskScopeId]);
const agentIdForPanel = activePosition.isIntentPosition ? INTENT_AGENT_ID : selectedAgentId;
const agentIdForPanel = activePosition.isIntentPosition ? POSITION_INTENT_AGENT_ID : selectedAgentId;
const { agent: selectedAgent } = useAgent(agentIdForPanel, {
enabled: !standaloneDemoMode,
});
@@ -2009,7 +2064,7 @@ export default function PositionRoundtablePage({
if (!activePosition.isIntentPosition) return null;
const seat = activeChain?.seats.find((item) => item.position_id === intentPositionId);
return {
id: INTENT_AGENT_ID,
id: POSITION_INTENT_AGENT_ID,
name: seat?.name || `${activePosition.name}智能体`,
description: seat?.description || activePosition.description,
user_id: null,
@@ -2072,7 +2127,7 @@ export default function PositionRoundtablePage({
let session = await ensureEditableSession(task);
session = await savePositionIntent(session.id, {
intentThreadId: intent.intentThreadId,
intentSnapshot: intent.intentReady,
intentSnapshot: positionIntent,
conversationSnapshot: buildIntentConversationSnapshot(
intent.interactiveMessages,
intent.timelineEntries,
@@ -2415,17 +2470,17 @@ export default function PositionRoundtablePage({
[handlePreviewArtifact],
);
const openIntentFlowDetail = useCallback(() => {
if (!(frozenIntent ?? intent.intentReady)) {
if (!positionIntent) {
toast.info("任务意图还在梳理中,完成确认后就能查看完整的意图说明。");
return;
}
setFlowDetailItem(
buildIntentResultItem(
frozenIntent ?? intent.intentReady,
positionIntent,
intentCompletedAt || positionSession?.updatedAt,
),
);
}, [frozenIntent, intent.intentReady, intentCompletedAt, positionSession?.updatedAt]);
}, [positionIntent, intentCompletedAt, positionSession?.updatedAt]);
const openNodeFlowDetail = useCallback(
(node: PositionRoundtableNode) => {
const item = buildNodeResultItem(node, activeChain);
@@ -2642,7 +2697,7 @@ export default function PositionRoundtablePage({
positionName={activePosition.name}
specialAgentConfig
onSelectAgent={handleSelectAgent}
intentObjective={frozenIntent?.objective ?? intent.intentReady?.objective}
intentObjective={positionIntentHeadline}
intentCompletedAt={intentCompletedAt}
assignedAgentIds={configurationAgentIds}
hasSelectedChain={showClosureAgents || Boolean(selectedChain)}
@@ -2684,7 +2739,7 @@ export default function PositionRoundtablePage({
}
interactiveMessages={visibleIntentMessages}
intentThreadId={intent.intentThreadId}
intentReady={chainStarted ? frozenIntent ?? intent.intentReady : intent.intentReady}
intentReady={chainStarted ? positionIntent : intent.intentReady}
isIntentSending={intent.isIntentSending}
pendingIntentClarificationMsgId={intent.pendingIntentClarificationMsgId}
intentClarificationSelections={intent.intentClarificationSelections}
@@ -2974,7 +3029,7 @@ export default function PositionRoundtablePage({
/>
{showIntentDeliveryOverview ? (
<IntentDeliveryOverviewSidebar
intent={frozenIntent ?? intent.intentReady}
intent={positionIntent}
chain={activeChain}
nodes={positionSession?.nodes ?? []}
activeNodeKey={activeNodeKey}
@@ -3106,7 +3161,7 @@ export default function PositionRoundtablePage({
positionName={activePosition.name}
specialAgentConfig={activePosition.isIntentPosition}
onSelectAgent={handleSelectAgent}
intentObjective={frozenIntent?.objective ?? intent.intentReady?.objective}
intentObjective={positionIntentHeadline}
intentCompletedAt={intentCompletedAt}
assignedAgentIds={configurationAgentIds}
hasSelectedChain={showClosureAgents || Boolean(selectedChain)}
@@ -3699,7 +3754,7 @@ function PositionNodeEmptyState({
}
/** 已启动会话中的意图是冻结快照;避免在情报岗视图意外覆盖它。 */
function IntentConfirmedState({ intent }: { intent: IntentSummary }) {
function IntentConfirmedState({ intent }: { intent: PositionIntentSummary }) {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-slate-50/50 px-6 py-7 dark:bg-[#032650]/30">
<div className="mx-auto w-full max-w-2xl rounded-2xl border border-emerald-500/20 bg-white p-5 shadow-sm dark:border-emerald-400/25 dark:bg-[#042b59]/65">
@@ -3715,15 +3770,15 @@ function IntentConfirmedState({ intent }: { intent: IntentSummary }) {
</div>
</div>
<div className="mt-5 rounded-xl border border-slate-100 bg-slate-50/70 p-4 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/40">
<p className="text-[11px] font-semibold text-muted-foreground dark:text-[#8aa0c4]">任务目标</p>
<p className="mt-1.5 text-sm font-semibold leading-6 text-slate-800 dark:text-[#e8edf5]">
{desensitize(intent.objective)}
</p>
<p className="text-[11px] font-semibold text-muted-foreground dark:text-[#8aa0c4]">核心目标</p>
<div className="mt-1.5 space-y-1.5 text-sm font-semibold leading-6 text-slate-800 dark:text-[#e8edf5]">
{intent.coreGoals.map((goal, index) => <p key={`${goal}-${index}`}>{desensitize(goal)}</p>)}
</div>
</div>
<div className="mt-4">
<p className="text-[11px] font-semibold text-muted-foreground dark:text-[#8aa0c4]">关键约束</p>
<p className="text-[11px] font-semibold text-muted-foreground dark:text-[#8aa0c4]">风险提示</p>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{intent.constraints.map((constraint, index) => (
{intent.riskWarnings.map((constraint, index) => (
<div
key={`${constraint}-${index}`}
className="flex items-start gap-2 rounded-lg border border-slate-100 bg-white px-3 py-2 text-xs leading-5 text-slate-700 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/35 dark:text-[#c5d8f1]"
@@ -3760,7 +3815,7 @@ function PositionDeliveryOverview({
chain,
session,
}: {
intent: IntentSummary | null;
intent: PositionIntentSummary | null;
intentCompletedAt?: string;
nodes: PositionRoundtableNode[];
chain: BusinessChain | null;
@@ -3893,7 +3948,10 @@ function PositionDeliveryOverview({
kind: "intent",
agentName: "情报分析智能体",
productName: "任务意图理解",
summary: intent?.objective || "情报分析岗已确认任务意图并冻结业务链。",
summary:
intent?.strategicSignificance[0] ||
intent?.coreGoals[0] ||
"情报分析岗已确认任务意图并冻结业务链。",
positionId: intentPositionIdFor(),
updatedAt: intentCompletedAt || session?.updatedAt,
status: intent ? "done" : "ready",
@@ -4821,7 +4879,7 @@ function IntentUnderstandingCard({
collapsed,
onCollapsedChange,
}: {
intent: ReturnType<typeof useStep1Intent>["intentReady"];
intent: PositionIntentSummary | null;
editable?: boolean;
onEdit?: () => void;
collapsed: boolean;
@@ -4863,30 +4921,21 @@ function IntentUnderstandingCard({
<div className="min-h-0 overflow-hidden">
{intent ? (
<div className="space-y-3 p-4">
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务意图</p>
<p className="mt-1 text-sm leading-6 text-slate-700 dark:text-[#f2f7ff]">
{desensitize(intent.objective)}
</p>
</div>
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">关键约束</p>
<ul className="mt-1 list-disc space-y-1 pl-4 text-sm leading-6 text-slate-600 marker:text-slate-400 dark:text-[#d8e6f8] dark:marker:text-[#83b6ed]">
{intent.constraints.map((constraint, index) => (
<li key={`${constraint}-${index}`}>{desensitize(constraint)}</li>
))}
</ul>
</div>
{intent.assumptions && intent.assumptions.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">关键假设</p>
<ul className="mt-1 list-disc space-y-1 pl-4 text-sm leading-6 text-slate-600 marker:text-slate-400 dark:text-[#d8e6f8] dark:marker:text-[#83b6ed]">
{intent.assumptions.map((assumption, index) => (
<li key={`${assumption}-${index}`}>{desensitize(assumption)}</li>
))}
</ul>
{([
["核心目标", intent.coreGoals],
["风险提示", intent.riskWarnings],
["关键要点", intent.keyPoints],
["战略意义", intent.strategicSignificance],
] as const).map(([label, values]) => (
<div key={label}>
<p className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">{label}</p>
{values.length > 0 ? (
<ul className="mt-1 list-disc space-y-1 pl-4 text-sm leading-6 text-slate-600 marker:text-slate-400 dark:text-[#d8e6f8] dark:marker:text-[#83b6ed]">
{values.map((value, index) => <li key={`${value}-${index}`}>{desensitize(value)}</li>)}
</ul>
) : <p className="mt-1 text-sm text-slate-500 dark:text-[#9ebfe2]">暂无{label}</p>}
</div>
)}
))}
</div>
) : (
<div className="p-4 text-sm leading-6 text-muted-foreground dark:text-[#d8e6f8]">
@@ -4907,16 +4956,17 @@ function IntentDeliveryOverviewSidebar({
onSelectNode,
onSelectSeat,
}: {
intent: IntentSummary | null;
intent: PositionIntentSummary | null;
chain: BusinessChain | null;
nodes: PositionRoundtableNode[];
activeNodeKey: string | null;
onSelectNode: (node: PositionRoundtableNode) => void;
onSelectSeat: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
}) {
const objective = intent?.objective?.trim() || "暂无任务目的";
const coreGoals = intent?.constraints?.filter(Boolean) ?? [];
const keyConstraints = intent?.assumptions?.filter(Boolean) ?? [];
const coreGoals = intent?.coreGoals ?? [];
const riskWarnings = intent?.riskWarnings ?? [];
const keyPoints = intent?.keyPoints ?? [];
const strategicSignificance = intent?.strategicSignificance ?? [];
return (
<section className="position-roundtable-intent-delivery-sidebar">
@@ -4926,10 +4976,6 @@ function IntentDeliveryOverviewSidebar({
<h2>意图理解结果</h2>
</header>
<div className="position-roundtable-intent-delivery-sections">
<article className="position-roundtable-intent-delivery-section position-roundtable-intent-delivery-section--purpose">
<h3>任务目的</h3>
<p>{desensitize(objective)}</p>
</article>
<article className="position-roundtable-intent-delivery-section position-roundtable-intent-delivery-section--goals">
<h3>核心目标</h3>
{coreGoals.length > 0 ? (
@@ -4943,17 +4989,25 @@ function IntentDeliveryOverviewSidebar({
)}
</article>
<article className="position-roundtable-intent-delivery-section position-roundtable-intent-delivery-section--constraints">
<h3>关键约束</h3>
{keyConstraints.length > 0 ? (
<h3>风险提示</h3>
{riskWarnings.length > 0 ? (
<ul>
{keyConstraints.map((constraint, index) => (
<li key={`${constraint}-${index}`}>{desensitize(constraint)}</li>
{riskWarnings.map((warning, index) => (
<li key={`${warning}-${index}`}>{desensitize(warning)}</li>
))}
</ul>
) : (
<p className="is-empty">暂无补充约束</p>
<p className="is-empty">暂无风险提示</p>
)}
</article>
<article className="position-roundtable-intent-delivery-section position-roundtable-intent-delivery-section--purpose">
<h3>关键要点</h3>
{keyPoints.length > 0 ? <ul>{keyPoints.map((item, index) => <li key={`${item}-${index}`}>{desensitize(item)}</li>)}</ul> : <p className="is-empty">暂无关键要点</p>}
</article>
<article className="position-roundtable-intent-delivery-section position-roundtable-intent-delivery-section--significance">
<h3>战略意义</h3>
{strategicSignificance.length > 0 ? <ul>{strategicSignificance.map((item, index) => <li key={`${item}-${index}`}>{desensitize(item)}</li>)}</ul> : <p className="is-empty">暂无战略意义</p>}
</article>
</div>
</div>
@@ -4999,34 +5053,43 @@ function IntentUnderstandingEditDialog({
</DialogHeader>
<div className="space-y-4 py-1">
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务意图</span>
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">核心目标</span>
<Textarea
value={draft.objective}
onChange={(event) => onChange({ ...draft, objective: event.target.value })}
value={draft.coreGoals}
onChange={(event) => onChange({ ...draft, coreGoals: event.target.value })}
className="min-h-24 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="请输入识别后的任务意图"
placeholder="每行一项核心目标"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">
关键约束
风险提示
</span>
<Textarea
value={draft.constraints}
onChange={(event) => onChange({ ...draft, constraints: event.target.value })}
value={draft.riskWarnings}
onChange={(event) => onChange({ ...draft, riskWarnings: event.target.value })}
className="min-h-28 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="每行一条关键约束"
placeholder="每行一项风险、依赖或待确认事项"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">
补充假设 / 说明
关键要点
</span>
<Textarea
value={draft.assumptions}
onChange={(event) => onChange({ ...draft, assumptions: event.target.value })}
value={draft.keyPoints}
onChange={(event) => onChange({ ...draft, keyPoints: event.target.value })}
className="min-h-20 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="可选,每行一条"
placeholder="每行一项关键要点"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">战略意义</span>
<Textarea
value={draft.strategicSignificance}
onChange={(event) => onChange({ ...draft, strategicSignificance: event.target.value })}
className="min-h-20 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="每行一项战略意义"
/>
</label>
</div>
@@ -5983,7 +6046,7 @@ function deliverableFallbackContent(item: ResultItem | null): string {
}
function buildIntentResultItem(
intent: IntentSummary | null,
intent: PositionIntentSummary | null,
updatedAt: string | undefined,
): ResultItem {
return {
@@ -5992,7 +6055,10 @@ function buildIntentResultItem(
node: null,
agentName: "情报分析智能体",
productName: "任务意图理解",
summary: intent?.objective || "任务意图已确认,当前没有更多意图摘要内容。",
summary:
intent?.strategicSignificance[0] ||
intent?.coreGoals[0] ||
"任务意图已确认,当前没有更多意图摘要内容。",
positionId: intentPositionIdFor(),
updatedAt,
status: intent ? "done" : "ready",
@@ -397,6 +397,15 @@
color: #33bc92;
}
.position-roundtable-intent-delivery-section--significance {
border-left: 4px solid #45c3f3;
background: rgba(0, 98, 217, 0.12);
}
.position-roundtable-intent-delivery-section--significance h3 {
color: #45c3f3;
}
.position-roundtable-intent-delivery-section .is-empty {
color: rgba(255, 255, 255, 0.62);
}
@@ -4,11 +4,14 @@ import { getBackendBaseURL } from "@/core/config";
import type { ClarificationPayload } from "./multi-agent";
import { reportRoundtableError } from "./diagnostics-report";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { normalizeIntentReadySummary } from "@/roundtable-planning/lib/roundtable-constants";
export type { ClarificationPayload };
/** Stable built-in agent used by the multi-agent roundtable Step 1 flow. */
export const INTENT_AGENT_ID = "roundtable-intent";
/** Dedicated Step 1 agent for the position-roundtable workspace. */
export const POSITION_INTENT_AGENT_ID = "position-roundtable-intent";
/**
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
@@ -103,13 +106,18 @@ function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
export async function initIntent(
model?: string,
taskContext?: Record<string, unknown> | null,
intentAgentId?: string,
): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认
// 业务链,第一阶段的对话也已经可以按 taskId 恢复。
body: JSON.stringify({ model, task_context: taskContext ?? undefined }),
body: JSON.stringify({
model,
task_context: taskContext ?? undefined,
intent_agent_id: intentAgentId || undefined,
}),
});
if (!res.ok) {
const raw = await res.text().catch(() => "");
@@ -140,6 +148,19 @@ export interface IntentSummary {
constraints: string[];
/** Optional assumptions the agent is making on the user's behalf. */
assumptions?: string[];
/** Present only for the dedicated position-roundtable intent agent. */
coreGoals?: string[];
riskWarnings?: string[];
keyPoints?: string[];
strategicSignificance?: string[];
}
/** Final hand-off shape emitted only by the position-roundtable intent agent. */
export interface PositionIntentSummary {
coreGoals: string[];
riskWarnings: string[];
keyPoints: string[];
strategicSignificance: string[];
}
/**
@@ -167,6 +188,8 @@ export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
/** Explicit built-in Step-1 agent. Omit to retain the multi-agent default. */
intentAgentId?: string;
/** Optional hidden business/task context injected into the model run. */
taskContext?: Record<string, unknown> | null;
/** Step 1 用户上传的参考文件(可选)。 */
@@ -189,8 +212,7 @@ export interface StreamIntentRequest {
* Fires for every `ask_clarification` tool message parsed out of the live
* stream (the intent agent may emit several in a single turn). Lets the UI
* surface each assistance question in real-time instead of waiting for the
* terminal frame — which only carries the *first* question. `index` is the
* 0-based arrival order within this turn.
* terminal frame. `index` is the 0-based arrival order within this turn.
*/
onClarification?: (clarification: ClarificationPayload, index: number) => void;
}
@@ -211,8 +233,8 @@ export interface StreamIntentResult {
clarification: ClarificationPayload | null;
/**
* All `ask_clarification` questions the agent asked this turn, in arrival
* order. Collected from the live `type:"tool"` frames (the terminal frame
* only carries the first). Empty unless status === "clarification".
* order. Collected live and reconciled against the terminal frame so a
* reconnect cannot lose cards. Empty unless status === "clarification".
*/
clarifications: ClarificationPayload[];
/**
@@ -238,6 +260,7 @@ function isFinalIntentFrame(value: unknown): value is {
options?: unknown[];
allow_custom?: boolean;
allow_multiple?: boolean;
clarifications?: unknown[];
error_detail?: unknown;
} {
if (!value || typeof value !== "object") return false;
@@ -330,6 +353,41 @@ function extractClarificationToolMessage(
};
}
/**
* Normalize one clarification included in the terminal status frame. This is
* the reconnect / proxy fallback for cases where one or more live `tool`
* frames were not delivered to the browser.
*/
function parseTerminalClarification(
value: unknown,
): { payload: ClarificationPayload; toolCallId: string } | null {
if (!value || typeof value !== "object") return null;
const raw = value as Record<string, unknown>;
const clarificationType =
typeof raw.clarification_type === "string" ? raw.clarification_type : "";
const toolCallId = typeof raw.id === "string" ? raw.id : "";
return {
toolCallId,
payload: {
id: toolCallId || undefined,
question: typeof raw.question === "string" ? raw.question : "",
clarificationType,
context:
typeof raw.clarification_context === "string"
? raw.clarification_context
: typeof raw.context === "string"
? raw.context
: "",
options: Array.isArray(raw.options) ? raw.options : [],
allowCustom: raw.allow_custom !== false,
allowMultiple: resolveAllowMultiple(
typeof raw.allow_multiple === "boolean" ? raw.allow_multiple : undefined,
clarificationType,
),
},
};
}
/**
* Open an SSE connection to /api/intent/stream and resolve when the upstream
* stream completes. Each call is one user turn; reuse the same threadId
@@ -343,6 +401,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
thread_id: req.threadId,
message: req.message,
model: req.model,
intent_agent_id: req.intentAgentId || undefined,
task_context: req.taskContext ?? undefined,
// 仅在有文件时带上,空数组后端也安全忽略。
files: req.files && req.files.length > 0 ? req.files : undefined,
@@ -390,8 +449,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
};
// Clarifications collected live from `type:"tool"` frames, deduped by
// tool_call_id. The terminal frame only carries the first question, so this
// is the source of truth for multi-question turns.
// tool_call_id, then reconciled against the complete terminal list.
const seenClarificationIds = new Set<string>();
const collectClarification = (payload: ClarificationPayload, toolCallId: string) => {
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
@@ -438,7 +496,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
result.status = parsed.status;
if (typeof parsed.content === "string") result.content = parsed.content;
if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary;
result.summary = normalizeIntentReadySummary(parsed.summary);
}
if (parsed.status === "error") {
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
@@ -448,28 +506,26 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
detail: result.errorDetail,
});
}
if (parsed.status === "clarification" && result.clarifications.length === 0) {
// Fallback path: no live `type:"tool"` clarification frames were seen
// (older backend, or single-question turn) — synthesize one from the
// terminal frame so the UI still gets a card. When tool frames *were*
// seen, `clarifications` already holds the full (possibly multi-question)
// set and we keep it.
const clarificationType =
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
const fallback: ClarificationPayload = {
question: typeof parsed.question === "string" ? parsed.question : "",
clarificationType,
context:
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
options: Array.isArray(parsed.options) ? parsed.options : [],
allowCustom: parsed.allow_custom !== false,
allowMultiple: resolveAllowMultiple(
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
clarificationType,
),
};
result.clarification = fallback;
result.clarifications = [fallback];
if (parsed.status === "clarification") {
// Newer backend frames contain every card. Merge them even when a
// subset already arrived live: an SSE reconnect can otherwise leave
// the user with only the first card in a multi-question assistance
// round. Older backends still fall back to the legacy top-level card.
const terminalCards = Array.isArray(parsed.clarifications)
? parsed.clarifications
.map(parseTerminalClarification)
.filter((item): item is { payload: ClarificationPayload; toolCallId: string } => item !== null)
: [];
if (terminalCards.length > 0) {
terminalCards.forEach((card, index) => {
collectClarification(card.payload, card.toolCallId || `terminal-${index}`);
});
} else if (result.clarifications.length === 0) {
const fallback = parseTerminalClarification(parsed);
if (fallback) {
collectClarification(fallback.payload, fallback.toolCallId || "terminal-0");
}
}
}
continue;
}
@@ -153,6 +153,10 @@ export function BusinessChainPicker({
// 席位「会商技能识别强化」开关(链条配置):按 agent_id 取,缺省 = 关。
const skillById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.seat_skill_directive ?? false]));
const skillOf = (id: string) => skillById.get(id) ?? false;
const humanValidationById = new Map(
selectedChain.seats.map((s) => [s.agent_id, s.human_validation ?? false]),
);
const humanValidationOf = (id: string) => humanValidationById.get(id) ?? false;
// 席位「推理深度覆盖」(链条配置 seat_mode):按 agent_id 取,null/缺省 = 跟随链条级 seatMode。
const seatModeById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.seat_mode ?? null]));
const seatModeOf = (id: string) => seatModeById.get(id) ?? null;
@@ -162,12 +166,12 @@ export function BusinessChainPicker({
// dag:席位顺序按 stage 展平(与 plan 一致)。
const agents = dagPlan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), seatMode: seatModeOf(id) }));
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), human_validation: humanValidationOf(id), seatMode: seatModeOf(id) }));
onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode, gatherFirst: selectedChain.gatherFirst ?? false });
return;
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id), human_validation: humanValidationOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
onConfirm({ agents, mode: "chain", chain: chainMeta, gatherFirst: selectedChain.gatherFirst ?? false });
};
@@ -688,6 +688,15 @@ function Step1Message({
/** [INTENT_READY] 标记块解析出 IntentSummary 后的绿色摘要卡。 */
function IntentReadySummaryCard({ summary }: { summary: IntentSummary }) {
const positionSections =
summary.coreGoals && summary.riskWarnings && summary.keyPoints && summary.strategicSignificance
? ([
["核心目标", summary.coreGoals],
["风险提示", summary.riskWarnings],
["关键要点", summary.keyPoints],
["战略意义", summary.strategicSignificance],
] as const)
: null;
return (
<div className="mt-1 border border-emerald-200 bg-emerald-50/50 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-1.5">
@@ -696,30 +705,35 @@ function IntentReadySummaryCard({ summary }: { summary: IntentSummary }) {
任务意图已明确
</span>
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
<p className="text-[11.5px] text-slate-800 leading-relaxed">{summary.objective}</p>
</div>
{summary.constraints?.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">约束条件</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.constraints.map((c, i) => (
<li key={i} className="leading-snug">{c}</li>
))}
</ul>
{positionSections ? positionSections.map(([label, values]) => (
<div key={label} className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">{label}</span>
{values.length > 0 ? <ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{values.map((value, index) => <li key={index} className="leading-snug">{value}</li>)}
</ul> : <p className="text-[11px] text-slate-500">暂无{label}</p>}
</div>
)}
{summary.assumptions && summary.assumptions.length > 0 && (
)) : <>
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">关键假设</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.assumptions.map((a, i) => (
<li key={i} className="leading-snug">{a}</li>
))}
</ul>
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
<p className="text-[11.5px] text-slate-800 leading-relaxed">{summary.objective}</p>
</div>
)}
{summary.constraints?.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">约束条件</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.constraints.map((c, i) => <li key={i} className="leading-snug">{c}</li>)}
</ul>
</div>
)}
{summary.assumptions && summary.assumptions.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">关键假设</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.assumptions.map((a, i) => <li key={i} className="leading-snug">{a}</li>)}
</ul>
</div>
)}
</>}
</div>
);
}
@@ -90,6 +90,8 @@ export interface UseStep1IntentOptions {
currentStep: number;
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
selectedModel: string;
/** Optional built-in agent override for a workspace with a distinct schema. */
intentAgentId?: string;
/** Optional hidden task/business context for each intent-agent turn. */
taskContext?: Record<string, unknown> | null;
/** Optional task-aware welcome bubble text before the first user turn. */
@@ -178,6 +180,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
const {
currentStep,
selectedModel,
intentAgentId,
taskContext,
initialWelcomeText,
showInitialWelcome = true,
@@ -214,6 +217,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
const intentAgentIdRef = useRef<string | undefined>(intentAgentId);
useEffect(() => {
intentAgentIdRef.current = intentAgentId;
}, [intentAgentId]);
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
useEffect(() => {
taskContextRef.current = taskContext ?? null;
@@ -265,6 +272,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
const res = await initIntent(
selectedModelRef.current || undefined,
taskContextRef.current,
intentAgentIdRef.current,
);
if (step1InitGenRef.current !== myGen) return;
setIntentThreadId(res.thread_id);
@@ -443,6 +451,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
threadId: intentThreadId,
message: trimmed,
model: selectedModelRef.current || undefined,
intentAgentId: intentAgentIdRef.current,
taskContext: taskContextRef.current,
files: fileRefs,
signal: aborter.signal,
@@ -33,6 +33,13 @@ import { useUpdateSubtask } from "@/core/tasks/context";
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
type SeatDelivery = { name: string; content: string };
/** In-memory cursor for a business-chain human-validation pause. */
type HumanValidationContinuation =
| { mode: "chain"; nextSeatIndex: number }
| { mode: "dag"; nextStageIndex: number; priorDeliveries: SeatDelivery[] };
/** 读取并清空 ref(consume-once):首个 leader run 拿到文件后清空,后续轮次不再带。 */
function takeOnce<T>(ref: { current: T | null }): T | undefined {
const v = ref.current ?? undefined;
@@ -286,6 +293,8 @@ export interface UseStep2OrchestrationOptions {
* OR。recommend / 未选链条 → 空 {}(各席位仅靠其自身开关)。
*/
seatSkillDirectives?: Record<string, boolean>;
/** Per-seat human-validation checkpoints inherited from the selected chain. */
humanValidations?: Record<string, boolean>;
/**
* 每席位「推理深度覆盖」映射 {agentId: SeatMode | null}(沿用所选业务链条里每个席位的配置)。
* 派活该 special 席位时按 agentId 查表,**席位覆盖优先、否则跟随链条级 `selectedMode`**:
@@ -456,6 +465,7 @@ export function useStep2Orchestration(
selectedModel,
selectedMode,
seatSkillDirectives = {},
humanValidations = {},
seatModes = {},
gatherFirst = false,
orchestrationMode,
@@ -593,6 +603,7 @@ export function useStep2Orchestration(
const selectedModeRef = useRef<SeatMode>(selectedMode);
// 每席位技能识别强化映射(所选业务链条各席位开关)同样打到 ref,席位调用按 agentId 读最新值。
const seatSkillDirectivesRef = useRef<Record<string, boolean>>(seatSkillDirectives);
const humanValidationsRef = useRef<Record<string, boolean>>(humanValidations);
// 每席位推理深度覆盖映射(所选业务链条各席位 seatMode)打到 ref,席位调用按 agentId 读最新值;
// 覆盖优先、否则跟随 selectedModeRef。
const seatModesRef = useRef<Record<string, SeatMode | null>>(seatModes);
@@ -624,6 +635,7 @@ export function useStep2Orchestration(
// 澄清回复都改走 **leader 自由派活循环**(runOrchestration),由总控接着派活,而**不是**
// 重新把整张 DAG 从头跑一遍(致命 bug 修复)。新任务 / 重新进入时由 resetInitGate 复位。
const dagPlanExecutedRef = useRef<boolean>(false);
const humanValidationContinuationRef = useRef<HumanValidationContinuation | null>(null);
const selectedAgentsRef = useRef<SelectedAgent[]>(selectedAgents);
useEffect(() => {
orchestrationModeRef.current = orchestrationMode;
@@ -643,6 +655,9 @@ export function useStep2Orchestration(
useEffect(() => {
seatSkillDirectivesRef.current = seatSkillDirectives;
}, [seatSkillDirectives]);
useEffect(() => {
humanValidationsRef.current = humanValidations;
}, [humanValidations]);
useEffect(() => {
seatModesRef.current = seatModes;
}, [seatModes]);
@@ -1213,6 +1228,21 @@ export function useStep2Orchestration(
[],
);
const pauseForHumanValidation = useCallback(
(continuation: HumanValidationContinuation, seatNames: string[]) => {
humanValidationContinuationRef.current = continuation;
appendDialogue({
sender: "系统",
avatarType: "blue",
content: `已完成 ${seatNames.join("、")} 的输出,按业务链条配置暂停,等待人工校验。可直接点击“恢复会商”继续,或在下方输入修改意见后提交。`,
confidence: "等待人工校验",
});
setIsRoundTableRunning(false);
triggerToast("已暂停等待人工校验:可恢复会商,或输入修改意见后继续");
},
[appendDialogue, triggerToast],
);
const phaseLabel = useCallback(
(
phase: StreamPhase,
@@ -2012,11 +2042,17 @@ export function useStep2Orchestration(
const chainSeats = selectedAgentsRef.current;
const total = chainSeats.length;
const continuation = humanValidationContinuationRef.current;
const startSeatIndex =
continuation?.mode === "chain" ? continuation.nextSeatIndex : 0;
if (continuation?.mode === "chain") {
humanValidationContinuationRef.current = null;
}
const leaderThreadId = resolveSeatThreadId(ids, coord);
let finishedAllSeats = true;
try {
for (let i = 0; i < total; i += 1) {
for (let i = startSeatIndex; i < total; i += 1) {
if (isStale()) {
finishedAllSeats = false;
break;
@@ -2179,6 +2215,14 @@ export function useStep2Orchestration(
}
setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90)));
if (humanValidationsRef.current[seat.agent_id]) {
pauseForHumanValidation(
{ mode: "chain", nextSeatIndex: i + 1 },
[seatName],
);
finishedAllSeats = false;
break;
}
}
// ── (c) 总控收口:综合全链交付,产出最终结论 ──
@@ -2257,6 +2301,7 @@ export function useStep2Orchestration(
handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId,
pauseForHumanValidation,
],
);
@@ -2300,23 +2345,37 @@ export function useStep2Orchestration(
dagPlanExecutedRef.current = true;
const leaderThreadId = resolveSeatThreadId(ids, coord);
const continuation = humanValidationContinuationRef.current;
const startStageIndex =
continuation?.mode === "dag" ? continuation.nextStageIndex : 0;
if (continuation?.mode === "dag") {
humanValidationContinuationRef.current = null;
}
let finishedAllStages = true;
// 跨 stage 累积的**完整交付** {name, content}[] —— 注入下一 stage 席位的 run context
// (peerDeliveries),供 read_peer_delivery 工具按需取全文(§12.9 传摘要按需传全文)。
// 研讨前并行取数(gatherFirst)的产物作为初始注入:首个 stage 席位开局即带共享资料。
const priorDeliveries: Array<{ name: string; content: string }> = [...gatheredDeliveriesRef.current];
const priorDeliveries: SeatDelivery[] =
continuation?.mode === "dag"
? [...continuation.priorDeliveries]
: [...gatheredDeliveriesRef.current];
// Phase 7:初始化 DAG 流程图节点状态 —— 计划内所有席位置 pending,执行过程中逐个点亮。
const markDagNode = (agentId: string, state: ChainNodeState) =>
setDagNodeStates((prev) => ({ ...prev, [agentId.toLowerCase()]: state }));
setDagNodeStates(
Object.fromEntries(
plan.stages.flatMap((st) => st.agentIds.map((id) => [id.toLowerCase(), "pending" as ChainNodeState])),
plan.stages.flatMap((st, stageIndex) =>
st.agentIds.map((id) => [
id.toLowerCase(),
(stageIndex < startStageIndex ? "done" : "pending") as ChainNodeState,
]),
),
),
);
try {
for (let s = 0; s < plan.stages.length; s += 1) {
for (let s = startStageIndex; s < plan.stages.length; s += 1) {
if (isStale()) {
finishedAllStages = false;
break;
@@ -2558,6 +2617,21 @@ export function useStep2Orchestration(
setConsensusPercentage(
Math.min(95, Math.round(((s + 1) / plan.stages.length) * 90)),
);
const validationSeatNames = stageSeats
.filter(({ agentId }) => humanValidationsRef.current[agentId])
.map(({ name }) => name);
if (validationSeatNames.length > 0) {
pauseForHumanValidation(
{
mode: "dag",
nextStageIndex: s + 1,
priorDeliveries: [...priorDeliveries],
},
validationSeatNames,
);
finishedAllStages = false;
break;
}
}
// ── 计划阶段全部完成 → **交给总控(leader 自由派活循环)继续** ──
@@ -2613,6 +2687,7 @@ export function useStep2Orchestration(
ingestParallelDelta,
resetParallelStreams,
runOrchestration,
pauseForHumanValidation,
],
);
@@ -2746,8 +2821,9 @@ export function useStep2Orchestration(
// 改走 leader 自由派活循环,由总控接着派活,而不是把 DAG 从头重跑一遍。
if (
mode === "dag" &&
!dagPlanExecutedRef.current &&
(orchestrationPlanRef.current?.stages.length ?? 0) > 0
(humanValidationContinuationRef.current?.mode === "dag" ||
(!dagPlanExecutedRef.current &&
(orchestrationPlanRef.current?.stages.length ?? 0) > 0))
) {
return runDagOrchestration(seedMessage, ids, coord);
}
@@ -3070,6 +3146,7 @@ export function useStep2Orchestration(
const resetInitGate = useCallback(() => {
step2InitRef.current = false;
humanValidationContinuationRef.current = null;
// 新一轮研讨:允许 DAG 计划重新执行一遍。
dagPlanExecutedRef.current = false;
// 新一轮研讨:允许「研讨前并行取数」再取一次(清掉上一轮取数产物 + 闸门)。
@@ -3086,6 +3163,7 @@ export function useStep2Orchestration(
// 这里只重复设 cancel ref 与 UI 标记,确保从 Step 2 在编排中切到草稿时
// UI 立刻翻成静止态。
orchestrationCancelRef.current = true;
humanValidationContinuationRef.current = null;
setIsOrchestrating(false);
setIsInitializing(false);
setPendingClarification(null);
@@ -3166,6 +3244,7 @@ export function useStep2Orchestration(
return false;
}
dagPlanExecutedRef.current = true;
humanValidationContinuationRef.current = null;
orchestrationCancelRef.current = true;
abortAllInFlight();
isOrchestratingRef.current = false;
@@ -3208,6 +3287,7 @@ export function useStep2Orchestration(
hasAutoOpenedRef.current = new Set();
pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false;
humanValidationContinuationRef.current = null;
gatherPhaseDoneRef.current = false;
gatheredDeliveriesRef.current = [];
deselectArtifact();
@@ -36,6 +36,11 @@ export interface ChainSeat {
* (无需 DB 列),在链条编辑器里逐席位开关。
*/
seat_skill_directive?: boolean;
/**
* Human-validation checkpoint. When enabled, Step 2 pauses after this seat
* (or its DAG stage) completes and reuses the existing resume/intervention UI.
*/
human_validation?: boolean;
/**
* 可选「席位推理深度覆盖」:覆盖链条级 `seatMode`,只对该席位生效。值是同一套
* `SeatMode`(flash/thinking/pro/ultra);null / 缺省 = 跟随链条级默认。用于「卡顿提速」:
@@ -109,7 +114,7 @@ export interface SeatSelectionResult {
* `model` 为该席位在来源链条里配置的模型覆盖(空 / 缺省 = 跟随全局模型)。
* `seat_skill_directive` 为该席位在来源链条里配置的「技能识别强化」开关(缺省 = 关)。
*/
agents: Array<{ agent_id: string; name: string; model?: string | null; seat_skill_directive?: boolean; seatMode?: SeatMode | null }>;
agents: Array<{ agent_id: string; name: string; model?: string | null; seat_skill_directive?: boolean; human_validation?: boolean; seatMode?: SeatMode | null }>;
/**
* 决定 Step 2 走哪种编排:
* - `recommend` 总控自由派活;
@@ -171,6 +176,10 @@ export function buildChainSeatSelection(
// 席位「技能识别强化」开关(链条配置):按 agent_id 取,缺省 = 关。
const skillById = new Map(chain.seats.map((s) => [s.agent_id, s.seat_skill_directive ?? false]));
const skillOf = (id: string) => skillById.get(id) ?? false;
const humanValidationById = new Map(
chain.seats.map((s) => [s.agent_id, s.human_validation ?? false]),
);
const humanValidationOf = (id: string) => humanValidationById.get(id) ?? false;
// 席位「推理深度覆盖」(链条配置 seat_mode):按 agent_id 取,null/缺省 = 跟随链条级 seatMode。
// 输出到 SeatSelectionResult.agents[].seatMode(camel,喂给编排/作业的 seatModes map)。
const seatModeById = new Map(chain.seats.map((s) => [s.agent_id, s.seat_mode ?? null]));
@@ -196,12 +205,12 @@ export function buildChainSeatSelection(
if (hasParallel || hasGoal || hasCoord) {
const agents = plan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), seatMode: seatModeOf(id) }));
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), human_validation: humanValidationOf(id), seatMode: seatModeOf(id) }));
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "sandbox", seatMode: chain.seatMode, gatherFirst: chain.gatherFirst ?? false };
}
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id), human_validation: humanValidationOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode, gatherFirst: chain.gatherFirst ?? false };
}
@@ -12,6 +12,7 @@ import {
moveSeatToStage,
moveStage,
removeSeat,
setSeatHumanValidation,
setStageGoal,
splitSeatToNewStage,
stageGroupsToSeats,
@@ -122,6 +123,15 @@ test("setStageGoal is immutable + out-of-range no-op", () => {
assert.equal(setStageGoal(groups, 9, "x"), groups); // 越界返回原引用
});
test("setSeatHumanValidation updates only the selected seat and is immutable", () => {
const groups = grp([seat("a"), seat("b")], [seat("c")]);
const next = setSeatHumanValidation(groups, "b", true);
assert.equal(next[0].seats[1].human_validation, true);
assert.equal(next[0].seats[0].human_validation, undefined);
assert.equal(next[1].seats[0].human_validation, undefined);
assert.equal(groups[0].seats[1].human_validation, undefined);
});
// ── lookups ───────────────────────────────────────────────────────────────────
test("findStageIndexOfSeat / countSeats", () => {
@@ -141,6 +141,23 @@ export function setSeatSkillDirective(groups: StageGroups, agentId: string, enab
});
}
/** Update a seat's human-validation checkpoint without changing other stages. */
export function setSeatHumanValidation(
groups: StageGroups,
agentId: string,
enabled: boolean,
): StageGroups {
return groups.map((g) => {
if (!g.seats.some((s) => s.agent_id === agentId)) return g;
return {
...g,
seats: g.seats.map((s) =>
s.agent_id === agentId ? { ...s, human_validation: enabled } : s,
),
};
});
}
/**
* 设置某个席位的「推理深度覆盖」(不可变,供编辑器席位下拉 onChange)。
* `mode` 为 null = 清除覆盖(跟随链条级 seatMode)。按 agent_id 定位,跨 stage 适用。
@@ -292,9 +292,29 @@ function toStringArray(value: unknown): string[] {
* 把解析出的对象归一成 IntentSummary。只要 `objective` 是非空字符串就认。
* constraints / assumptions 容忍模型写成字符串 / null / 缺失,统一归到 string[]。
*/
function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
export function normalizeIntentReadySummary(parsed: unknown): IntentSummary | null {
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
const positionKeys = ["coreGoals", "riskWarnings", "keyPoints", "strategicSignificance"];
if (positionKeys.some((key) => key in obj)) {
if (!positionKeys.every((key) => Array.isArray(obj[key]))) return null;
const coreGoals = toStringArray(obj.coreGoals);
const riskWarnings = toStringArray(obj.riskWarnings);
const keyPoints = toStringArray(obj.keyPoints);
const strategicSignificance = toStringArray(obj.strategicSignificance);
// Keep the long-standing Step-1 state machine compatible while retaining
// every position-specific field verbatim for its dedicated UI and storage.
return {
objective:
strategicSignificance[0] || coreGoals[0] || keyPoints[0] || "岗位任务意图已识别",
constraints: riskWarnings,
assumptions: keyPoints,
coreGoals,
riskWarnings,
keyPoints,
strategicSignificance,
};
}
const objective =
typeof obj.objective === "string" ? obj.objective.trim() : "";
if (!objective) return null;
@@ -310,14 +330,14 @@ function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
* 1) 原文直接 parse
* 2) 转义字符串内的裸控制字符(换行 / 制表符)后再 parse
* 3) 再去掉尾随逗号后 parse
* 三次都失败才放弃。任一成功都过 `normalizeIntentSummary` 兜字段类型。
* 三次都失败才放弃。任一成功都过 `normalizeIntentReadySummary` 兜字段类型。
*/
function parseIntentSummary(raw: string): IntentSummary | null {
const sanitized = sanitizeJsonControlChars(raw);
const candidates = [raw, sanitized, stripTrailingCommas(sanitized)];
for (const candidate of candidates) {
try {
const summary = normalizeIntentSummary(JSON.parse(candidate));
const summary = normalizeIntentReadySummary(JSON.parse(candidate));
if (summary) return summary;
} catch {
// 下一个候选
@@ -41,6 +41,7 @@ import {
Sparkles,
SplitSquareVertical,
Trash2,
UserCheck,
UserPlus,
Wand2,
X,
@@ -127,6 +128,7 @@ import {
seatExistsInGroups,
setSeatMode as setSeatModeOnGroups,
setSeatModel,
setSeatHumanValidation,
setSeatSkillDirective,
setStageGoal,
splitSeatToNewStage,
@@ -453,6 +455,10 @@ export default function BusinessChainEditorPage() {
setStageGroups((prev) => setSeatSkillDirective(prev, agentId, enabled));
}, []);
const setSeatHumanValidationCb = useCallback((agentId: string, enabled: boolean) => {
setStageGroups((prev) => setSeatHumanValidation(prev, agentId, enabled));
}, []);
// 席位「推理深度覆盖」:编辑器席位小图标下拉 onChange(null = 清除,跟随链条级 seatMode)。
const setSeatModeCb = useCallback((agentId: string, mode: SeatMode | null) => {
setStageGroups((prev) => setSeatModeOnGroups(prev, agentId, mode));
@@ -670,6 +676,7 @@ export default function BusinessChainEditorPage() {
model: s.model ?? null,
// 席位「会商技能识别强化」开关:随席位一起落库(缺省 = false 关)。
seat_skill_directive: s.seat_skill_directive ?? false,
human_validation: s.human_validation ?? false,
// 席位「推理深度覆盖」:随席位一起落库(null = 跟随链条级 seatMode)。
seat_mode: s.seat_mode ?? null,
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
@@ -1272,6 +1279,7 @@ export default function BusinessChainEditorPage() {
onSplitSeat={splitSeat}
onSeatModelChange={setSeatModelCb}
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
onSeatHumanValidationChange={setSeatHumanValidationCb}
onSeatModeChange={setSeatModeCb}
positionConfigMode={positionConfigMode}
positionOptions={positionOptions}
@@ -1455,6 +1463,7 @@ function StagesColumn({
onSplitSeat,
onSeatModelChange,
onSeatSkillDirectiveChange,
onSeatHumanValidationChange,
onSeatModeChange,
positionConfigMode,
positionOptions,
@@ -1476,6 +1485,7 @@ function StagesColumn({
onSplitSeat: (agentId: string) => void;
onSeatModelChange: (agentId: string, model: string | null) => void;
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
onSeatHumanValidationChange: (agentId: string, enabled: boolean) => void;
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
/** 仅岗位协同入口启用;普通圆桌编辑器不显示岗位控件。 */
positionConfigMode: boolean;
@@ -1586,6 +1596,7 @@ function StagesColumn({
onSplitSeat={onSplitSeat}
onSeatModelChange={onSeatModelChange}
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
onSeatHumanValidationChange={onSeatHumanValidationChange}
onSeatModeChange={onSeatModeChange}
positionConfigMode={positionConfigMode}
positionOptions={positionOptions}
@@ -1620,6 +1631,7 @@ function StageSection({
onSplitSeat,
onSeatModelChange,
onSeatSkillDirectiveChange,
onSeatHumanValidationChange,
onSeatModeChange,
positionConfigMode,
positionOptions,
@@ -1643,6 +1655,7 @@ function StageSection({
onSplitSeat: (agentId: string) => void;
onSeatModelChange: (agentId: string, model: string | null) => void;
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
onSeatHumanValidationChange: (agentId: string, enabled: boolean) => void;
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
positionConfigMode: boolean;
positionOptions: readonly PositionOption[];
@@ -1809,6 +1822,7 @@ function StageSection({
onSplit={() => onSplitSeat(seat.agent_id)}
onModelChange={(model) => onSeatModelChange(seat.agent_id, model)}
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
onHumanValidationChange={(enabled) => onSeatHumanValidationChange(seat.agent_id, enabled)}
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
showPositionConfig={positionConfigMode}
positionOptions={positionOptions}
@@ -1837,6 +1851,7 @@ function SeatStrip({
onSplit,
onModelChange,
onSkillDirectiveChange,
onHumanValidationChange,
onSeatModeChange,
showPositionConfig,
positionOptions,
@@ -1857,6 +1872,7 @@ function SeatStrip({
onModelChange: (model: string | null) => void;
/** 席位「会商技能识别强化」开关变更。 */
onSkillDirectiveChange: (enabled: boolean) => void;
onHumanValidationChange: (enabled: boolean) => void;
/** 席位「推理深度覆盖」变更(null = 跟随链条级 seatMode)。 */
onSeatModeChange: (mode: SeatMode | null) => void;
/** 仅岗位协同入口展示的岗位配置。 */
@@ -2058,6 +2074,38 @@ function SeatStrip({
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title="人工校验:该席位完成后暂停会商,等待用户恢复或输入修改意见"
className={cn(
"p-1 transition",
R_CTRL,
seat.human_validation
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<UserCheck className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 p-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<p className="text-xs font-semibold text-foreground">人工校验</p>
<p className="text-[11px] leading-relaxed text-muted-foreground">
开启后,该席位所在的执行单元完成时会自动暂停。用户可直接点击“恢复会商”继续,或在下方输入修改意见后提交;不会新增校验卡片。
</p>
</div>
<Switch
checked={seat.human_validation ?? false}
onCheckedChange={onHumanValidationChange}
aria-label={`切换 ${seat.name || seat.agent_id} 的人工校验`}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={onDetail}
@@ -409,6 +409,10 @@ export default function RoundtablePlanningPage({
// Step 2 每席位「技能识别强化」开关映射 {agentId: bool}:由所选业务链条里**每个席位**的同名
// 配置决定(recommend 无链条 → 空 {})。透传给每个席位 run,与各席位智能体自身的开关取 OR。
const [seatSkillDirectives, setSeatSkillDirectives] = useState<Record<string, boolean>>({});
// Per-seat human-validation checkpoints from the selected business chain.
// A checkpoint pauses Step 2 after the configured seat/stage completes and
// reuses the existing resume button plus bottom intervention composer.
const [humanValidations, setHumanValidations] = useState<Record<string, boolean>>({});
// Step 2 每席位「推理深度覆盖」映射 {agentId: SeatMode|null}:由所选业务链条里**每个席位**单独配
// 的推理深度决定(null/未配 → 跟随链条级 seatMode)。透传给席位 run(覆盖优先、否则跟随 seatMode)。
// 「卡顿提速」用:把次要席位降到 flash 即可大幅缩短第二步耗时。
@@ -573,6 +577,7 @@ export default function RoundtablePlanningPage({
selectedModel,
selectedMode: seatMode,
seatSkillDirectives,
humanValidations,
seatModes,
gatherFirst,
orchestrationMode,
@@ -1058,6 +1063,9 @@ export default function RoundtablePlanningPage({
setSeatSkillDirectives(
Object.fromEntries(result.agents.map((a) => [a.agent_id, a.seat_skill_directive ?? false])),
);
setHumanValidations(
Object.fromEntries(result.agents.map((a) => [a.agent_id, a.human_validation ?? false])),
);
// 每席位推理深度覆盖:取所选业务链条里**每席位**单独配的 seatMode → {agentId: SeatMode|null}
// 映射(recommend / 未配 → null,跟随链条级 seatMode)。
setSeatModes(