Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
2f7f07467e
28 files changed
+3277
-5368
No files matched your search
@@ -24,6 +24,7 @@ import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
@@ -102,7 +103,9 @@ export function MessageList({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmitAllClarifications = useCallback(async () => {
|
||||
const handleSubmitAllClarifications = useCallback(async (
|
||||
selectionOverrides?: Map<string, ClarificationSelection>,
|
||||
) => {
|
||||
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +114,7 @@ export function MessageList({
|
||||
const id = pendingClarificationIds[index]!;
|
||||
const message = messages.find((msg) => msg.id === id);
|
||||
const payload = message ? getClarificationPayload(message) : null;
|
||||
const selection = clarificationSelections.get(id) ?? {
|
||||
const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
@@ -221,7 +224,12 @@ export function MessageList({
|
||||
}
|
||||
submitting={submittingClarification}
|
||||
canSubmit={hasAnyClarificationAnswer}
|
||||
onSubmitAll={handleSubmitAllClarifications}
|
||||
onSubmitAll={(override) => {
|
||||
const overrides = override
|
||||
? new Map([[messageId, override]])
|
||||
: undefined;
|
||||
void handleSubmitAllClarifications(overrides);
|
||||
}}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -412,6 +420,7 @@ type ClarificationPayload = {
|
||||
clarification_type?: string;
|
||||
options?: ClarificationOption[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
};
|
||||
|
||||
type ClarificationSelection = {
|
||||
@@ -600,7 +609,7 @@ function ClarificationCard({
|
||||
onSelectionChange: (next: ClarificationSelection) => void;
|
||||
submitting: boolean;
|
||||
canSubmit: boolean;
|
||||
onSubmitAll: () => void | Promise<void>;
|
||||
onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message);
|
||||
|
||||
@@ -624,11 +633,21 @@ function ClarificationCard({
|
||||
|
||||
const disabled = !isPending || submitting;
|
||||
const multiPending = (pendingCount ?? 0) > 1;
|
||||
const allowMultiple = resolveAllowMultiple(
|
||||
payload.allow_multiple,
|
||||
payload.clarification_type,
|
||||
);
|
||||
|
||||
function toggleOption(optionId: string) {
|
||||
function handleOptionClick(optionId: string) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (!allowMultiple) {
|
||||
const next = { selectedIds: [optionId], custom: "" };
|
||||
onSelectionChange(next);
|
||||
void onSubmitAll(next);
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
? selection.selectedIds.filter((id) => id !== optionId)
|
||||
: [...selection.selectedIds, optionId];
|
||||
@@ -666,7 +685,7 @@ function ClarificationCard({
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
可多选(再次点击取消选择)
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const optionId = option.id || option.label;
|
||||
@@ -675,10 +694,10 @@ function ClarificationCard({
|
||||
<Button
|
||||
key={optionId}
|
||||
type="button"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
variant={isSelected && allowMultiple ? "default" : "outline"}
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={disabled}
|
||||
onClick={() => toggleOption(optionId)}
|
||||
onClick={() => handleOptionClick(optionId)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span>{option.label}</span>
|
||||
@@ -695,7 +714,7 @@ function ClarificationCard({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected ? (
|
||||
{isSelected && allowMultiple ? (
|
||||
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</Button>
|
||||
@@ -720,10 +739,10 @@ function ClarificationCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending ? (
|
||||
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
|
||||
isLastPending ? (
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
{multiPending ? (
|
||||
{multiPending && allowMultiple ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
将一起提交以上 {pendingCount} 个问题的回答
|
||||
</span>
|
||||
@@ -735,14 +754,14 @@ function ClarificationCard({
|
||||
onClick={() => void onSubmitAll()}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
|
||||
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : allowMultiple ? (
|
||||
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||||
选择后等待最后一题统一提交
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { ClarificationPayload } from "./multi-agent";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type { ClarificationPayload };
|
||||
|
||||
/**
|
||||
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
|
||||
* built-in agent and no per-call coordinator: one /init creates a thread
|
||||
@@ -73,12 +78,17 @@ export interface StreamIntentRequest {
|
||||
}
|
||||
|
||||
export interface StreamIntentResult {
|
||||
/** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */
|
||||
status: "asking" | "done" | "error";
|
||||
/** "asking" → keep the dialogue going; "clarification" → structured question card; "done" → summary is ready; "error" → backend failure. */
|
||||
status: "asking" | "clarification" | "done" | "error";
|
||||
/** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */
|
||||
content: string;
|
||||
/** Populated only when status === "done". */
|
||||
summary: IntentSummary | null;
|
||||
/**
|
||||
* Populated when the intent agent called ask_clarification — render option
|
||||
* cards + custom input instead of treating the turn as plain text only.
|
||||
*/
|
||||
clarification: ClarificationPayload | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,14 +97,25 @@ export interface StreamIntentResult {
|
||||
* is optional.
|
||||
*/
|
||||
function isFinalIntentFrame(value: unknown): value is {
|
||||
status: "asking" | "done" | "error";
|
||||
status: "asking" | "clarification" | "done" | "error";
|
||||
content?: string;
|
||||
summary?: IntentSummary | null;
|
||||
question?: string;
|
||||
clarification_type?: string;
|
||||
clarification_context?: string;
|
||||
options?: unknown[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
const status = (value as { status: unknown }).status;
|
||||
return status === "asking" || status === "done" || status === "error";
|
||||
return (
|
||||
status === "asking" ||
|
||||
status === "clarification" ||
|
||||
status === "done" ||
|
||||
status === "error"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +187,12 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
req.onPhaseChange?.(next, toolName);
|
||||
};
|
||||
|
||||
const result: StreamIntentResult = { status: "asking", content: "", summary: null };
|
||||
const result: StreamIntentResult = {
|
||||
status: "asking",
|
||||
content: "",
|
||||
summary: null,
|
||||
clarification: null,
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -205,6 +231,22 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
if (parsed.summary && typeof parsed.summary === "object") {
|
||||
result.summary = parsed.summary as IntentSummary;
|
||||
}
|
||||
if (parsed.status === "clarification") {
|
||||
const clarificationType =
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||
result.clarification = {
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type ThreadIdMap = Record<string, string>;
|
||||
|
||||
export interface SeatReadyEvent {
|
||||
@@ -222,6 +224,8 @@ export interface ClarificationPayload {
|
||||
options: unknown[];
|
||||
/** Whether the user is allowed to type a custom answer. */
|
||||
allowCustom: boolean;
|
||||
/** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamMultiAgentResult {
|
||||
@@ -257,6 +261,7 @@ function isFinalStatusFrame(value: unknown): value is {
|
||||
clarification_context?: string;
|
||||
options?: unknown[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
@@ -447,14 +452,19 @@ export async function streamMultiAgent(
|
||||
// a question (or chip choices) instead of mistaking the run for
|
||||
// a consensus.
|
||||
if (parsed.status === "clarification") {
|
||||
const clarificationType =
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||
result.clarification = {
|
||||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||
clarificationType:
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "",
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type { Agent } from "@/core/agents";
|
||||
|
||||
import type { IntentSummary } from "./intent";
|
||||
|
||||
/**
|
||||
* Agent-recommendation API. Sits between Step 1 (intent clarification) and
|
||||
* Step 2 (multi-agent roundtable): given the IntentSummary and a candidate
|
||||
* agent pool, the built-in `roundtable-recommender` picks 2-6 agents most
|
||||
* relevant to the task and explains why.
|
||||
*
|
||||
* One-shot, stateless: no /init, no thread_id, each call spins up a fresh
|
||||
* backend-managed thread. Use {@link filterRecommendCandidates} to clean the
|
||||
* `/api/agents` list before passing it as `candidates`.
|
||||
*/
|
||||
|
||||
/** Single agent pick returned by the recommender. */
|
||||
export interface RecommendPick {
|
||||
agent_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Final status frame the backend appends after the upstream stream finishes. */
|
||||
export interface StreamRecommendResult {
|
||||
/**
|
||||
* `done` → picks parsed and validated, ready to populate the dialog as
|
||||
* default-checked selections.
|
||||
* `asking` → recommender failed to emit a usable [RECOMMEND_READY] block,
|
||||
* caller should apply its fallback list (typically the 6 roundtable-* seats).
|
||||
* `error` → upstream / network failure.
|
||||
*/
|
||||
status: "done" | "asking" | "error";
|
||||
/** Full final AI text (includes the natural-language rationale). */
|
||||
content: string;
|
||||
/** Populated only when status === "done"; null otherwise. */
|
||||
picks: RecommendPick[] | null;
|
||||
}
|
||||
|
||||
/** Phase mirror of streamIntent so the UI can reuse one loading-label mapper. */
|
||||
export type RecommendStreamPhase = "connecting" | "connected" | "streaming";
|
||||
|
||||
export interface StreamRecommendRequest {
|
||||
/** Step 1 output. `objective` is required by the backend. */
|
||||
intent: IntentSummary;
|
||||
/** Pre-filtered candidate pool (use {@link filterRecommendCandidates}). */
|
||||
candidates: RecommendCandidate[];
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
/** Fires whenever parsed natural-language rationale changes (excludes [RECOMMEND_READY] + JSON). */
|
||||
onRationaleUpdate?: (rationale: string) => void;
|
||||
/** Fires whenever newly complete pick objects are parsed from the streaming JSON block. */
|
||||
onPicksUpdate?: (picks: RecommendPick[]) => void;
|
||||
/** Fires for every raw AI text chunk (includes JSON); prefer onRationaleUpdate/onPicksUpdate in UI. */
|
||||
onTextDelta?: (text: string, messageId: string) => void;
|
||||
/** Fires whenever the inferred SSE phase changes. */
|
||||
onPhaseChange?: (phase: RecommendStreamPhase) => void;
|
||||
}
|
||||
|
||||
/** Shape the backend expects in `candidates[]`. */
|
||||
export interface RecommendCandidate {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent ids that must never enter the candidate pool. Centralized here so
|
||||
* every caller (recommend dialog, future tools) applies the same rule.
|
||||
*
|
||||
* - `roundtable-intent` — Step 1 clarification specialist, no role in research.
|
||||
* - `roundtable-recommender`— the recommender itself, can't recommend itself.
|
||||
* - `roundtable-coordinator-*` — disposable Step 2 coordinator shells (DB
|
||||
* accumulates one per session); meaningless as roundtable participants.
|
||||
*/
|
||||
const EXCLUDED_AGENT_ID_PREFIX = "roundtable-coordinator-";
|
||||
const EXCLUDED_AGENT_IDS = new Set<string>(["roundtable-intent", "roundtable-recommender"]);
|
||||
|
||||
/**
|
||||
* Drop excluded ids from a raw `GET /api/agents` list and project each entry
|
||||
* to the slim shape the backend expects. Returns a fresh array; original
|
||||
* objects are not mutated.
|
||||
*/
|
||||
export function filterRecommendCandidates(agents: Agent[]): RecommendCandidate[] {
|
||||
const out: RecommendCandidate[] = [];
|
||||
for (const a of agents) {
|
||||
if (!a || !a.id) continue;
|
||||
if (EXCLUDED_AGENT_IDS.has(a.id)) continue;
|
||||
if (a.id.startsWith(EXCLUDED_AGENT_ID_PREFIX)) continue;
|
||||
out.push({
|
||||
agent_id: a.id,
|
||||
name: a.name ?? a.id,
|
||||
description: a.description ?? "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isFinalRecommendFrame(value: unknown): value is {
|
||||
status: "done" | "asking" | "error";
|
||||
content?: string;
|
||||
picks?: RecommendPick[] | null;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
const status = (value as { status: unknown }).status;
|
||||
return status === "done" || status === "asking" || status === "error";
|
||||
}
|
||||
|
||||
const RECOMMEND_READY_MARKER = "[RECOMMEND_READY]";
|
||||
|
||||
/** Match one complete pick object inside a partially streamed JSON block. */
|
||||
const PICK_OBJECT_PATTERN =
|
||||
/\{\s*"agent_id"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"reason"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}/g;
|
||||
|
||||
function unescapeJsonString(raw: string): string {
|
||||
try {
|
||||
return JSON.parse(`"${raw}"`) as string;
|
||||
} catch {
|
||||
return raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split accumulated recommender output into visible rationale text and any
|
||||
* pick objects that have fully arrived in the JSON section.
|
||||
*/
|
||||
export function parseRecommendStreamContent(raw: string): {
|
||||
rationale: string;
|
||||
picks: RecommendPick[];
|
||||
} {
|
||||
const markerIdx = raw.indexOf(RECOMMEND_READY_MARKER);
|
||||
let rationale = markerIdx >= 0 ? raw.slice(0, markerIdx) : raw;
|
||||
rationale = rationale.replace(/\[(?:RECOMMEND(?:_(?:READY)?)?)?\]?$/i, "").trimEnd();
|
||||
|
||||
const jsonSection =
|
||||
markerIdx >= 0 ? raw.slice(markerIdx + RECOMMEND_READY_MARKER.length) : "";
|
||||
|
||||
const picks: RecommendPick[] = [];
|
||||
const seen = new Set<string>();
|
||||
PICK_OBJECT_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = PICK_OBJECT_PATTERN.exec(jsonSection)) !== null) {
|
||||
const agent_id = unescapeJsonString(match[1]).trim();
|
||||
const reason = unescapeJsonString(match[2]).trim();
|
||||
if (!agent_id || seen.has(agent_id)) continue;
|
||||
seen.add(agent_id);
|
||||
picks.push({ agent_id, reason });
|
||||
}
|
||||
|
||||
return { rationale, picks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an AI text delta from one LangGraph `messages-tuple` frame. Same
|
||||
* shape as the helper in intent.ts; duplicated rather than imported to keep
|
||||
* each helper independently readable.
|
||||
*/
|
||||
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
|
||||
if (!Array.isArray(parsed) || parsed.length < 1) return null;
|
||||
|
||||
if (parsed.length >= 2 && parsed[1] && typeof parsed[1] === "object") {
|
||||
const meta = parsed[1] as Record<string, unknown>;
|
||||
const node = String(meta.langgraph_node ?? "");
|
||||
if (node.includes("TitleMiddleware")) return null;
|
||||
const tags = meta.tags;
|
||||
if (Array.isArray(tags) && tags.includes("middleware:title")) return null;
|
||||
}
|
||||
|
||||
const chunk = parsed[0];
|
||||
if (!chunk || typeof chunk !== "object") return null;
|
||||
const obj = chunk as Record<string, unknown>;
|
||||
const type = String(obj.type ?? "").toLowerCase();
|
||||
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
|
||||
const content = obj.content;
|
||||
let text = "";
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (typeof item === "string") text += item;
|
||||
else if (item && typeof item === "object") {
|
||||
const block = item as Record<string, unknown>;
|
||||
const blockText = block.text;
|
||||
if (typeof blockText === "string") text += blockText;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!text) return null;
|
||||
const id = typeof obj.id === "string" ? obj.id : "";
|
||||
return { text, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an SSE connection to `/api/recommend/stream` and resolve once the
|
||||
* upstream stream finishes. Each call is one recommendation round — the
|
||||
* recommender is stateless, so calling twice in a row produces two
|
||||
* independent runs.
|
||||
*/
|
||||
export async function streamRecommend(req: StreamRecommendRequest): Promise<StreamRecommendResult> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/recommend/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
intent: req.intent,
|
||||
candidates: req.candidates,
|
||||
model: req.model,
|
||||
}),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`recommend/stream failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let lastEvent = "";
|
||||
|
||||
let currentPhase: RecommendStreamPhase = "connecting";
|
||||
req.onPhaseChange?.("connecting");
|
||||
const setPhase = (next: RecommendStreamPhase) => {
|
||||
if (next === currentPhase) return;
|
||||
currentPhase = next;
|
||||
req.onPhaseChange?.(next);
|
||||
};
|
||||
|
||||
const result: StreamRecommendResult = { status: "asking", content: "", picks: null };
|
||||
let rawContent = "";
|
||||
let lastRationale = "";
|
||||
let lastPickCount = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("event:")) {
|
||||
lastEvent = line.slice(6).trim();
|
||||
if (lastEvent === "metadata" && currentPhase === "connecting") {
|
||||
setPhase("connected");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFinalRecommendFrame(parsed)) {
|
||||
result.status = parsed.status;
|
||||
if (typeof parsed.content === "string") result.content = parsed.content;
|
||||
if (Array.isArray(parsed.picks)) {
|
||||
result.picks = parsed.picks as RecommendPick[];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastEvent === "messages") {
|
||||
const delta = extractAiTextDelta(parsed);
|
||||
if (delta) {
|
||||
setPhase("streaming");
|
||||
req.onTextDelta?.(delta.text, delta.id);
|
||||
rawContent += delta.text;
|
||||
const { rationale, picks } = parseRecommendStreamContent(rawContent);
|
||||
if (rationale !== lastRationale) {
|
||||
lastRationale = rationale;
|
||||
req.onRationaleUpdate?.(rationale);
|
||||
}
|
||||
if (picks.length > lastPickCount) {
|
||||
lastPickCount = picks.length;
|
||||
req.onPicksUpdate?.(picks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.content) {
|
||||
const { rationale, picks } = parseRecommendStreamContent(result.content);
|
||||
if (rationale !== lastRationale) {
|
||||
req.onRationaleUpdate?.(rationale);
|
||||
}
|
||||
if (picks.length > lastPickCount) {
|
||||
req.onPicksUpdate?.(picks);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
export type IntentClarificationSelection = {
|
||||
selectedIds: string[];
|
||||
custom: string;
|
||||
};
|
||||
|
||||
type ClarificationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
function normalizeOptions(raw: unknown[]): ClarificationOption[] {
|
||||
return raw
|
||||
.map((opt, index) => {
|
||||
if (typeof opt === "string") {
|
||||
const label = opt.trim();
|
||||
return label ? { id: `option-${index}`, label } : null;
|
||||
}
|
||||
if (opt && typeof opt === "object" && "label" in opt) {
|
||||
const obj = opt as { id?: unknown; label?: unknown; description?: unknown };
|
||||
const label = String(obj.label ?? "").trim();
|
||||
if (!label) return null;
|
||||
const id = String(obj.id ?? label).trim() || `option-${index}`;
|
||||
const description =
|
||||
typeof obj.description === "string" && obj.description.trim()
|
||||
? obj.description.trim()
|
||||
: undefined;
|
||||
return { id, label, description };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((opt): opt is ClarificationOption => opt !== null);
|
||||
}
|
||||
|
||||
export function formatIntentClarificationAnswer(
|
||||
payload: ClarificationPayload,
|
||||
selection: IntentClarificationSelection,
|
||||
): string | null {
|
||||
const options = normalizeOptions(Array.isArray(payload.options) ? payload.options : []);
|
||||
const selectedLabels = selection.selectedIds
|
||||
.map((sid) => options.find((option) => option.id === sid)?.label)
|
||||
.filter((label): label is string => Boolean(label && label.trim()));
|
||||
const trimmedCustom = selection.custom.trim();
|
||||
|
||||
if (selectedLabels.length > 0 && trimmedCustom) {
|
||||
return `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
|
||||
}
|
||||
if (selectedLabels.length > 0) {
|
||||
return `我选择:${selectedLabels.join("、")}`;
|
||||
}
|
||||
if (trimmedCustom) {
|
||||
return trimmedCustom;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface IntentClarificationCardProps {
|
||||
payload: ClarificationPayload;
|
||||
fallbackContent?: string;
|
||||
selection: IntentClarificationSelection;
|
||||
onSelectionChange: (next: IntentClarificationSelection) => void;
|
||||
disabled?: boolean;
|
||||
submitting?: boolean;
|
||||
/** Multi-select: submit current selection. Single-select: also used for custom text. */
|
||||
onSubmit: (overrideSelection?: IntentClarificationSelection) => void;
|
||||
}
|
||||
|
||||
export function IntentClarificationCard({
|
||||
payload,
|
||||
fallbackContent,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
disabled = false,
|
||||
submitting = false,
|
||||
onSubmit,
|
||||
}: IntentClarificationCardProps) {
|
||||
const options = useMemo(
|
||||
() => normalizeOptions(Array.isArray(payload.options) ? payload.options : []),
|
||||
[payload.options],
|
||||
);
|
||||
|
||||
const allowMultiple = resolveAllowMultiple(
|
||||
payload.allowMultiple,
|
||||
payload.clarificationType,
|
||||
);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return selection.selectedIds.length > 0 || selection.custom.trim().length > 0;
|
||||
}, [selection.custom, selection.selectedIds]);
|
||||
|
||||
const showSubmitButton = allowMultiple || payload.allowCustom !== false;
|
||||
|
||||
function handleOptionClick(optionId: string) {
|
||||
if (disabled || submitting) return;
|
||||
if (!allowMultiple) {
|
||||
onSubmit({ selectedIds: [optionId], custom: "" });
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
? selection.selectedIds.filter((id) => id !== optionId)
|
||||
: [...selection.selectedIds, optionId];
|
||||
onSelectionChange({ ...selection, selectedIds: next });
|
||||
}
|
||||
|
||||
function updateCustom(value: string) {
|
||||
if (disabled || submitting) return;
|
||||
onSelectionChange({ ...selection, custom: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-xl border border-red-100 bg-white p-3 shadow-sm">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-primary">
|
||||
需要你的确认
|
||||
</span>
|
||||
{payload.clarificationType ? (
|
||||
<span className="rounded-full bg-[var(--brand-200)]/60 px-2 py-0.5 text-[9px] font-mono font-bold text-[var(--brand-950)]">
|
||||
{payload.clarificationType}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{payload.context ? (
|
||||
<p className="text-[11px] leading-relaxed text-slate-500 italic">{payload.context}</p>
|
||||
) : null}
|
||||
<div className="text-xs font-medium leading-relaxed text-slate-800">
|
||||
{payload.question || fallbackContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="text-[10px] text-slate-500">
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const isSelected = selection.selectedIds.includes(option.id);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={disabled || submitting}
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition",
|
||||
isSelected && allowMultiple
|
||||
? "border-primary bg-primary text-white shadow-sm"
|
||||
: "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-primary/40 hover:bg-white",
|
||||
!allowMultiple && !disabled && !submitting && "active:scale-[0.99]",
|
||||
(disabled || submitting) && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<span className="text-[11px] font-bold">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-snug",
|
||||
isSelected && allowMultiple ? "text-white/80" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected && allowMultiple ? (
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.allowCustom !== false ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={selection.custom}
|
||||
onChange={(event) => updateCustom(event.currentTarget.value)}
|
||||
placeholder={
|
||||
options.length > 0
|
||||
? allowMultiple
|
||||
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
|
||||
: "如果以上选项都不合适,可以在这里输入自定义回答"
|
||||
: "如果以上选项都不合适,可以在这里输入你的回答"
|
||||
}
|
||||
disabled={disabled || submitting}
|
||||
className="min-h-16 resize-none border-slate-200 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSubmitButton ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={disabled || submitting || !canSubmit}
|
||||
onClick={() => onSubmit()}
|
||||
className="h-8 rounded-xl px-4 text-xs font-bold"
|
||||
>
|
||||
{submitting ? "发送中…" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckIcon, Loader2Icon, RefreshCwIcon, SparklesIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { listAgents, type Agent } from "@/core/agents";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { IntentSummary } from "../api/intent";
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
parseRecommendStreamContent,
|
||||
streamRecommend,
|
||||
type RecommendCandidate,
|
||||
type RecommendPick,
|
||||
type RecommendStreamPhase,
|
||||
} from "../api/recommend";
|
||||
|
||||
/**
|
||||
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
|
||||
* (roundtable). Opens with a streaming recommendation from
|
||||
* `roundtable-recommender`, defaults its picks to checked, and lets the user
|
||||
* trim or extend the selection from the rest of the agent pool before
|
||||
* confirming.
|
||||
*
|
||||
* Selection bounds (2 ≤ N ≤ 8) are enforced on the confirm button — the
|
||||
* backend also clamps but UI feedback is faster here.
|
||||
*/
|
||||
|
||||
export interface RecommendAgentsDialogProps {
|
||||
open: boolean;
|
||||
intent: IntentSummary | null;
|
||||
/** Falls back to the recommender's own default if omitted. */
|
||||
model?: string;
|
||||
onCancel: () => void;
|
||||
/** Called with the user's final selection of agent ids (in display order). */
|
||||
onConfirm: (agentIds: string[]) => void;
|
||||
}
|
||||
|
||||
const MIN_SELECTED = 2;
|
||||
const MAX_SELECTED = 8;
|
||||
|
||||
/**
|
||||
* Default selection when the recommender fails or returns < 2 valid picks.
|
||||
* Intersected with the live candidate pool at runtime so missing built-ins
|
||||
* don't break the dialog.
|
||||
*/
|
||||
const FALLBACK_AGENT_IDS = [
|
||||
"roundtable-intelligence",
|
||||
"roundtable-environment",
|
||||
"roundtable-solution-design",
|
||||
"roundtable-risk-review",
|
||||
"roundtable-execution-plan",
|
||||
"roundtable-summary",
|
||||
];
|
||||
|
||||
type DialogPhase =
|
||||
| "idle"
|
||||
| "loading_agents"
|
||||
| "recommending"
|
||||
| "ready"
|
||||
| "fallback"
|
||||
| "error";
|
||||
|
||||
interface InternalState {
|
||||
phase: DialogPhase;
|
||||
/** Streaming + final rationale text from the recommender. */
|
||||
rationale: string;
|
||||
/** Streaming SSE phase from the recommender helper. */
|
||||
streamPhase: RecommendStreamPhase | null;
|
||||
/** Pre-filtered candidate pool fetched from `/api/agents`. */
|
||||
candidates: RecommendCandidate[];
|
||||
/** Reason-by-agent_id, only for picks returned by the recommender. */
|
||||
reasons: Record<string, string>;
|
||||
/** Agent ids the recommender returned (in order). */
|
||||
recommendedIds: string[];
|
||||
/** User-visible warning ("推荐失败,已使用默认席位") for the fallback case. */
|
||||
warning: string | null;
|
||||
/** Latest error message; only shown when `phase === "error"`. */
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: InternalState = {
|
||||
phase: "idle",
|
||||
rationale: "",
|
||||
streamPhase: null,
|
||||
candidates: [],
|
||||
reasons: {},
|
||||
recommendedIds: [],
|
||||
warning: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve which candidate ids should be checked by default given a set of
|
||||
* recommended ids. The result preserves recommendation order and silently
|
||||
* drops ids that aren't in the candidate pool.
|
||||
*/
|
||||
function intersectInOrder(preferred: string[], pool: RecommendCandidate[]): string[] {
|
||||
const poolIds = new Set(pool.map((c) => c.agent_id));
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of preferred) {
|
||||
if (!poolIds.has(id) || seen.has(id)) continue;
|
||||
out.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function RecommendAgentsDialog({
|
||||
open,
|
||||
intent,
|
||||
model,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RecommendAgentsDialogProps) {
|
||||
const [state, setState] = useState<InternalState>(INITIAL_STATE);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Latest stream abort controller, so closing or re-running cancels the
|
||||
// in-flight request instead of leaking it.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
// Generation token guards against stale completion handlers writing into
|
||||
// state after a re-run (e.g. user clicks "重新推荐" mid-stream).
|
||||
const generationRef = useRef(0);
|
||||
|
||||
const cancelInflight = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The full pipeline: fetch agents → run recommendation → seed selection.
|
||||
* Wrapped so the user can re-trigger it via the "重新推荐" button.
|
||||
*/
|
||||
const runRecommendation = useCallback(async () => {
|
||||
if (!intent || !intent.objective) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "缺少任务目标,无法启动推荐",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
cancelInflight();
|
||||
const myGen = ++generationRef.current;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState({ ...INITIAL_STATE, phase: "loading_agents" });
|
||||
setSelectedIds(new Set());
|
||||
|
||||
let agents: Agent[];
|
||||
try {
|
||||
agents = await listAgents();
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
if (candidates.length === 0) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "recommending",
|
||||
candidates,
|
||||
rationale: "",
|
||||
streamPhase: "connecting",
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await streamRecommend({
|
||||
intent,
|
||||
candidates,
|
||||
model,
|
||||
signal: controller.signal,
|
||||
onRationaleUpdate: (rationale) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => ({ ...prev, rationale }));
|
||||
},
|
||||
onPicksUpdate: (picks) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
for (const p of picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of seed) next.add(id);
|
||||
return next;
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
recommendedIds,
|
||||
reasons,
|
||||
}));
|
||||
},
|
||||
onPhaseChange: (phase) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => ({ ...prev, streamPhase: phase }));
|
||||
},
|
||||
});
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
if (result.picks) {
|
||||
for (const p of result.picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "ready",
|
||||
rationale: rationale || prev.rationale,
|
||||
recommendedIds,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: status === "asking" or picks too few. Use the 6 built-in
|
||||
// roundtable seats, intersected against the live pool.
|
||||
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
|
||||
const seed = fallback.length >= MIN_SELECTED
|
||||
? fallback
|
||||
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "fallback",
|
||||
rationale: rationale || prev.rationale,
|
||||
recommendedIds: seed,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
|
||||
}));
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
// AbortError fires when the user closes the dialog mid-stream — no
|
||||
// state update needed because the dialog is going away.
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
streamPhase: null,
|
||||
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
|
||||
}));
|
||||
}
|
||||
}, [intent, model, cancelInflight]);
|
||||
|
||||
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
|
||||
// in-flight stream so the next open starts clean.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
cancelInflight();
|
||||
return;
|
||||
}
|
||||
void runRecommendation();
|
||||
return () => {
|
||||
cancelInflight();
|
||||
};
|
||||
}, [open, runRecommendation, cancelInflight]);
|
||||
|
||||
const handleToggle = useCallback((agentId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(agentId)) next.delete(agentId);
|
||||
else next.add(agentId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Split candidates into "recommended" and "other" — order within each
|
||||
// group matches the recommender's pick order / the API's list order so the
|
||||
// dialog is deterministic between renders.
|
||||
const { recommendedCards, otherCards } = useMemo(() => {
|
||||
const recSet = new Set(state.recommendedIds);
|
||||
const byId = new Map(state.candidates.map((c) => [c.agent_id, c]));
|
||||
const recommended: RecommendCandidate[] = [];
|
||||
for (const id of state.recommendedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c) recommended.push(c);
|
||||
}
|
||||
const others = state.candidates.filter((c) => !recSet.has(c.agent_id));
|
||||
return { recommendedCards: recommended, otherCards: others };
|
||||
}, [state.candidates, state.recommendedIds]);
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const canConfirm =
|
||||
(state.phase === "ready" || state.phase === "fallback") &&
|
||||
selectedCount >= MIN_SELECTED &&
|
||||
selectedCount <= MAX_SELECTED;
|
||||
|
||||
const selectionHint = (() => {
|
||||
if (selectedCount < MIN_SELECTED) {
|
||||
return `至少再选 ${MIN_SELECTED - selectedCount} 个`;
|
||||
}
|
||||
if (selectedCount > MAX_SELECTED) {
|
||||
return `已超出上限 ${selectedCount - MAX_SELECTED} 个`;
|
||||
}
|
||||
return `已选 ${selectedCount} / ${MAX_SELECTED}`;
|
||||
})();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!canConfirm) return;
|
||||
// Order: recommended-and-selected first (in recommender order), then
|
||||
// user-added picks (in pool order). Keeps the leader's "可调度智能体"
|
||||
// list visually stable.
|
||||
const ordered: string[] = [];
|
||||
for (const c of recommendedCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
|
||||
}
|
||||
for (const c of otherCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id);
|
||||
}
|
||||
onConfirm(ordered);
|
||||
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) onCancel();
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-purple-500" />
|
||||
选择参会智能体
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Rationale + status block */}
|
||||
<div className="border rounded-md bg-muted/30 p-3 text-sm">
|
||||
<RationaleBlock state={state} />
|
||||
</div>
|
||||
|
||||
{/* Selection area: scrollable, takes remaining height */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
|
||||
{state.phase === "error" ? (
|
||||
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
|
||||
{state.errorMessage ?? "未知错误"}
|
||||
</div>
|
||||
) : state.phase === "recommending" ? (
|
||||
<>
|
||||
{recommendedCards.length > 0 ? (
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`}>
|
||||
{recommendedCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={state.reasons[c.agent_id]}
|
||||
recommended
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
streaming
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 py-2">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
等待推荐结果...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (state.phase === "ready" || state.phase === "fallback") ? (
|
||||
<>
|
||||
{state.warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-300 p-2 border border-amber-300/50 dark:border-amber-700/50 rounded-md bg-amber-50 dark:bg-amber-950/30">
|
||||
{state.warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recommendedCards.length > 0 && (
|
||||
<Section title={`推荐参会 (${recommendedCards.length})`}>
|
||||
{recommendedCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={state.reasons[c.agent_id]}
|
||||
recommended
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{otherCards.length > 0 && (
|
||||
<Section title={`其他候选 (${otherCards.length})`}>
|
||||
{otherCards.map((c) => (
|
||||
<AgentCard
|
||||
key={c.agent_id}
|
||||
candidate={c}
|
||||
reason={undefined}
|
||||
recommended={false}
|
||||
checked={selectedIds.has(c.agent_id)}
|
||||
onToggle={() => handleToggle(c.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
selectedCount < MIN_SELECTED || selectedCount > MAX_SELECTED
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{selectionHint}
|
||||
</span>
|
||||
{(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void runRecommendation()}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RefreshCwIcon className="size-3 mr-1" />
|
||||
重新推荐
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!canConfirm}>
|
||||
进入研讨
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RationaleBlock({ state }: { state: InternalState }) {
|
||||
if (state.phase === "loading_agents") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载候选智能体...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.phase === "recommending") {
|
||||
const label =
|
||||
state.streamPhase === "connecting"
|
||||
? "调度推荐智能体..."
|
||||
: state.streamPhase === "connected"
|
||||
? "已连接,等待响应..."
|
||||
: "正在生成推荐...";
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{label}
|
||||
</div>
|
||||
{state.rationale ? (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed">
|
||||
{state.rationale}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.phase === "ready" || state.phase === "fallback") {
|
||||
if (!state.rationale) {
|
||||
return <div className="text-xs text-muted-foreground">推荐完成。</div>;
|
||||
}
|
||||
return (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed max-h-32 overflow-y-auto">
|
||||
{state.rationale}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.phase === "error") {
|
||||
return <div className="text-xs text-destructive">推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。</div>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{title}
|
||||
</div>
|
||||
<div className="grid gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentCardProps {
|
||||
candidate: RecommendCandidate;
|
||||
reason: string | undefined;
|
||||
recommended: boolean;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
/** Card is still being filled in from the SSE stream. */
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
function AgentCard({
|
||||
candidate,
|
||||
reason,
|
||||
recommended,
|
||||
checked,
|
||||
onToggle,
|
||||
streaming = false,
|
||||
}: AgentCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
|
||||
checked
|
||||
? "border-primary bg-primary/5 hover:bg-primary/10"
|
||||
: "border-border hover:bg-muted/40",
|
||||
streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300",
|
||||
)}
|
||||
>
|
||||
{/* Visual check box — simulated, since the project ships no Checkbox primitive */}
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 size-4 rounded border flex items-center justify-center shrink-0",
|
||||
checked ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3" />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div>
|
||||
{recommended && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
|
||||
推荐
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{candidate.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{candidate.description}
|
||||
</div>
|
||||
)}
|
||||
{reason ? (
|
||||
<div className="text-xs text-primary/80 mt-1 italic">
|
||||
<span className="font-medium not-italic">推荐理由:</span> {reason}
|
||||
</div>
|
||||
) : streaming ? (
|
||||
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,10 @@
|
||||
/**
|
||||
* 圆桌规划(Roundtable Planning)模块入口。
|
||||
*
|
||||
* 详细架构 / API 字段速查见 `frontend-web/docs/multi-agent-frontend-dev.md`。
|
||||
* 这里只暴露两个外部使用的组件:
|
||||
* - `RoundtablePlanningPage`:三步合一的主页面(任务理解 → 多智能体研讨 → 方案展示)。
|
||||
* - `HighFidelityReport`:Step 3 报告视图,可单独嵌入其它面板(如未来的「方案对比」)。
|
||||
*/
|
||||
export { default as RoundtablePlanningPage } from "./pages/RoundtablePlanningPage";
|
||||
export { HighFidelityReport } from "./components/HighFidelityReport";
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Resolve whether option cards should allow multi-select.
|
||||
* Default is single-select (click to send); only multi when explicitly requested.
|
||||
*/
|
||||
export function resolveAllowMultiple(
|
||||
allowMultiple: boolean | undefined,
|
||||
_clarificationType?: string | undefined,
|
||||
): boolean {
|
||||
if (typeof allowMultiple === "boolean") {
|
||||
return allowMultiple;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,4 +1,38 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
/**
|
||||
* 圆桌规划主页面(三步合一)。
|
||||
*
|
||||
* 这是一个约 3 600 行的单文件页面,通过 `currentStep ∈ {1,2,3}` 在同一容器内切换
|
||||
* 三步 UI。架构 / 状态机 / SSE 终态语义请先读 `frontend-web/docs/multi-agent-frontend-dev.md`,
|
||||
* 这里只补充阅读源码时最容易忽略的几条规则:
|
||||
*
|
||||
* 1) **不要并行派活**:编排循环 `runOrchestration` 内 `for (const [subName, task] of dispatched)`
|
||||
* 是**顺序**而非并行——历史上并行触发 N 路 SSE,每路 token 级 setState 整表会卡顿;保持顺序。
|
||||
*
|
||||
* 2) **三段式流式气泡**:`appendStreamingDialogue` → `appendToDialogue`(仅增量)→
|
||||
* `finalizeDialogue`(最终文本 + confidence + `streaming=false`)。三个函数都按 `id`
|
||||
* 匹配,绝不要用数组下标。
|
||||
*
|
||||
* 3) **Step 1 init 防 StrictMode 双 mount**:用单调代数 `step1InitGenRef` 而不是闭包 cancelled
|
||||
* 标志。任何 setState 完成前都要校验 `myGen === step1InitGenRef.current`。同样模式见
|
||||
* `RecommendAgentsDialog` 的 `generationRef`。
|
||||
*
|
||||
* 4) **离开 Step 2 不销毁线程**:`step2InitRef`/`threadIds` 保留,允许用户回到 Step 2 继续看
|
||||
* 完整讨论。只有「新建任务 / 加载草稿 / 推荐确认」会复位 `step2InitRef`。
|
||||
*
|
||||
* 5) **草稿自动保存只在前进切换时触发**(1→2、2→3),后退不保存,避免覆盖 `furthestStep`。
|
||||
*
|
||||
* 6) **顶栏门禁**:Step 2 需 `intentReady`;Step 3 需 `hasConsensus`(leader.dispatched===[])。
|
||||
*
|
||||
* 7) **Persona 抽屉**改的 model 不会立即重启编排,下一轮该席位被派活时由 `runOrchestration`
|
||||
* 读 `agentConfigs[meta.speakerId]?.model` 传入 special。prompt / temp 当前**仅前端记录**。
|
||||
*
|
||||
* 8) **常量**:`DEFAULT_MODEL`、`MAX_CYCLES=8`、`COMMANDER_SEAT_ID=1`、`INTENT_READY_RE`。
|
||||
*
|
||||
* 调试开关(默认开启):
|
||||
* window.__MULTI_AGENT_INIT_TIMING__ // /api/multi-agent/init 各阶段耗时
|
||||
* window.__MULTI_AGENT_DEBUG__ // streamMultiAgent 帧计数 + lastEvent
|
||||
*/
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
|
||||
@@ -21,6 +55,12 @@ import {
|
||||
type IntentStreamPhase,
|
||||
type IntentSummary,
|
||||
} from "@/roundtable-planning/api/intent";
|
||||
import {
|
||||
IntentClarificationCard,
|
||||
formatIntentClarificationAnswer,
|
||||
type IntentClarificationSelection,
|
||||
} from "@/roundtable-planning/components/IntentClarificationCard";
|
||||
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
|
||||
import {
|
||||
deleteDraft,
|
||||
deriveDraftName,
|
||||
@@ -159,42 +199,111 @@ type RoleEntry = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
const ROUNDTABLE_ROLE_TEMPLATES: ReadonlyArray<Omit<RoleEntry, "status">> = [
|
||||
{ id: 1, name: "指挥员 / 主持人", type: "human" },
|
||||
{ id: 2, name: "总结陈述", agentId: "roundtable-summary", type: "agent" },
|
||||
{ id: 3, name: "情报收集", agentId: "roundtable-intelligence", type: "agent" },
|
||||
{ id: 4, name: "环境评估", agentId: "roundtable-environment", type: "agent" },
|
||||
{ id: 5, name: "方案设计", agentId: "roundtable-solution-design", type: "agent" },
|
||||
{ id: 6, name: "风险审查", agentId: "roundtable-risk-review", type: "agent" },
|
||||
{ id: 7, name: "执行规划", agentId: "roundtable-execution-plan", type: "agent" },
|
||||
{ id: 8, name: "总结陈述", agentId: "roundtable-summary", type: "agent" },
|
||||
];
|
||||
/** Stable seat id reserved for the human commander row. Agent seats start at 2. */
|
||||
const COMMANDER_SEAT_ID = 1;
|
||||
|
||||
function makeRoles(defaultStatus: string, overrides: Record<number, string> = {}): RoleEntry[] {
|
||||
return ROUNDTABLE_ROLE_TEMPLATES.map((t) => ({
|
||||
/** One entry per agent the user picked for this round of Step 2. */
|
||||
type SelectedAgent = { agent_id: string; name: string };
|
||||
|
||||
/**
|
||||
* Display metadata for the 6 built-in roundtable seats — kept so the
|
||||
* familiar emoji/color/display name survives when the user keeps the
|
||||
* defaults. Custom agents fall through to {@link FALLBACK_AVATAR_TYPES}.
|
||||
*/
|
||||
const BUILTIN_AGENT_META: Record<string, { avatarType: string; displayName: string }> = {
|
||||
"roundtable-intelligence": { avatarType: "purple", displayName: "情报收集" },
|
||||
"roundtable-environment": { avatarType: "emerald", displayName: "环境评估" },
|
||||
"roundtable-solution-design": { avatarType: "human", displayName: "方案设计" },
|
||||
"roundtable-risk-review": { avatarType: "amber", displayName: "风险审查" },
|
||||
"roundtable-execution-plan": { avatarType: "pink", displayName: "执行规划" },
|
||||
"roundtable-summary": { avatarType: "blue", displayName: "总结陈述" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Visual rotation for non-built-in agents. Each entry must be a key
|
||||
* recognized by {@link getAvatar} — anything else falls back to the plain
|
||||
* `RobotAvatar`.
|
||||
*/
|
||||
const FALLBACK_AVATAR_TYPES = ["blue", "purple", "emerald", "amber", "pink"] as const;
|
||||
|
||||
function getAvatarTypeFor(agentId: string, index: number): string {
|
||||
return (
|
||||
BUILTIN_AGENT_META[agentId]?.avatarType ??
|
||||
FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length]
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayNameFor(agent: SelectedAgent): string {
|
||||
return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the seat-template list shown in the right-rail role panel.
|
||||
* The commander always sits at id=1; agents take id=2..N+1, in the order
|
||||
* the user picked them so the panel mirrors the recommend dialog.
|
||||
*/
|
||||
function buildRoleTemplates(
|
||||
agents: SelectedAgent[],
|
||||
): ReadonlyArray<Omit<RoleEntry, "status">> {
|
||||
const out: Omit<RoleEntry, "status">[] = [
|
||||
{ id: COMMANDER_SEAT_ID, name: "指挥员 / 主持人", type: "human" },
|
||||
];
|
||||
agents.forEach((a, i) => {
|
||||
out.push({
|
||||
id: i + 2,
|
||||
name: getDisplayNameFor(a),
|
||||
agentId: a.agent_id,
|
||||
type: "agent",
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeRolesFromTemplates(
|
||||
templates: ReadonlyArray<Omit<RoleEntry, "status">>,
|
||||
defaultStatus: string,
|
||||
overrides: Record<number, string> = {},
|
||||
): RoleEntry[] {
|
||||
return templates.map((t) => ({
|
||||
...t,
|
||||
status: overrides[t.id] ?? defaultStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
// Subset of role templates that map to real backend agents (excludes the
|
||||
// human commander seat) — used as the `agent_names` payload on init.
|
||||
const ROUNDTABLE_AGENT_IDS = Array.from(
|
||||
new Set(
|
||||
ROUNDTABLE_ROLE_TEMPLATES.filter((r) => r.agentId).map((r) => r.agentId as string),
|
||||
),
|
||||
);
|
||||
/**
|
||||
* Map `agent_name` (lowercase) → bubble meta. Used while streaming output so
|
||||
* the right speaker bubble lights up and the dialogue card picks the right
|
||||
* avatar. Speaker ids stay in sync with {@link buildRoleTemplates} (agents
|
||||
* are id 2..N+1, indexed by selection order).
|
||||
*/
|
||||
function buildAgentNameToRole(
|
||||
agents: SelectedAgent[],
|
||||
): Record<string, { speakerId: number; avatarType: string; displayName: string }> {
|
||||
const out: Record<string, { speakerId: number; avatarType: string; displayName: string }> = {};
|
||||
agents.forEach((a, i) => {
|
||||
out[a.agent_id] = {
|
||||
speakerId: i + 2,
|
||||
avatarType: getAvatarTypeFor(a.agent_id, i),
|
||||
displayName: getDisplayNameFor(a),
|
||||
};
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// agent_name (lowercase) → bubble meta. Used when streaming output, so we can
|
||||
// light up the right speaker bubble and pick an avatar for the dialogue card.
|
||||
const AGENT_NAME_TO_ROLE: Record<string, { speakerId: number; avatarType: string; displayName: string }> = {
|
||||
"roundtable-summary": { speakerId: 2, avatarType: "blue", displayName: "总结陈述" },
|
||||
"roundtable-intelligence": { speakerId: 3, avatarType: "purple", displayName: "情报收集" },
|
||||
"roundtable-environment": { speakerId: 4, avatarType: "emerald", displayName: "环境评估" },
|
||||
"roundtable-solution-design": { speakerId: 5, avatarType: "human", displayName: "方案设计" },
|
||||
"roundtable-risk-review": { speakerId: 6, avatarType: "amber", displayName: "风险审查" },
|
||||
"roundtable-execution-plan": { speakerId: 7, avatarType: "pink", displayName: "执行规划" },
|
||||
};
|
||||
/**
|
||||
* Default Step-2 roster when the user hasn't picked anything yet (e.g. the
|
||||
* recommend dialog was skipped or hasn't run). Matches the original
|
||||
* hand-coded 6 roundtable seats so the page renders identically until the
|
||||
* recommend flow overrides it.
|
||||
*/
|
||||
const DEFAULT_SELECTED_AGENTS: SelectedAgent[] = [
|
||||
{ agent_id: "roundtable-summary", name: "总结陈述" },
|
||||
{ agent_id: "roundtable-intelligence", name: "情报收集" },
|
||||
{ agent_id: "roundtable-environment", name: "环境评估" },
|
||||
{ agent_id: "roundtable-solution-design", name: "方案设计" },
|
||||
{ agent_id: "roundtable-risk-review", name: "风险审查" },
|
||||
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
|
||||
];
|
||||
|
||||
function nowTime() {
|
||||
return new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
@@ -334,24 +443,33 @@ export default function RoundtablePlanningPage() {
|
||||
}
|
||||
]);
|
||||
|
||||
// Roles in roundtable state (updates as we switch steps!)
|
||||
const [roles, setRoles] = useState<RoleEntry[]>([
|
||||
{ id: 1, name: "指挥员 / 主持人", type: "human", status: "等待激活" },
|
||||
{ id: 2, name: "总结陈述", agentId: "roundtable-summary", type: "agent", status: "等待激活" },
|
||||
{ id: 3, name: "情报收集", agentId: "roundtable-intelligence", type: "agent", status: "等待激活" },
|
||||
{ id: 4, name: "环境评估", agentId: "roundtable-environment", type: "agent", status: "等待激活" },
|
||||
{ id: 5, name: "方案设计", agentId: "roundtable-solution-design", type: "agent", status: "等待激活" },
|
||||
{ id: 6, name: "风险审查", agentId: "roundtable-risk-review", type: "agent", status: "等待激活" },
|
||||
{ id: 7, name: "执行规划", agentId: "roundtable-execution-plan", type: "agent", status: "等待激活" },
|
||||
{ id: 8, name: "总结陈述", agentId: "roundtable-summary", type: "agent", status: "等待陈述" },
|
||||
]);
|
||||
// The user's chosen Step-2 participants. Defaults to the 6 built-in
|
||||
// roundtable seats so behavior is unchanged before the recommend dialog
|
||||
// overrides it. Step 6 will replace this via the dialog's onConfirm.
|
||||
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(DEFAULT_SELECTED_AGENTS);
|
||||
|
||||
// Derived: template list for the seat panel + dispatch-time name lookup +
|
||||
// the `agent_names` payload for /api/multi-agent/init. All three regenerate
|
||||
// together whenever the selection changes, so seat ids stay in sync.
|
||||
const roleTemplates = useMemo(() => buildRoleTemplates(selectedAgents), [selectedAgents]);
|
||||
const agentNameToRole = useMemo(() => buildAgentNameToRole(selectedAgents), [selectedAgents]);
|
||||
const roundtableAgentIds = useMemo(() => selectedAgents.map((a) => a.agent_id), [selectedAgents]);
|
||||
|
||||
// Roles in roundtable state (updates as we switch steps!). Seeded from
|
||||
// `roleTemplates`'s initial value (selectedAgents starts at the default
|
||||
// roster), so the panel renders identically to the previous hand-coded list.
|
||||
const [roles, setRoles] = useState<RoleEntry[]>(() =>
|
||||
makeRolesFromTemplates(buildRoleTemplates(DEFAULT_SELECTED_AGENTS), "等待激活"),
|
||||
);
|
||||
|
||||
// Dialog: recommend agents before entering Step 2.
|
||||
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
|
||||
|
||||
// Step 2 dialogue list — populated by the real orchestration loop once the
|
||||
// user enters the roundtable. Starts empty so we never flash mock content.
|
||||
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<any[]>([]);
|
||||
|
||||
// Inputs for Step 2 conversation simulation controls
|
||||
const [roundTableSpeed, setRoundTableSpeed] = useState<string>("正常");
|
||||
const [isRoundTableRunning, setIsRoundTableRunning] = useState<boolean>(true);
|
||||
const [customAgentInput, setCustomAgentInput] = useState<string>("");
|
||||
// Whether the roundtable visualizer + constraint console panel is collapsed.
|
||||
@@ -367,6 +485,10 @@ export default function RoundtablePlanningPage() {
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
const [intentInitError, setIntentInitError] = useState<string | null>(null);
|
||||
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
|
||||
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState<number | null>(null);
|
||||
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
|
||||
Map<number, IntentClarificationSelection>
|
||||
>(new Map());
|
||||
const step1InitRef = useRef<boolean>(false);
|
||||
// Monotonic counter incremented every time Step 1 init starts. The in-flight
|
||||
// async checks this on completion and bails if a newer init has begun (e.g.
|
||||
@@ -376,6 +498,7 @@ export default function RoundtablePlanningPage() {
|
||||
// early-returned because the ref was already set; net result: the async
|
||||
// landed but its setState got skipped, leaving intentThreadId null forever).
|
||||
const step1InitGenRef = useRef<number>(0);
|
||||
const intentAborterRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Multi-agent orchestration state (lives only for one step-2 visit).
|
||||
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
|
||||
@@ -427,6 +550,17 @@ export default function RoundtablePlanningPage() {
|
||||
activeAbortersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const stopIntentStream = useCallback(() => {
|
||||
if (intentAborterRef.current) {
|
||||
try {
|
||||
intentAborterRef.current.abort();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
intentAborterRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Toast auto-dismiss logic
|
||||
useEffect(() => {
|
||||
if (showToast) {
|
||||
@@ -435,6 +569,11 @@ export default function RoundtablePlanningPage() {
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === 1) return;
|
||||
stopIntentStream();
|
||||
}, [currentStep, stopIntentStream]);
|
||||
|
||||
// Auto-follow the streamed conversations: track scroll position, stop
|
||||
// following when the user scrolls up, expose a `回到底部` button that
|
||||
// re-engages auto-follow. Same hook for both steps.
|
||||
@@ -442,6 +581,22 @@ export default function RoundtablePlanningPage() {
|
||||
step1ScrollRef,
|
||||
[interactiveMessages],
|
||||
);
|
||||
|
||||
// Sync intentReady when [INTENT_READY] appears in chat but the stream frame
|
||||
// missed it (e.g. clarification returned before summary was parsed).
|
||||
useEffect(() => {
|
||||
if (intentReady) return;
|
||||
for (let i = interactiveMessages.length - 1; i >= 0; i--) {
|
||||
const msg = interactiveMessages[i];
|
||||
if (msg.sender === "user") continue;
|
||||
const { summary } = splitIntentReadyBlock(msg.content ?? "");
|
||||
if (summary) {
|
||||
setIntentReady(summary);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [interactiveMessages, intentReady]);
|
||||
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = useAutoScroll(
|
||||
step2ScrollRef,
|
||||
[step2RoundtableDialogues],
|
||||
@@ -737,9 +892,13 @@ export default function RoundtablePlanningPage() {
|
||||
// parallel meant N concurrent SSE streams each triggering a full-list
|
||||
// setState on every token delta, which made the browser stutter badly
|
||||
// when the leader 派活给多个席位。改为顺序后,同一时刻只有一个 SSE 在跑。
|
||||
//
|
||||
// ⚠️ 不要改回 Promise.all 并行 —— 详见 multi-agent-frontend-dev.md §5.4
|
||||
// 与 §12.4。如果未来要并行,先把 step2RoundtableDialogues 换成 Map<id, Bubble>
|
||||
// 或基于 ref 的局部更新,再放开 N 路 SSE。
|
||||
for (const [subName, task] of leaderRes.dispatched) {
|
||||
if (orchestrationCancelRef.current) break;
|
||||
const meta = AGENT_NAME_TO_ROLE[subName.toLowerCase()];
|
||||
const meta = agentNameToRole[subName.toLowerCase()];
|
||||
if (meta) markSpeakerStreaming(meta.speakerId);
|
||||
const perSeatModel = meta ? agentConfigs[meta.speakerId]?.model : undefined;
|
||||
|
||||
@@ -806,7 +965,23 @@ export default function RoundtablePlanningPage() {
|
||||
clearAllStreamingSpeakers();
|
||||
setIsOrchestrating(false);
|
||||
}
|
||||
}, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs]);
|
||||
}, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs, agentNameToRole]);
|
||||
|
||||
// Called by RecommendAgentsDialog onConfirm: update the participant roster,
|
||||
// reset step-2 init so the new coordinator is created for the new roster,
|
||||
// then navigate to Step 2.
|
||||
const handleConfirmAgents = useCallback((agentIds: string[]) => {
|
||||
setSelectedAgents(
|
||||
agentIds.map((id) => ({
|
||||
agent_id: id,
|
||||
name: BUILTIN_AGENT_META[id]?.displayName ?? id,
|
||||
})),
|
||||
);
|
||||
step2InitRef.current = false;
|
||||
setIsRecommendDialogOpen(false);
|
||||
setCurrentStep(2);
|
||||
setShowToast("成功确认!已切换至第二阶段圆桌研讨系统");
|
||||
}, []);
|
||||
|
||||
// Public stop handler used by the pause/stop button and the step-2 teardown.
|
||||
// Sets the cancel flag and aborts every in-flight stream so the loop can
|
||||
@@ -849,7 +1024,7 @@ export default function RoundtablePlanningPage() {
|
||||
const coord = newCoordinatorName();
|
||||
try {
|
||||
const initRes = await initMultiAgent({
|
||||
agentNames: ROUNDTABLE_AGENT_IDS,
|
||||
agentNames: roundtableAgentIds,
|
||||
mainAgentName: coord,
|
||||
model: DEFAULT_MODEL,
|
||||
// As each sub-agent's thread comes online, flip every role that
|
||||
@@ -918,7 +1093,11 @@ export default function RoundtablePlanningPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isRoundTableRunning]);
|
||||
|
||||
// Adjust budget constraint dynamically
|
||||
// Budget slider — UI demo only.
|
||||
// 这是一段**未对接后端**的演示逻辑:滑动预算条只会本地 setBudgetLimit 并 1.5 秒后追加一条
|
||||
// 模拟智能体口吻的气泡(根据档位选话术)。真实研讨里预算属于约束条件,应由 Step 1 的
|
||||
// intent.summary.constraints 承载、由总控派活时透传给 sub-agent,而不是这条单滑块。
|
||||
// 重写此功能前请同步考虑:1) 是否真的需要可调约束;2) 若需要,改成走 runOrchestration 注入新 prompt。
|
||||
const handleBudgetChange = (newVal: number) => {
|
||||
setBudgetLimit(newVal);
|
||||
triggerToast(`💰 研商经费调优约束变更为 ${newVal} 万!各大智能体启动资源重算...`);
|
||||
@@ -961,6 +1140,10 @@ export default function RoundtablePlanningPage() {
|
||||
// Save per-seat agent config overrides. We persist locally only — the next
|
||||
// time the orchestration loop dispatches to this seat it will read the
|
||||
// chosen model out of `agentConfigs` and pass it on the streaming call.
|
||||
//
|
||||
// 目前 prompt / temp 两个字段**仅前端保留**,后端尚未开放 per-agent override 接口。
|
||||
// 当后端开放时,应在 streamMultiAgent 的 special 调用上同时传 prompt_override / temperature,
|
||||
// 而不是改这里的本地存储结构(后者会被 useState 重置)。
|
||||
const handleSaveAgentAndRecalculate = (id: number) => {
|
||||
const role = roles.find(r => r.id === id);
|
||||
const roleName = role?.name || "专家智能体";
|
||||
@@ -1009,6 +1192,8 @@ export default function RoundtablePlanningPage() {
|
||||
setIntentReady(null);
|
||||
setIntentInitError(null);
|
||||
setIntentThreadId(null);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setTimelineEntries([]);
|
||||
setInteractiveMessages([
|
||||
{
|
||||
@@ -1049,6 +1234,7 @@ export default function RoundtablePlanningPage() {
|
||||
// Stop anything in flight before swapping state out.
|
||||
orchestrationCancelRef.current = true;
|
||||
abortAllInFlight();
|
||||
stopIntentStream();
|
||||
setIsOrchestrating(false);
|
||||
setIsInitializing(false);
|
||||
setPendingClarification(null);
|
||||
@@ -1078,7 +1264,7 @@ export default function RoundtablePlanningPage() {
|
||||
// doesn't change and the effect wouldn't re-fire.
|
||||
runStep1Init();
|
||||
triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求");
|
||||
}, [abortAllInFlight, runStep1Init]);
|
||||
}, [abortAllInFlight, runStep1Init, stopIntentStream]);
|
||||
|
||||
// ── Draft helpers ──────────────────────────────────────────────────────
|
||||
// Build a fresh snapshot of the current in-memory session. Called by every
|
||||
@@ -1184,6 +1370,7 @@ export default function RoundtablePlanningPage() {
|
||||
// Cancel anything in-flight before swapping state out from under it.
|
||||
orchestrationCancelRef.current = true;
|
||||
abortAllInFlight();
|
||||
stopIntentStream();
|
||||
setIsOrchestrating(false);
|
||||
setIsInitializing(false);
|
||||
setPendingClarification(null);
|
||||
@@ -1224,7 +1411,7 @@ export default function RoundtablePlanningPage() {
|
||||
setCurrentStep(draft.furthestStep);
|
||||
triggerToast(`📂 已加载草稿:${draft.name}`);
|
||||
},
|
||||
[drafts, abortAllInFlight],
|
||||
[drafts, abortAllInFlight, stopIntentStream],
|
||||
);
|
||||
|
||||
const handleDeleteDraft = useCallback(
|
||||
@@ -1277,10 +1464,11 @@ export default function RoundtablePlanningPage() {
|
||||
// Chat sender logic for Step 1 interaction — drives the real intent agent
|
||||
// via /api/intent/stream. The agent streams its reply into a placeholder
|
||||
// bubble; if its final status is "done" we capture the structured summary
|
||||
// for the Step 2 handoff.
|
||||
const handleSendMessage = async () => {
|
||||
const userText = inputText.trim();
|
||||
if (!userText) return;
|
||||
// for the Step 2 handoff. When status is "clarification", render option
|
||||
// cards like the main chat ClarificationCard.
|
||||
const sendIntentMessage = async (userText: string) => {
|
||||
const trimmed = userText.trim();
|
||||
if (!trimmed) return;
|
||||
if (!intentThreadId) {
|
||||
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
|
||||
return;
|
||||
@@ -1290,33 +1478,31 @@ export default function RoundtablePlanningPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
|
||||
const userMsgTime = nowTime();
|
||||
const userMsgId = Date.now() + Math.random();
|
||||
const userMsg = {
|
||||
id: userMsgId,
|
||||
sender: "user",
|
||||
senderName: "用户",
|
||||
content: userText,
|
||||
content: trimmed,
|
||||
time: userMsgTime,
|
||||
};
|
||||
setInteractiveMessages(prev => [...prev, userMsg]);
|
||||
setInputText("");
|
||||
setCharactersLeft(0);
|
||||
|
||||
// Right-side timeline gets a short summary too.
|
||||
setTimelineEntries(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: prev.length + 1,
|
||||
time: userMsgTime,
|
||||
title: "用户实时反馈",
|
||||
desc: userText.length > 25 ? userText.substring(0, 25) + "..." : userText,
|
||||
desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed,
|
||||
},
|
||||
]);
|
||||
|
||||
// Placeholder agent bubble — text deltas accumulate into it. `phase`
|
||||
// tracks the inferred SSE stage so the "in flight" caption can flip from
|
||||
// "调度中" → "已连接,等待响应" → "正在思考问题" as the run progresses.
|
||||
const agentMsgId = Date.now() + Math.random() + 0.01;
|
||||
setInteractiveMessages(prev => [
|
||||
...prev,
|
||||
@@ -1331,9 +1517,6 @@ export default function RoundtablePlanningPage() {
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Map an inferred phase + (optional) tool name to a human-readable label
|
||||
// shown next to the spinner. Tool-call phases highlight ask_clarification
|
||||
// explicitly since that's the only tool the intent agent normally uses.
|
||||
const intentPhaseLabel = (phase: IntentStreamPhase, toolName?: string): string => {
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
@@ -1351,11 +1534,14 @@ export default function RoundtablePlanningPage() {
|
||||
};
|
||||
|
||||
setIsIntentSending(true);
|
||||
const aborter = new AbortController();
|
||||
intentAborterRef.current = aborter;
|
||||
try {
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
message: userText,
|
||||
message: trimmed,
|
||||
model: DEFAULT_MODEL,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
setInteractiveMessages(prev =>
|
||||
prev.map(m => (m.id === agentMsgId ? { ...m, content: (m.content ?? "") + delta } : m)),
|
||||
@@ -1368,47 +1554,105 @@ export default function RoundtablePlanningPage() {
|
||||
);
|
||||
},
|
||||
});
|
||||
// Finalize the bubble with the authoritative final text from the
|
||||
// status frame (covers cases where deltas didn't accumulate cleanly).
|
||||
const parsedFromContent = splitIntentReadyBlock(res.content || "").summary;
|
||||
const resolvedSummary = res.summary ?? parsedFromContent;
|
||||
|
||||
setInteractiveMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === agentMsgId
|
||||
? { ...m, content: res.content || m.content || "(无文本输出)", streaming: false } as any
|
||||
? {
|
||||
...m,
|
||||
content: res.content || m.content || "(无文本输出)",
|
||||
streaming: false,
|
||||
clarification: resolvedSummary ? undefined : res.clarification ?? undefined,
|
||||
} as any
|
||||
: m,
|
||||
),
|
||||
);
|
||||
if (res.status === "done" && res.summary) {
|
||||
setIntentReady(res.summary);
|
||||
|
||||
if (resolvedSummary) {
|
||||
setIntentReady(resolvedSummary);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setTimelineEntries(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: prev.length + 1,
|
||||
time: nowTime(),
|
||||
title: "任务意图已明确",
|
||||
desc: res.summary!.objective.length > 25
|
||||
? res.summary!.objective.substring(0, 25) + "..."
|
||||
: res.summary!.objective,
|
||||
desc: resolvedSummary.objective.length > 25
|
||||
? resolvedSummary.objective.substring(0, 25) + "..."
|
||||
: resolvedSummary.objective,
|
||||
},
|
||||
]);
|
||||
triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨");
|
||||
} else if (res.status === "clarification" && res.clarification) {
|
||||
setPendingIntentClarificationMsgId(agentMsgId);
|
||||
setIntentClarificationSelections(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentMsgId, { selectedIds: [], custom: "" });
|
||||
return next;
|
||||
});
|
||||
} else if (res.status === "error") {
|
||||
triggerToast(`❌ 智能体调用失败: ${res.content}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("intent stream failed:", err);
|
||||
setInteractiveMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === agentMsgId
|
||||
? { ...m, content: `(错误) ${(err as Error).message}`, streaming: false } as any
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast(`❌ 发送失败: ${(err as Error).message}`);
|
||||
if ((err as Error).name === "AbortError" || aborter.signal.aborted) {
|
||||
setInteractiveMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === agentMsgId
|
||||
? {
|
||||
...m,
|
||||
content: (m.content ?? "").trim()
|
||||
? `${m.content ?? ""}\n\n(已暂停)`
|
||||
: "(已暂停)",
|
||||
streaming: false,
|
||||
} as any
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast("⏸️ 已暂停任务理解回复");
|
||||
} else {
|
||||
console.error("intent stream failed:", err);
|
||||
setInteractiveMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === agentMsgId
|
||||
? { ...m, content: `(错误) ${(err as Error).message}`, streaming: false } as any
|
||||
: m,
|
||||
),
|
||||
);
|
||||
triggerToast(`❌ 发送失败: ${(err as Error).message}`);
|
||||
}
|
||||
} finally {
|
||||
if (intentAborterRef.current === aborter) {
|
||||
intentAborterRef.current = null;
|
||||
}
|
||||
setIsIntentSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = () => {
|
||||
void sendIntentMessage(inputText);
|
||||
};
|
||||
|
||||
const submitIntentClarification = (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
) => {
|
||||
const selection =
|
||||
overrideSelection ??
|
||||
intentClarificationSelections.get(msgId) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const answer = formatIntentClarificationAnswer(payload, selection);
|
||||
if (!answer) {
|
||||
triggerToast("请先选择选项或输入回答");
|
||||
return;
|
||||
}
|
||||
void sendIntentMessage(answer);
|
||||
};
|
||||
|
||||
// Skip step logic
|
||||
const handleSkipQuestion = () => {
|
||||
const systemSkipMsg = {
|
||||
@@ -1473,11 +1717,11 @@ export default function RoundtablePlanningPage() {
|
||||
// seat to 在线 — assume all seats are online so the panel doesn't get
|
||||
// stuck at "正在上线".
|
||||
const baseStatus = loadedFromDraftRef.current ? "在线" : "正在上线";
|
||||
setRoles(makeRoles(baseStatus, { 1: "在线" }));
|
||||
setRoles(makeRolesFromTemplates(roleTemplates, baseStatus, { 1: "在线" }));
|
||||
} else {
|
||||
setRoles(makeRoles("已完成"));
|
||||
setRoles(makeRolesFromTemplates(roleTemplates, "已完成"));
|
||||
}
|
||||
}, [currentStep, intentThreadId, intentInitError]);
|
||||
}, [currentStep, intentThreadId, intentInitError, roleTemplates]);
|
||||
|
||||
// Inject user input to the roundtable room — sends it as a new leader call.
|
||||
const handleInjectToRoundtable = () => {
|
||||
@@ -1544,6 +1788,14 @@ export default function RoundtablePlanningPage() {
|
||||
>
|
||||
|
||||
{/* Toast Notice Banner */}
|
||||
<RecommendAgentsDialog
|
||||
open={isRecommendDialogOpen}
|
||||
intent={intentReady}
|
||||
model={DEFAULT_MODEL}
|
||||
onCancel={() => setIsRecommendDialogOpen(false)}
|
||||
onConfirm={handleConfirmAgents}
|
||||
/>
|
||||
|
||||
{showToast && (
|
||||
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
|
||||
<Sparkles className="h-4 w-4 shrink-0" />
|
||||
@@ -2009,16 +2261,10 @@ export default function RoundtablePlanningPage() {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Avatar chooser based on role.id */}
|
||||
{role.type === "human" ? (
|
||||
<Man1Avatar size="h-7 w-7" />
|
||||
) : (
|
||||
role.id === 2 ? <RobotAvatar headColor="#f5222d" size="h-7 w-7" /> :
|
||||
role.id === 3 ? <PurpleRobotAvatar size="h-7 w-7" /> :
|
||||
role.id === 4 ? <EmeraldRobotAvatar size="h-7 w-7" /> :
|
||||
role.id === 6 ? <AmberRobotAvatar size="h-7 w-7" /> :
|
||||
role.id === 7 ? <PinkRobotAvatar size="h-7 w-7" /> :
|
||||
<BlueRobotAvatar size="h-7 w-7" />
|
||||
getAvatar(role.agentId ? (agentNameToRole[role.agentId]?.avatarType ?? "blue") : "blue", "h-7 w-7")
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
@@ -2146,10 +2392,7 @@ export default function RoundtablePlanningPage() {
|
||||
</div>
|
||||
<button
|
||||
id="confirm-roundtable-btn"
|
||||
onClick={() => {
|
||||
setCurrentStep(2);
|
||||
triggerToast("成功确认!已切换至第二阶段圆桌研讨系统");
|
||||
}}
|
||||
onClick={() => setIsRecommendDialogOpen(true)}
|
||||
className="bg-primary hover:bg-[var(--brand-900)] text-white font-bold text-xs px-4 py-2 rounded-xl transition shadow-[0_2px_8px_rgba(245,34,45,0.2)] active:scale-95 shrink-0"
|
||||
>
|
||||
用户确认,进入多智能体研讨
|
||||
@@ -2229,12 +2472,26 @@ export default function RoundtablePlanningPage() {
|
||||
const isStreaming = (msg as { streaming?: boolean }).streaming === true;
|
||||
const hasContent = (msg.content ?? "").length > 0;
|
||||
const isAgent = msg.sender !== "user";
|
||||
// Only agent replies carry markdown / [INTENT_READY]; user
|
||||
// messages stay as plain text so casing/whitespace are
|
||||
// preserved verbatim.
|
||||
const clarification = (msg as { clarification?: ClarificationPayload }).clarification;
|
||||
const parts = isAgent
|
||||
? splitIntentReadyBlock(msg.content ?? "")
|
||||
: { before: msg.content ?? "", summary: null, after: "" };
|
||||
const isPendingClarification =
|
||||
isAgent &&
|
||||
clarification &&
|
||||
!intentReady &&
|
||||
!parts.summary &&
|
||||
pendingIntentClarificationMsgId === msg.id;
|
||||
const clarificationSelection =
|
||||
intentClarificationSelections.get(msg.id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const introText =
|
||||
clarification &&
|
||||
parts.before.trim() === (clarification.question || "").trim()
|
||||
? ""
|
||||
: parts.before;
|
||||
return (
|
||||
<div key={msg.id} className="flex items-start justify-between gap-3 border-b border-slate-100/50 pb-2.5 last:border-0 last:pb-0">
|
||||
<div className="flex items-start gap-2.5 min-w-0">
|
||||
@@ -2257,7 +2514,7 @@ export default function RoundtablePlanningPage() {
|
||||
</span>
|
||||
) : isAgent ? (
|
||||
<div className="text-xs text-slate-700 leading-normal font-normal break-words space-y-2">
|
||||
{parts.before && (
|
||||
{introText && (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
@@ -2293,7 +2550,7 @@ export default function RoundtablePlanningPage() {
|
||||
),
|
||||
}}
|
||||
>
|
||||
{parts.before}
|
||||
{introText}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
{parts.summary && (
|
||||
@@ -2338,6 +2595,25 @@ export default function RoundtablePlanningPage() {
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1 h-3 ml-0.5 bg-primary align-middle animate-pulse" />
|
||||
)}
|
||||
{isPendingClarification && clarification && (
|
||||
<IntentClarificationCard
|
||||
payload={clarification}
|
||||
fallbackContent={parts.before || undefined}
|
||||
selection={clarificationSelection}
|
||||
onSelectionChange={(next) => {
|
||||
setIntentClarificationSelections(prev => {
|
||||
const map = new Map(prev);
|
||||
map.set(msg.id, next);
|
||||
return map;
|
||||
});
|
||||
}}
|
||||
disabled={!isPendingClarification}
|
||||
submitting={isIntentSending && isPendingClarification}
|
||||
onSubmit={(override) =>
|
||||
submitIntentClarification(msg.id, clarification, override)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-700 leading-normal font-normal whitespace-pre-wrap break-words">
|
||||
@@ -2373,7 +2649,11 @@ export default function RoundtablePlanningPage() {
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
placeholder="请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
|
||||
placeholder={
|
||||
pendingIntentClarificationMsgId
|
||||
? "也可在此输入自定义回答 (Enter 快速发送)"
|
||||
: "请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
|
||||
}
|
||||
className="w-full text-xs text-slate-700 placeholder:text-slate-400 resize-none h-[48px] focus:outline-none leading-relaxed font-normal"
|
||||
maxLength={500}
|
||||
/>
|
||||
@@ -2410,6 +2690,18 @@ export default function RoundtablePlanningPage() {
|
||||
{isIntentSending ? "发送中" : "发送"}
|
||||
</button>
|
||||
|
||||
{isIntentSending && (
|
||||
<button
|
||||
id="pause-intent-btn"
|
||||
type="button"
|
||||
onClick={() => stopIntentStream()}
|
||||
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-xs font-bold px-3 py-2.5 rounded-xl flex items-center gap-1.5 transition border border-amber-200/40 shadow-sm active:scale-95"
|
||||
>
|
||||
<Pause className="w-3 h-3" />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="suggest-followup-btn"
|
||||
onClick={handleAgentFollowUp}
|
||||
@@ -2435,7 +2727,7 @@ export default function RoundtablePlanningPage() {
|
||||
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
|
||||
return;
|
||||
}
|
||||
setCurrentStep(2);
|
||||
setIsRecommendDialogOpen(true);
|
||||
}}
|
||||
disabled={!intentReady}
|
||||
title={intentReady ? undefined : "请先完成任务意图澄清"}
|
||||
@@ -2483,23 +2775,6 @@ export default function RoundtablePlanningPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Interactive speed controls */}
|
||||
<div className="flex items-center bg-slate-50 border border-slate-200 rounded-xl p-0.5 text-[10px] shrink-0 font-bold font-sans shadow-inner">
|
||||
<span className="px-2 text-slate-400 font-semibold selection:bg-transparent">研商步长</span>
|
||||
<button
|
||||
onClick={() => { setRoundTableSpeed("慢速"); triggerToast("🐌 已切换为 0.5x 慢速拟合监听模式"); }}
|
||||
className={`px-1.5 py-0.5 rounded transition ${roundTableSpeed === "慢速" ? "bg-white text-primary font-extrabold shadow-sm" : "text-slate-500 hover:text-slate-800"}`}
|
||||
>慢速</button>
|
||||
<button
|
||||
onClick={() => { setRoundTableSpeed("正常"); triggerToast("⏱️ 已恢复为 1.0x 标准协商状态值"); }}
|
||||
className={`px-1.5 py-0.5 rounded transition ${roundTableSpeed === "正常" ? "bg-white text-primary font-extrabold shadow-sm" : "text-slate-500 hover:text-slate-800"}`}
|
||||
>正常</button>
|
||||
<button
|
||||
onClick={() => { setRoundTableSpeed("极速"); triggerToast("🚀 已并行为 2.5x 极速算力重算模式"); }}
|
||||
className={`px-1.5 py-0.5 rounded transition ${roundTableSpeed === "极速" ? "bg-white text-primary font-extrabold shadow-sm" : "text-slate-500 hover:text-slate-800"}`}
|
||||
>极速</button>
|
||||
</div>
|
||||
|
||||
{/* Suspend / resume button visibility:
|
||||
- Show "挂起协商" only while a run is actually in flight
|
||||
(isOrchestrating + still nominally running). When the
|
||||
@@ -2744,7 +3019,14 @@ export default function RoundtablePlanningPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Agent Persona Config Drawer / Drawer dialog box */}
|
||||
{/* Selected Agent Persona Config Drawer / Drawer dialog box
|
||||
────────────────────────────────────────────────────────
|
||||
点击圆桌可视化里的某个智能体头像后展开,允许覆写该席位的:
|
||||
- System Prompt (本地记录,后端覆写未开放)
|
||||
- 推理模型 (下一轮派活时 runOrchestration 会读 agentConfigs[id].model)
|
||||
- Temperature (本地记录,后端覆写未开放)
|
||||
保存按钮 = handleSaveAgentAndRecalculate,不会立即重启编排,只更新
|
||||
agentConfigs;真正生效在下一次该席位被 leader 派活时。 */}
|
||||
{selectedAgentId && (
|
||||
<div className="mx-6 mb-4 p-4.5 bg-gradient-to-r from-[var(--brand-200)]/50 via-[var(--brand-200)]/15 to-white border-2 border-primary rounded-2xl shadow-[0_8px_30px_rgba(245,34,45,0.06)] relative animate-fade-in">
|
||||
<button
|
||||
@@ -3123,7 +3405,7 @@ export default function RoundtablePlanningPage() {
|
||||
content: String(d.content ?? ""),
|
||||
}))}
|
||||
seatNameMap={Object.fromEntries(
|
||||
Object.entries(AGENT_NAME_TO_ROLE).map(([k, v]) => [k, v.displayName]),
|
||||
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
|
||||
)}
|
||||
onReset={handleStartNewTask}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user