This commit is contained in:
董宏凯 committed 2026-06-10 21:57:12 +08:00
commit 0784f30e23
9 files changed
+411 -49

No files matched your search

@@ -142,10 +142,24 @@ export interface IntentSummary {
*/
export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling";
/**
* 用户上传文件的引用(已经过 `/api/threads/{thread_id}/uploads`)。透传给后端
* `additional_kwargs.files`,由 UploadsMiddleware 注入文件清单并解锁 read_file 等工具。
* 字段与普通聊天的 `FileInMessage` 对齐:`path` 用上传返回的 `virtual_path`。
*/
export interface IntentFileRef {
filename: string;
size: number;
path: string;
status?: "uploading" | "uploaded";
}
export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
/** Step 1 用户上传的参考文件(可选)。 */
files?: IntentFileRef[];
signal?: AbortSignal;
/** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */
onTextDelta?: (text: string, messageId: string) => void;
@@ -318,6 +332,8 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
thread_id: req.threadId,
message: req.message,
model: req.model,
// 仅在有文件时带上,空数组后端也安全忽略。
files: req.files && req.files.length > 0 ? req.files : undefined,
}),
signal: req.signal,
});
@@ -227,6 +227,12 @@ export interface StreamMultiAgentRequest {
* (平时只看广播摘要)。不进 prompt → 不调工具就不耗 token。
*/
peerDeliveries?: Array<{ name: string; content: string }>;
/**
* 用户随干预消息上传的参考文件(已经过 `/api/threads/{thread_id}/uploads`)。
* 透传给后端 `additional_kwargs.files`,由 UploadsMiddleware 注入清单 + 解锁
* read_file 等工具。字段 `path` 用上传返回的 `virtual_path`。
*/
files?: Array<{ filename: string; size: number; path: string; status?: string }>;
signal?: AbortSignal;
/** Fires for every non-status data frame the upstream emits. */
onUpstreamFrame?: (frame: unknown) => void;
@@ -447,6 +453,8 @@ export async function streamMultiAgent(
parallel_no_file: req.parallelNoFile ?? false,
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
peer_deliveries: req.peerDeliveries ?? [],
// 用户随干预上传的参考文件;仅在有文件时带上,后端注入 additional_kwargs.files。
files: req.files && req.files.length > 0 ? req.files : undefined,
}),
signal: req.signal,
});
@@ -14,8 +14,10 @@
import { useRef, useState } from "react";
import {
CheckCircle,
FileText,
Loader2,
MessageSquare,
Paperclip,
Pause,
Repeat,
Save,
@@ -25,6 +27,13 @@ import {
X,
} from "lucide-react";
/** 人类可读的文件大小(B / KB / MB)。 */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
import { Button } from "@/components/ui/button";
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import {
@@ -62,7 +71,7 @@ export interface Step1PanelProps {
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<string, IntentClarificationSelection>>
>;
onSendMessage: (text: string) => void;
onSendMessage: (text: string, files?: File[]) => void;
onStopIntent: () => void;
onSkipQuestion: () => void;
onFollowUp: () => void;
@@ -101,13 +110,34 @@ export function Step1Panel(props: Step1PanelProps) {
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
const [inputText, setInputText] = useState<string>("");
// 待发送的本地附件(点发送时连同文本一起上传)。
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const charactersLeft = inputText.length;
const handlePickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length > 0) {
// 去重(按 名称+大小),追加到已选列表。
setAttachedFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
// 清空 input 值,允许再次选同名文件。
e.target.value = "";
};
const removeAttachedFile = (idx: number) => {
setAttachedFiles((prev) => prev.filter((_, i) => i !== idx));
};
const handleSend = () => {
if (!inputText.trim()) return;
onSendMessage(inputText);
if (!inputText.trim() && attachedFiles.length === 0) return;
onSendMessage(inputText, attachedFiles.length > 0 ? attachedFiles : undefined);
setInputText("");
setAttachedFiles([]);
};
return (
@@ -150,6 +180,33 @@ export function Step1Panel(props: Step1PanelProps) {
{/* Bottom input area */}
<div className="shrink-0 p-4 border-t border-slate-100 bg-[#fcfcfd]">
<div className="relative border border-slate-200 focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/50 transition-all rounded-xl overflow-hidden bg-white px-3 py-2.5">
{/* 已选附件预览(点发送时随消息上传) */}
{attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5 pb-2 mb-2 border-b border-slate-100">
{attachedFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-slate-50 border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileText className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">
{formatFileSize(file.size)}
</span>
<button
type="button"
onClick={() => removeAttachedFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<textarea
id="input-text-area"
value={inputText}
@@ -177,6 +234,21 @@ export function Step1Panel(props: Step1PanelProps) {
>
<Smile className="w-4 h-4" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickFiles}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(智能体将读取文件内容辅助理解任务)"
className="p-1 hover:text-primary hover:bg-slate-50 rounded transition"
>
<Paperclip className="w-4 h-4" />
</button>
</div>
<span className="text-[10px] font-mono text-slate-400">{charactersLeft}/500</span>
@@ -422,8 +494,30 @@ function Step1Message({
)}
</div>
) : (
<div className="roundtable-chat-message text-slate-700 font-medium whitespace-pre-wrap break-words">
{msg.content}
<div className="space-y-2">
{msg.files && msg.files.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{msg.files.map((file, i) => (
<span
key={`${file.filename}-${i}`}
className="inline-flex items-center gap-1.5 bg-white/70 border border-slate-200 rounded-lg px-2 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileText className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.filename}>
{file.filename}
</span>
<span className="shrink-0 text-slate-400">
{formatFileSize(file.size)}
</span>
</span>
))}
</div>
)}
{msg.content && (
<div className="roundtable-chat-message text-slate-700 font-medium whitespace-pre-wrap break-words">
{msg.content}
</div>
)}
</div>
)}
</MessageBubble>
@@ -23,11 +23,20 @@ import {
HelpCircle,
Loader2,
Network,
Paperclip,
Pause,
Play,
Sparkles,
X,
} from "lucide-react";
/** 人类可读的文件大小(B / KB / MB)。 */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
import {
Dialog,
DialogContent,
@@ -124,7 +133,7 @@ export interface Step2PanelProps {
// ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void;
onResumeRoundtable: () => void;
onHandleInjectToRoundtable: () => void;
onHandleInjectToRoundtable: (files?: File[]) => void;
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
@@ -251,11 +260,34 @@ export function Step2Panel(props: Step2PanelProps) {
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
const [injectFiles, setInjectFiles] = useState<File[]>([]);
const injectFileInputRef = useRef<HTMLInputElement>(null);
const handlePickInjectFiles = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length > 0) {
setInjectFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
e.target.value = "";
},
[],
);
const removeInjectFile = useCallback((idx: number) => {
setInjectFiles((prev) => prev.filter((_, i) => i !== idx));
}, []);
const handleInject = useCallback(() => {
onHandleInjectToRoundtable();
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
setInjectFiles([]);
setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600);
}, [onHandleInjectToRoundtable]);
}, [onHandleInjectToRoundtable, injectFiles]);
const isSandboxStreaming =
isOrchestrating ||
@@ -530,7 +562,35 @@ export function Step2Panel(props: Step2PanelProps) {
)}
{/* Console + intervention bar */}
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex items-center gap-3">
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
{/* 已选附件预览(点「干预并发送」时随指令上传) */}
{injectFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{injectFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileIcon className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">
{formatFileSize(file.size)}
</span>
<button
type="button"
onClick={() => removeInjectFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-center gap-3">
<input
type="text"
value={customAgentInput}
@@ -542,6 +602,22 @@ export function Step2Panel(props: Step2PanelProps) {
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
/>
<input
ref={injectFileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickInjectFiles}
/>
<button
type="button"
onClick={() => injectFileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
>
<Paperclip className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleInject}
@@ -581,6 +657,7 @@ export function Step2Panel(props: Step2PanelProps) {
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
</>
);
@@ -19,9 +19,11 @@ import {
initIntent,
streamIntent,
type IntentErrorDetail,
type IntentFileRef,
type IntentStreamPhase,
type IntentSummary,
} from "@/roundtable-planning/api/intent";
import { uploadFiles } from "@/core/uploads/api";
import {
clarificationKey,
formatIntentClarificationAnswer,
@@ -42,6 +44,8 @@ export type InteractiveMessage = {
time: string;
streaming?: boolean;
phase?: string;
/** 用户气泡里附带的上传文件(仅展示用:文件名 + 大小)。 */
files?: Array<{ filename: string; size: number }>;
/** First clarification (back-compat); prefer `clarifications`. */
clarification?: ClarificationPayload;
/**
@@ -109,8 +113,12 @@ export interface UseStep1IntentReturn {
// ─── Handlers ────────────────────────────────────────────────────────
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
runStep1Init: () => void;
/** 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。 */
sendIntentMessage: (userText: string) => Promise<void>;
/**
* 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。
* `files`:可选的本地文件,发送前先上传到 intent thread,再把引用随消息提交(后端
* 注入 additional_kwargs.files,意图智能体即可 read_file 读取)。允许「仅文件无文本」。
*/
sendIntentMessage: (userText: string, files?: File[]) => Promise<void>;
/**
* 提交某条 agent 消息的全部澄清问题答案 —— 逐题取各自选择,拼成一条合并消息
* 发回。至少答一题才会发送。单题时退化为发送该题答案。
@@ -275,9 +283,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
}, [interactiveMessages, intentReady]);
const sendIntentMessage = useCallback(
async (userText: string) => {
async (userText: string, files?: File[]) => {
const trimmed = userText.trim();
if (!trimmed) return;
const hasFiles = !!(files && files.length > 0);
if (!trimmed && !hasFiles) return;
if (!intentThreadId) {
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
return;
@@ -304,16 +313,24 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
senderName: "用户",
content: trimmed,
time: userMsgTime,
files: hasFiles
? files!.map((f) => ({ filename: f.name, size: f.size }))
: undefined,
};
setInteractiveMessages((prev) => [...prev, userMsg]);
const timelineDesc = trimmed
? trimmed.length > 25
? trimmed.substring(0, 25) + "..."
: trimmed
: `上传了 ${files!.length} 个文件`;
setTimelineEntries((prev) => [
...prev,
{
id: prev.length + 1,
time: userMsgTime,
title: "用户实时反馈",
desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed,
desc: timelineDesc,
},
]);
@@ -333,10 +350,49 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
setIsIntentSending(true);
try {
// 先把本地文件上传到 intent thread —— 与普通聊天同一上传接口/技能链路。
// 拿到 virtual_path 后随消息提交,后端注入 additional_kwargs.files。
let fileRefs: IntentFileRef[] | undefined;
if (hasFiles) {
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId ? { ...m, phase: "正在上传文件" } : m,
),
);
try {
const up = await uploadFiles(intentThreadId, files!);
fileRefs = up.files.map((f) => ({
filename: f.filename,
size: f.size,
path: f.virtual_path,
status: "uploaded" as const,
}));
} catch (err) {
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId
? {
...m,
content: `(文件上传失败) ${(err as Error).message}`,
streaming: false,
}
: m,
),
);
triggerToast(`❌ 文件上传失败: ${(err as Error).message}`);
if (intentAborterRef.current === aborter) {
intentAborterRef.current = null;
}
setIsIntentSending(false);
return;
}
}
const res = await streamIntent({
threadId: intentThreadId,
message: trimmed,
model: selectedModelRef.current || undefined,
files: fileRefs,
signal: aborter.signal,
onTextDelta: (delta) => {
setInteractiveMessages((prev) =>
@@ -23,8 +23,20 @@ import {
type ClarificationPayload,
type LeaderDispatch,
type StreamPhase,
type StreamMultiAgentRequest,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { uploadFiles } from "@/core/uploads/api";
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
/** 读取并清空 ref(consume-once):首个 leader run 拿到文件后清空,后续轮次不再带。 */
function takeOnce<T>(ref: { current: T | null }): T | undefined {
const v = ref.current ?? undefined;
ref.current = null;
return v;
}
import {
buildDagStageLeaderTask,
pickDagSeatTask,
@@ -305,8 +317,11 @@ export interface UseStep2OrchestrationReturn {
recordInjectMessage: () => void;
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
abortAllInFlight: () => void;
/** 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。 */
handleInjectToRoundtable: () => void;
/**
* 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。
* `files`:可选的参考文件,发送前先上传到总控 thread,首轮 leader run 据此 read_file 读取。
*/
handleInjectToRoundtable: (files?: File[]) => void;
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
submitClarification: (rawAnswer: string) => void;
/** 预算滑块(本地演示,非编排真实输入)。 */
@@ -464,6 +479,11 @@ export function useStep2Orchestration(
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
/** 干预研审:恢复会商后待执行的 leader prompt。 */
const pendingInjectPromptRef = useRef<string | null>(null);
/**
* 干预消息携带的上传文件引用:由下一轮编排的**首个 leader run**消费一次后清空,
* 总控据此 read_file 读取文件再派活。consume-once 语义避免后续轮次重复带文件。
*/
const pendingInjectFilesRef = useRef<RunFileRef[] | null>(null);
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
@@ -1353,6 +1373,8 @@ export function useStep2Orchestration(
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
// 首轮纯派活、无交付,传 false 保持上下文精简。
synthesisMode: safetyCounter > 1,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
@@ -1600,6 +1622,8 @@ export function useStep2Orchestration(
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
@@ -1887,6 +1911,8 @@ export function useStep2Orchestration(
// 抑制预广播:别把总控的派活 prompt 播进席位 thread(否则弱模型从历史里捡到派活
// 意图、跟着模仿派活 / 绕道 bash 调内部接口)。席位任务由 pickDagSeatTask 干净下发。
suppressPreBroadcast: true,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
@@ -2289,38 +2315,75 @@ export function useStep2Orchestration(
triggerToast("干预指令已记录,会商已自动恢复执行");
}, [appendDialogue, customAgentInput, triggerToast]);
const handleInjectToRoundtable = useCallback(() => {
const userText = customAgentInput.trim();
if (!userText) return;
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
return;
}
const handleInjectToRoundtable = useCallback(
async (files?: File[]) => {
const userText = customAgentInput.trim();
const hasFiles = !!(files && files.length > 0);
if (!userText && !hasFiles) return;
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
return;
}
// 中止当前编排(如有)
orchestrationCancelRef.current = true;
abortAllInFlight();
// 强制标记非编排中,使后续 runOrchestration 可立即启动
isOrchestratingRef.current = false;
setIsOrchestrating(false);
// 先把文件上传到总控 thread(与普通聊天同一上传接口/技能链路)。拿到 virtual_path
// 存进 consume-once ref,供下一轮首个 leader run 注入 additional_kwargs.files。
// 上传失败则中止本次干预,不打断当前会商。
if (hasFiles) {
const coordThreadId = resolveSeatThreadId(
threadIdsRef.current,
coordinatorNameRef.current,
);
try {
const up = await uploadFiles(coordThreadId, files!);
pendingInjectFilesRef.current = up.files.map((f) => ({
filename: f.filename,
size: f.size,
path: f.virtual_path,
status: "uploaded",
}));
} catch (err) {
triggerToast(`❌ 文件上传失败: ${(err as Error).message}`);
return;
}
}
setCustomAgentInput("");
appendDialogue({
sender: "用户 (插话干预)",
avatarType: "human",
content: `💡 (人工指令介入) ${userText}`,
confidence: "最高指令",
});
// 中止当前编排(如有)
orchestrationCancelRef.current = true;
abortAllInFlight();
// 强制标记非编排中,使后续 runOrchestration 可立即启动
isOrchestratingRef.current = false;
setIsOrchestrating(false);
const prompt = `用户人工干预新指令:${userText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
setCustomAgentInput("");
const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : "";
const shownText = userText || "(上传了参考文件)";
appendDialogue({
sender: "用户 (插话干预)",
avatarType: "human",
content: `💡 (人工指令介入) ${shownText}${fileNote}`,
confidence: "最高指令",
});
// 确保处于运行状态(同步更新按钮 UI)
setIsRoundTableRunning(true);
triggerToast("干预指令已发送,会商继续执行");
const promptText =
userText || "我上传了参考文件,请先阅读文件内容,再据此推进研讨。";
const prompt = `用户人工干预新指令:${promptText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
// 直接启动新一轮编排
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
}, [customAgentInput, appendDialogue, abortAllInFlight, runActiveOrchestration, triggerToast]);
// 确保处于运行状态(同步更新按钮 UI)
setIsRoundTableRunning(true);
triggerToast("干预指令已发送,会商继续执行");
// 直接启动新一轮编排
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
},
[
customAgentInput,
appendDialogue,
abortAllInFlight,
runActiveOrchestration,
resolveSeatThreadId,
triggerToast,
],
);
const submitClarification = useCallback(
(rawAnswer: string) => {
@@ -1010,7 +1010,7 @@ export default function RoundtablePlanningPage() {
pendingIntentClarificationMsgId={step1.pendingIntentClarificationMsgId}
intentClarificationSelections={step1.intentClarificationSelections}
setIntentClarificationSelections={step1.setIntentClarificationSelections}
onSendMessage={(text) => void step1.sendIntentMessage(text)}
onSendMessage={(text, files) => void step1.sendIntentMessage(text, files)}
onStopIntent={step1.stopIntentStream}
onSkipQuestion={handleSkipQuestion}
onFollowUp={handleAgentFollowUp}