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";
|
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 {
|
export interface StreamIntentRequest {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
message: string;
|
message: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
/** Step 1 用户上传的参考文件(可选)。 */
|
||||||
|
files?: IntentFileRef[];
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */
|
/** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */
|
||||||
onTextDelta?: (text: string, messageId: string) => void;
|
onTextDelta?: (text: string, messageId: string) => void;
|
||||||
@@ -318,6 +332,8 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
|||||||
thread_id: req.threadId,
|
thread_id: req.threadId,
|
||||||
message: req.message,
|
message: req.message,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
|
// 仅在有文件时带上,空数组后端也安全忽略。
|
||||||
|
files: req.files && req.files.length > 0 ? req.files : undefined,
|
||||||
}),
|
}),
|
||||||
signal: req.signal,
|
signal: req.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ export interface StreamMultiAgentRequest {
|
|||||||
* (平时只看广播摘要)。不进 prompt → 不调工具就不耗 token。
|
* (平时只看广播摘要)。不进 prompt → 不调工具就不耗 token。
|
||||||
*/
|
*/
|
||||||
peerDeliveries?: Array<{ name: string; content: string }>;
|
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;
|
signal?: AbortSignal;
|
||||||
/** Fires for every non-status data frame the upstream emits. */
|
/** Fires for every non-status data frame the upstream emits. */
|
||||||
onUpstreamFrame?: (frame: unknown) => void;
|
onUpstreamFrame?: (frame: unknown) => void;
|
||||||
@@ -447,6 +453,8 @@ export async function streamMultiAgent(
|
|||||||
parallel_no_file: req.parallelNoFile ?? false,
|
parallel_no_file: req.parallelNoFile ?? false,
|
||||||
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
|
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
|
||||||
peer_deliveries: req.peerDeliveries ?? [],
|
peer_deliveries: req.peerDeliveries ?? [],
|
||||||
|
// 用户随干预上传的参考文件;仅在有文件时带上,后端注入 additional_kwargs.files。
|
||||||
|
files: req.files && req.files.length > 0 ? req.files : undefined,
|
||||||
}),
|
}),
|
||||||
signal: req.signal,
|
signal: req.signal,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,8 +14,10 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Paperclip,
|
||||||
Pause,
|
Pause,
|
||||||
Repeat,
|
Repeat,
|
||||||
Save,
|
Save,
|
||||||
@@ -25,6 +27,13 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { Button } from "@/components/ui/button";
|
||||||
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
||||||
import {
|
import {
|
||||||
@@ -62,7 +71,7 @@ export interface Step1PanelProps {
|
|||||||
setIntentClarificationSelections: React.Dispatch<
|
setIntentClarificationSelections: React.Dispatch<
|
||||||
React.SetStateAction<Map<string, IntentClarificationSelection>>
|
React.SetStateAction<Map<string, IntentClarificationSelection>>
|
||||||
>;
|
>;
|
||||||
onSendMessage: (text: string) => void;
|
onSendMessage: (text: string, files?: File[]) => void;
|
||||||
onStopIntent: () => void;
|
onStopIntent: () => void;
|
||||||
onSkipQuestion: () => void;
|
onSkipQuestion: () => void;
|
||||||
onFollowUp: () => void;
|
onFollowUp: () => void;
|
||||||
@@ -101,13 +110,34 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
|
|
||||||
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
|
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
|
||||||
const [inputText, setInputText] = useState<string>("");
|
const [inputText, setInputText] = useState<string>("");
|
||||||
|
// 待发送的本地附件(点发送时连同文本一起上传)。
|
||||||
|
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const charactersLeft = inputText.length;
|
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 = () => {
|
const handleSend = () => {
|
||||||
if (!inputText.trim()) return;
|
if (!inputText.trim() && attachedFiles.length === 0) return;
|
||||||
onSendMessage(inputText);
|
onSendMessage(inputText, attachedFiles.length > 0 ? attachedFiles : undefined);
|
||||||
setInputText("");
|
setInputText("");
|
||||||
|
setAttachedFiles([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -150,6 +180,33 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
{/* Bottom input area */}
|
{/* Bottom input area */}
|
||||||
<div className="shrink-0 p-4 border-t border-slate-100 bg-[#fcfcfd]">
|
<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">
|
<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
|
<textarea
|
||||||
id="input-text-area"
|
id="input-text-area"
|
||||||
value={inputText}
|
value={inputText}
|
||||||
@@ -177,6 +234,21 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
>
|
>
|
||||||
<Smile className="w-4 h-4" />
|
<Smile className="w-4 h-4" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<span className="text-[10px] font-mono text-slate-400">{charactersLeft}/500</span>
|
<span className="text-[10px] font-mono text-slate-400">{charactersLeft}/500</span>
|
||||||
@@ -422,8 +494,30 @@ function Step1Message({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="roundtable-chat-message text-slate-700 font-medium whitespace-pre-wrap break-words">
|
<div className="space-y-2">
|
||||||
{msg.content}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</MessageBubble>
|
</MessageBubble>
|
||||||
|
|||||||
@@ -23,11 +23,20 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Network,
|
Network,
|
||||||
|
Paperclip,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} 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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -124,7 +133,7 @@ export interface Step2PanelProps {
|
|||||||
// ─── Handlers ─────────────────────────────────────────────────
|
// ─── Handlers ─────────────────────────────────────────────────
|
||||||
onPauseRoundtable: () => void;
|
onPauseRoundtable: () => void;
|
||||||
onResumeRoundtable: () => void;
|
onResumeRoundtable: () => void;
|
||||||
onHandleInjectToRoundtable: () => void;
|
onHandleInjectToRoundtable: (files?: File[]) => void;
|
||||||
onSubmitClarification: (answer: string) => void;
|
onSubmitClarification: (answer: string) => void;
|
||||||
onSavePersona: (id: number) => void;
|
onSavePersona: (id: number) => void;
|
||||||
onProceedToStep3: () => void;
|
onProceedToStep3: () => void;
|
||||||
@@ -251,11 +260,34 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
|
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
|
||||||
const [pauseFlash, setPauseFlash] = useState(false);
|
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(() => {
|
const handleInject = useCallback(() => {
|
||||||
onHandleInjectToRoundtable();
|
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
|
||||||
|
setInjectFiles([]);
|
||||||
setPauseFlash(true);
|
setPauseFlash(true);
|
||||||
window.setTimeout(() => setPauseFlash(false), 1600);
|
window.setTimeout(() => setPauseFlash(false), 1600);
|
||||||
}, [onHandleInjectToRoundtable]);
|
}, [onHandleInjectToRoundtable, injectFiles]);
|
||||||
|
|
||||||
const isSandboxStreaming =
|
const isSandboxStreaming =
|
||||||
isOrchestrating ||
|
isOrchestrating ||
|
||||||
@@ -530,7 +562,35 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Console + intervention bar */}
|
{/* 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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customAgentInput}
|
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"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleInject}
|
onClick={handleInject}
|
||||||
@@ -581,6 +657,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
|
||||||
达成共识生成全案成果 >
|
达成共识生成全案成果 >
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
initIntent,
|
initIntent,
|
||||||
streamIntent,
|
streamIntent,
|
||||||
type IntentErrorDetail,
|
type IntentErrorDetail,
|
||||||
|
type IntentFileRef,
|
||||||
type IntentStreamPhase,
|
type IntentStreamPhase,
|
||||||
type IntentSummary,
|
type IntentSummary,
|
||||||
} from "@/roundtable-planning/api/intent";
|
} from "@/roundtable-planning/api/intent";
|
||||||
|
import { uploadFiles } from "@/core/uploads/api";
|
||||||
import {
|
import {
|
||||||
clarificationKey,
|
clarificationKey,
|
||||||
formatIntentClarificationAnswer,
|
formatIntentClarificationAnswer,
|
||||||
@@ -42,6 +44,8 @@ export type InteractiveMessage = {
|
|||||||
time: string;
|
time: string;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
phase?: string;
|
phase?: string;
|
||||||
|
/** 用户气泡里附带的上传文件(仅展示用:文件名 + 大小)。 */
|
||||||
|
files?: Array<{ filename: string; size: number }>;
|
||||||
/** First clarification (back-compat); prefer `clarifications`. */
|
/** First clarification (back-compat); prefer `clarifications`. */
|
||||||
clarification?: ClarificationPayload;
|
clarification?: ClarificationPayload;
|
||||||
/**
|
/**
|
||||||
@@ -109,8 +113,12 @@ export interface UseStep1IntentReturn {
|
|||||||
// ─── Handlers ────────────────────────────────────────────────────────
|
// ─── Handlers ────────────────────────────────────────────────────────
|
||||||
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
|
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
|
||||||
runStep1Init: () => void;
|
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 消息的全部澄清问题答案 —— 逐题取各自选择,拼成一条合并消息
|
* 提交某条 agent 消息的全部澄清问题答案 —— 逐题取各自选择,拼成一条合并消息
|
||||||
* 发回。至少答一题才会发送。单题时退化为发送该题答案。
|
* 发回。至少答一题才会发送。单题时退化为发送该题答案。
|
||||||
@@ -275,9 +283,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
}, [interactiveMessages, intentReady]);
|
}, [interactiveMessages, intentReady]);
|
||||||
|
|
||||||
const sendIntentMessage = useCallback(
|
const sendIntentMessage = useCallback(
|
||||||
async (userText: string) => {
|
async (userText: string, files?: File[]) => {
|
||||||
const trimmed = userText.trim();
|
const trimmed = userText.trim();
|
||||||
if (!trimmed) return;
|
const hasFiles = !!(files && files.length > 0);
|
||||||
|
if (!trimmed && !hasFiles) return;
|
||||||
if (!intentThreadId) {
|
if (!intentThreadId) {
|
||||||
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
|
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
|
||||||
return;
|
return;
|
||||||
@@ -304,16 +313,24 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
senderName: "用户",
|
senderName: "用户",
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
time: userMsgTime,
|
time: userMsgTime,
|
||||||
|
files: hasFiles
|
||||||
|
? files!.map((f) => ({ filename: f.name, size: f.size }))
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
setInteractiveMessages((prev) => [...prev, userMsg]);
|
setInteractiveMessages((prev) => [...prev, userMsg]);
|
||||||
|
|
||||||
|
const timelineDesc = trimmed
|
||||||
|
? trimmed.length > 25
|
||||||
|
? trimmed.substring(0, 25) + "..."
|
||||||
|
: trimmed
|
||||||
|
: `上传了 ${files!.length} 个文件`;
|
||||||
setTimelineEntries((prev) => [
|
setTimelineEntries((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: prev.length + 1,
|
id: prev.length + 1,
|
||||||
time: userMsgTime,
|
time: userMsgTime,
|
||||||
title: "用户实时反馈",
|
title: "用户实时反馈",
|
||||||
desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed,
|
desc: timelineDesc,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -333,10 +350,49 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
|
|
||||||
setIsIntentSending(true);
|
setIsIntentSending(true);
|
||||||
try {
|
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({
|
const res = await streamIntent({
|
||||||
threadId: intentThreadId,
|
threadId: intentThreadId,
|
||||||
message: trimmed,
|
message: trimmed,
|
||||||
model: selectedModelRef.current || undefined,
|
model: selectedModelRef.current || undefined,
|
||||||
|
files: fileRefs,
|
||||||
signal: aborter.signal,
|
signal: aborter.signal,
|
||||||
onTextDelta: (delta) => {
|
onTextDelta: (delta) => {
|
||||||
setInteractiveMessages((prev) =>
|
setInteractiveMessages((prev) =>
|
||||||
|
|||||||
@@ -23,8 +23,20 @@ import {
|
|||||||
type ClarificationPayload,
|
type ClarificationPayload,
|
||||||
type LeaderDispatch,
|
type LeaderDispatch,
|
||||||
type StreamPhase,
|
type StreamPhase,
|
||||||
|
type StreamMultiAgentRequest,
|
||||||
type ThreadIdMap,
|
type ThreadIdMap,
|
||||||
} from "@/roundtable-planning/api/multi-agent";
|
} 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 {
|
import {
|
||||||
buildDagStageLeaderTask,
|
buildDagStageLeaderTask,
|
||||||
pickDagSeatTask,
|
pickDagSeatTask,
|
||||||
@@ -305,8 +317,11 @@ export interface UseStep2OrchestrationReturn {
|
|||||||
recordInjectMessage: () => void;
|
recordInjectMessage: () => void;
|
||||||
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
|
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
|
||||||
abortAllInFlight: () => void;
|
abortAllInFlight: () => void;
|
||||||
/** 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。 */
|
/**
|
||||||
handleInjectToRoundtable: () => void;
|
* 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。
|
||||||
|
* `files`:可选的参考文件,发送前先上传到总控 thread,首轮 leader run 据此 read_file 读取。
|
||||||
|
*/
|
||||||
|
handleInjectToRoundtable: (files?: File[]) => void;
|
||||||
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
|
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
|
||||||
submitClarification: (rawAnswer: string) => void;
|
submitClarification: (rawAnswer: string) => void;
|
||||||
/** 预算滑块(本地演示,非编排真实输入)。 */
|
/** 预算滑块(本地演示,非编排真实输入)。 */
|
||||||
@@ -464,6 +479,11 @@ export function useStep2Orchestration(
|
|||||||
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
|
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
|
||||||
/** 干预研审:恢复会商后待执行的 leader prompt。 */
|
/** 干预研审:恢复会商后待执行的 leader prompt。 */
|
||||||
const pendingInjectPromptRef = useRef<string | null>(null);
|
const pendingInjectPromptRef = useRef<string | null>(null);
|
||||||
|
/**
|
||||||
|
* 干预消息携带的上传文件引用:由下一轮编排的**首个 leader run**消费一次后清空,
|
||||||
|
* 总控据此 read_file 读取文件再派活。consume-once 语义避免后续轮次重复带文件。
|
||||||
|
*/
|
||||||
|
const pendingInjectFilesRef = useRef<RunFileRef[] | null>(null);
|
||||||
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
|
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
|
||||||
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
|
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
|
||||||
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
|
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
|
||||||
@@ -1353,6 +1373,8 @@ export function useStep2Orchestration(
|
|||||||
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
|
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
|
||||||
// 首轮纯派活、无交付,传 false 保持上下文精简。
|
// 首轮纯派活、无交付,传 false 保持上下文精简。
|
||||||
synthesisMode: safetyCounter > 1,
|
synthesisMode: safetyCounter > 1,
|
||||||
|
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
|
||||||
|
files: takeOnce(pendingInjectFilesRef),
|
||||||
model: selectedModelRef.current || undefined,
|
model: selectedModelRef.current || undefined,
|
||||||
signal: leaderAborter.signal,
|
signal: leaderAborter.signal,
|
||||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||||
@@ -1600,6 +1622,8 @@ export function useStep2Orchestration(
|
|||||||
dAgentThreadId: ids,
|
dAgentThreadId: ids,
|
||||||
agentName: coord,
|
agentName: coord,
|
||||||
newMessage: leaderTask,
|
newMessage: leaderTask,
|
||||||
|
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
|
||||||
|
files: takeOnce(pendingInjectFilesRef),
|
||||||
model: selectedModelRef.current || undefined,
|
model: selectedModelRef.current || undefined,
|
||||||
signal: leaderAborter.signal,
|
signal: leaderAborter.signal,
|
||||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||||
@@ -1887,6 +1911,8 @@ export function useStep2Orchestration(
|
|||||||
// 抑制预广播:别把总控的派活 prompt 播进席位 thread(否则弱模型从历史里捡到派活
|
// 抑制预广播:别把总控的派活 prompt 播进席位 thread(否则弱模型从历史里捡到派活
|
||||||
// 意图、跟着模仿派活 / 绕道 bash 调内部接口)。席位任务由 pickDagSeatTask 干净下发。
|
// 意图、跟着模仿派活 / 绕道 bash 调内部接口)。席位任务由 pickDagSeatTask 干净下发。
|
||||||
suppressPreBroadcast: true,
|
suppressPreBroadcast: true,
|
||||||
|
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
|
||||||
|
files: takeOnce(pendingInjectFilesRef),
|
||||||
model: selectedModelRef.current || undefined,
|
model: selectedModelRef.current || undefined,
|
||||||
signal: leaderAborter.signal,
|
signal: leaderAborter.signal,
|
||||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||||
@@ -2289,38 +2315,75 @@ export function useStep2Orchestration(
|
|||||||
triggerToast("干预指令已记录,会商已自动恢复执行");
|
triggerToast("干预指令已记录,会商已自动恢复执行");
|
||||||
}, [appendDialogue, customAgentInput, triggerToast]);
|
}, [appendDialogue, customAgentInput, triggerToast]);
|
||||||
|
|
||||||
const handleInjectToRoundtable = useCallback(() => {
|
const handleInjectToRoundtable = useCallback(
|
||||||
const userText = customAgentInput.trim();
|
async (files?: File[]) => {
|
||||||
if (!userText) return;
|
const userText = customAgentInput.trim();
|
||||||
if (!threadIdsRef.current || !coordinatorNameRef.current) {
|
const hasFiles = !!(files && files.length > 0);
|
||||||
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
|
if (!userText && !hasFiles) return;
|
||||||
return;
|
if (!threadIdsRef.current || !coordinatorNameRef.current) {
|
||||||
}
|
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 中止当前编排(如有)
|
// 先把文件上传到总控 thread(与普通聊天同一上传接口/技能链路)。拿到 virtual_path
|
||||||
orchestrationCancelRef.current = true;
|
// 存进 consume-once ref,供下一轮首个 leader run 注入 additional_kwargs.files。
|
||||||
abortAllInFlight();
|
// 上传失败则中止本次干预,不打断当前会商。
|
||||||
// 强制标记非编排中,使后续 runOrchestration 可立即启动
|
if (hasFiles) {
|
||||||
isOrchestratingRef.current = false;
|
const coordThreadId = resolveSeatThreadId(
|
||||||
setIsOrchestrating(false);
|
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({
|
orchestrationCancelRef.current = true;
|
||||||
sender: "用户 (插话干预)",
|
abortAllInFlight();
|
||||||
avatarType: "human",
|
// 强制标记非编排中,使后续 runOrchestration 可立即启动
|
||||||
content: `💡 (人工指令介入) ${userText}`,
|
isOrchestratingRef.current = false;
|
||||||
confidence: "最高指令",
|
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)
|
const promptText =
|
||||||
setIsRoundTableRunning(true);
|
userText || "我上传了参考文件,请先阅读文件内容,再据此推进研讨。";
|
||||||
triggerToast("干预指令已发送,会商继续执行");
|
const prompt = `用户人工干预新指令:${promptText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
|
||||||
|
|
||||||
// 直接启动新一轮编排
|
// 确保处于运行状态(同步更新按钮 UI)
|
||||||
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
|
setIsRoundTableRunning(true);
|
||||||
}, [customAgentInput, appendDialogue, abortAllInFlight, runActiveOrchestration, triggerToast]);
|
triggerToast("干预指令已发送,会商继续执行");
|
||||||
|
|
||||||
|
// 直接启动新一轮编排
|
||||||
|
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
customAgentInput,
|
||||||
|
appendDialogue,
|
||||||
|
abortAllInFlight,
|
||||||
|
runActiveOrchestration,
|
||||||
|
resolveSeatThreadId,
|
||||||
|
triggerToast,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const submitClarification = useCallback(
|
const submitClarification = useCallback(
|
||||||
(rawAnswer: string) => {
|
(rawAnswer: string) => {
|
||||||
|
|||||||
@@ -1010,7 +1010,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
pendingIntentClarificationMsgId={step1.pendingIntentClarificationMsgId}
|
pendingIntentClarificationMsgId={step1.pendingIntentClarificationMsgId}
|
||||||
intentClarificationSelections={step1.intentClarificationSelections}
|
intentClarificationSelections={step1.intentClarificationSelections}
|
||||||
setIntentClarificationSelections={step1.setIntentClarificationSelections}
|
setIntentClarificationSelections={step1.setIntentClarificationSelections}
|
||||||
onSendMessage={(text) => void step1.sendIntentMessage(text)}
|
onSendMessage={(text, files) => void step1.sendIntentMessage(text, files)}
|
||||||
onStopIntent={step1.stopIntentStream}
|
onStopIntent={step1.stopIntentStream}
|
||||||
onSkipQuestion={handleSkipQuestion}
|
onSkipQuestion={handleSkipQuestion}
|
||||||
onFollowUp={handleAgentFollowUp}
|
onFollowUp={handleAgentFollowUp}
|
||||||
|
|||||||
@@ -328,9 +328,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
|
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
|
||||||
# 自己的本地模型即可工作。
|
# 自己的本地模型即可工作。
|
||||||
model_name = str(payload.get("model") or "")
|
model_name = str(payload.get("model") or "")
|
||||||
|
# 用户在 Step 1 上传的参考文件(已经过 /api/threads/{thread_id}/uploads)。
|
||||||
|
# 前端传 [{filename, size, path, status}, ...],这里原样透传给 _run_payload →
|
||||||
|
# additional_kwargs.files → UploadsMiddleware 自动注入清单 + 解锁 read_file 等工具。
|
||||||
|
raw_files = payload.get("files")
|
||||||
|
files = raw_files if isinstance(raw_files, list) else []
|
||||||
if not thread_id:
|
if not thread_id:
|
||||||
raise HTTPException(status_code=400, detail="thread_id is required")
|
raise HTTPException(status_code=400, detail="thread_id is required")
|
||||||
if not message:
|
# 允许「仅文件、无文本」的回合:带了文件就不强制要求 message。
|
||||||
|
if not message and not files:
|
||||||
raise HTTPException(status_code=400, detail="message is required")
|
raise HTTPException(status_code=400, detail="message is required")
|
||||||
|
|
||||||
# 调用前自检:防御性二次检查——若管理员在 init 之后手动删了目录,这里也能自愈。
|
# 调用前自检:防御性二次检查——若管理员在 init 之后手动删了目录,这里也能自愈。
|
||||||
@@ -381,8 +387,17 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
_MAX_CLARIFICATION_ROUNDS,
|
_MAX_CLARIFICATION_ROUNDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 带了上传文件时,解锁「读」类工具(read_file / ls / grep / view_image),
|
||||||
|
# 否则 UploadsMiddleware 注入了文件清单、模型却没工具读取。仍保留对
|
||||||
|
# write_file / str_replace / present_files / bash / agent_orchestration 的封锁,
|
||||||
|
# 防止意图阶段越界做方案/派活。
|
||||||
|
if files:
|
||||||
|
read_tools = {"read_file", "ls", "grep", "view_image"}
|
||||||
|
base_excluded_tools = [t for t in base_excluded_tools if t not in read_tools]
|
||||||
|
|
||||||
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
|
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
|
||||||
effective_message = message
|
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
|
||||||
|
effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。"
|
||||||
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
||||||
effective_message = (
|
effective_message = (
|
||||||
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
|
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
|
||||||
@@ -409,6 +424,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
# not reliable on its own, hard exclusion is.
|
# not reliable on its own, hard exclusion is.
|
||||||
excluded_tools=base_excluded_tools,
|
excluded_tools=base_excluded_tools,
|
||||||
skill_stop_names=[],
|
skill_stop_names=[],
|
||||||
|
files=files,
|
||||||
)
|
)
|
||||||
|
|
||||||
# captured:上游 SSE 全部 data 行,用于流结束后从 values 快照取 messages。
|
# captured:上游 SSE 全部 data 行,用于流结束后从 values 快照取 messages。
|
||||||
|
|||||||
@@ -638,6 +638,7 @@ def _run_payload(
|
|||||||
reasoning_effort: str = "medium",
|
reasoning_effort: str = "medium",
|
||||||
multitask_strategy: str = "reject",
|
multitask_strategy: str = "reject",
|
||||||
extra_context: dict[str, Any] | None = None,
|
extra_context: dict[str, Any] | None = None,
|
||||||
|
files: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
||||||
|
|
||||||
@@ -650,13 +651,19 @@ def _run_payload(
|
|||||||
应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐
|
应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐
|
||||||
reasoning tokens 才开口的时间,显著降低前端"调度中"首字节延迟。
|
reasoning tokens 才开口的时间,显著降低前端"调度中"首字节延迟。
|
||||||
"""
|
"""
|
||||||
|
# 用户上传的文件元数据透传给 UploadsMiddleware(与普通聊天同链路):
|
||||||
|
# before_agent 钩子读 additional_kwargs.files,生成 <uploaded_files> 清单注入消息,
|
||||||
|
# 之后席位/总控用内置 read_file / grep / view_image 工具按需读取。空时落空 dict,行为不变。
|
||||||
|
additional_kwargs: dict[str, Any] = {}
|
||||||
|
if files:
|
||||||
|
additional_kwargs["files"] = files
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"input": {
|
"input": {
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"type": "human",
|
"type": "human",
|
||||||
"content": [{"type": "text", "text": new_message}],
|
"content": [{"type": "text", "text": new_message}],
|
||||||
"additional_kwargs": {},
|
"additional_kwargs": additional_kwargs,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1258,6 +1265,7 @@ async def _leader_run(
|
|||||||
seat_meta: dict[str, dict[str, Any]] | None = None,
|
seat_meta: dict[str, dict[str, Any]] | None = None,
|
||||||
synthesis_mode: bool = False,
|
synthesis_mode: bool = False,
|
||||||
suppress_pre_broadcast: bool = False,
|
suppress_pre_broadcast: bool = False,
|
||||||
|
files: list[dict[str, Any]] | None = None,
|
||||||
) -> AsyncIterator[str | dict[str, Any]]:
|
) -> AsyncIterator[str | dict[str, Any]]:
|
||||||
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
|
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
|
||||||
|
|
||||||
@@ -1346,18 +1354,26 @@ async def _leader_run(
|
|||||||
# 调用方可再叠加自己的 skill_stop。后台进程内网关用同一份 policy,保证传参一致。
|
# 调用方可再叠加自己的 skill_stop。后台进程内网关用同一份 policy,保证传参一致。
|
||||||
policy = roundtable_run_policy("leader")
|
policy = roundtable_run_policy("leader")
|
||||||
effective_stop_names = merge_skill_stop_names(policy.skill_stop_names, skill_stop_names)
|
effective_stop_names = merge_skill_stop_names(policy.skill_stop_names, skill_stop_names)
|
||||||
|
# 带了上传文件时,给总控解锁「读」类工具(leader policy 默认禁 read_file/ls/view_image),
|
||||||
|
# 否则文件清单注入了却无工具读取。写/派活类仍按 policy 封锁,不放开。
|
||||||
|
leader_excluded = policy.excluded_tools
|
||||||
|
if files:
|
||||||
|
_read_tools = {"read_file", "ls", "grep", "view_image"}
|
||||||
|
leader_excluded = [t for t in leader_excluded if t not in _read_tools]
|
||||||
body = _run_payload(
|
body = _run_payload(
|
||||||
agent_name=agent_name,
|
agent_name=agent_name,
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
# 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。
|
# 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。
|
||||||
new_message=leader_input,
|
new_message=leader_input,
|
||||||
excluded_tools=policy.excluded_tools,
|
excluded_tools=leader_excluded,
|
||||||
skill_stop_names=effective_stop_names,
|
skill_stop_names=effective_stop_names,
|
||||||
thinking_enabled=policy.thinking_enabled,
|
thinking_enabled=policy.thinking_enabled,
|
||||||
reasoning_effort=policy.reasoning_effort,
|
reasoning_effort=policy.reasoning_effort,
|
||||||
# 人工干预/恢复会重发 leader run 到同一总控 thread;抢占掉没结束的旧 run,避免 409。
|
# 人工干预/恢复会重发 leader run 到同一总控 thread;抢占掉没结束的旧 run,避免 409。
|
||||||
multitask_strategy="rollback",
|
multitask_strategy="rollback",
|
||||||
|
# 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。
|
||||||
|
files=files,
|
||||||
)
|
)
|
||||||
t_payload_built = time.perf_counter()
|
t_payload_built = time.perf_counter()
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1532,6 +1548,7 @@ async def _special_run(
|
|||||||
display_name: str = "",
|
display_name: str = "",
|
||||||
no_file: bool = False,
|
no_file: bool = False,
|
||||||
peer_deliveries: list[dict[str, Any]] | None = None,
|
peer_deliveries: list[dict[str, Any]] | None = None,
|
||||||
|
files: list[dict[str, Any]] | None = None,
|
||||||
) -> AsyncIterator[str | dict[str, Any]]:
|
) -> AsyncIterator[str | dict[str, Any]]:
|
||||||
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
|
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
|
||||||
|
|
||||||
@@ -1560,12 +1577,18 @@ async def _special_run(
|
|||||||
else:
|
else:
|
||||||
role = "seat"
|
role = "seat"
|
||||||
policy = roundtable_run_policy(role)
|
policy = roundtable_run_policy(role)
|
||||||
|
# 带了上传文件时,给席位解锁「读」类工具(view_image 默认被禁;read_file 本就可用),
|
||||||
|
# 让席位能读取/识别用户上传的文件。写/派活类仍按 policy 封锁。
|
||||||
|
seat_excluded = policy.excluded_tools
|
||||||
|
if files:
|
||||||
|
_read_tools = {"read_file", "ls", "grep", "view_image"}
|
||||||
|
seat_excluded = [t for t in seat_excluded if t not in _read_tools]
|
||||||
body = _run_payload(
|
body = _run_payload(
|
||||||
agent_name=agent_name,
|
agent_name=agent_name,
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
new_message=new_message,
|
new_message=new_message,
|
||||||
excluded_tools=policy.excluded_tools,
|
excluded_tools=seat_excluded,
|
||||||
skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
|
skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
|
||||||
thinking_enabled=policy.thinking_enabled,
|
thinking_enabled=policy.thinking_enabled,
|
||||||
reasoning_effort=policy.reasoning_effort,
|
reasoning_effort=policy.reasoning_effort,
|
||||||
@@ -1575,6 +1598,8 @@ async def _special_run(
|
|||||||
extra_context=(
|
extra_context=(
|
||||||
{"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None
|
{"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None
|
||||||
),
|
),
|
||||||
|
# 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。
|
||||||
|
files=files,
|
||||||
)
|
)
|
||||||
t_payload_built = time.perf_counter()
|
t_payload_built = time.perf_counter()
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1677,6 +1702,11 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
|||||||
display_name: str = str(payload.get("display_name") or "").strip()
|
display_name: str = str(payload.get("display_name") or "").strip()
|
||||||
new_message: str = str(payload.get("new_message") or "")
|
new_message: str = str(payload.get("new_message") or "")
|
||||||
skill_stop_names: list[str] = payload.get("skill_stop_names") or []
|
skill_stop_names: list[str] = payload.get("skill_stop_names") or []
|
||||||
|
# 用户随干预消息上传的参考文件(已经过 /api/threads/{thread_id}/uploads)。
|
||||||
|
# 前端传 [{filename, size, path, status}, ...],透传给 _run_payload →
|
||||||
|
# additional_kwargs.files → UploadsMiddleware 注入清单 + 解锁 read_file 等工具。
|
||||||
|
_files_raw = payload.get("files")
|
||||||
|
files: list[dict[str, Any]] = _files_raw if isinstance(_files_raw, list) else []
|
||||||
# 综合轮标记(仅 leader 用):前端在「派活后让总控判断继续/综合」的轮次置真,网关据此
|
# 综合轮标记(仅 leader 用):前端在「派活后让总控判断继续/综合」的轮次置真,网关据此
|
||||||
# 额外把各已交付席位的完整交付正文注入总控输入,供其写最终方案。缺省 False(派活决策轮
|
# 额外把各已交付席位的完整交付正文注入总控输入,供其写最终方案。缺省 False(派活决策轮
|
||||||
# 只给摘要,保持上下文精简)。旧前端不传 → False,行为安全退化。
|
# 只给摘要,保持上下文精简)。旧前端不传 → False,行为安全退化。
|
||||||
@@ -1741,6 +1771,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
|||||||
seat_meta=seat_meta,
|
seat_meta=seat_meta,
|
||||||
synthesis_mode=synthesis_mode,
|
synthesis_mode=synthesis_mode,
|
||||||
suppress_pre_broadcast=suppress_pre_broadcast,
|
suppress_pre_broadcast=suppress_pre_broadcast,
|
||||||
|
files=files,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
gen = _special_run(
|
gen = _special_run(
|
||||||
@@ -1748,6 +1779,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
|||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
no_file=no_file,
|
no_file=no_file,
|
||||||
peer_deliveries=peer_deliveries,
|
peer_deliveries=peer_deliveries,
|
||||||
|
files=files,
|
||||||
)
|
)
|
||||||
async for chunk in gen:
|
async for chunk in gen:
|
||||||
if isinstance(chunk, dict):
|
if isinstance(chunk, dict):
|
||||||
|
|||||||
Reference in new issue
Block a user