Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
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" />
|
||||
达成共识生成全案成果 >
|
||||
</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}
|
||||
|
||||
Reference in new issue
Block a user