Merge branch 'dhk-1' into 'master'

接口地址修改

See merge request knowledge-base-bj/deerflow-server!109
This commit is contained in:
董宏凯 committed 2026-06-10 13:57:49 +00:00
commit c57696cf80
11 files changed
+427 -61

No files matched your search

+8 -7
View File
@@ -8,10 +8,10 @@ VITE_USE_REAL_DATA=true
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
@@ -26,15 +26,16 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
+8 -5
View File
@@ -10,6 +10,8 @@ VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.sit.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=https://ch1.b.sit.4.cn/gpt
# 多轮检索接口
@@ -24,15 +26,16 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -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}
@@ -328,9 +328,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
# 自己的本地模型即可工作。
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:
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")
# 调用前自检:防御性二次检查——若管理员在 init 之后手动删了目录,这里也能自愈。
@@ -381,8 +387,17 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
_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 前缀——给模型一个明确的"本轮必须收敛"信号。
effective_message = message
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。"
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
effective_message = (
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.
excluded_tools=base_excluded_tools,
skill_stop_names=[],
files=files,
)
# captured:上游 SSE 全部 data 行,用于流结束后从 values 快照取 messages。
@@ -638,6 +638,7 @@ def _run_payload(
reasoning_effort: str = "medium",
multitask_strategy: str = "reject",
extra_context: dict[str, Any] | None = None,
files: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
@@ -650,13 +651,19 @@ def _run_payload(
应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐
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] = {
"input": {
"messages": [
{
"type": "human",
"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,
synthesis_mode: bool = False,
suppress_pre_broadcast: bool = False,
files: list[dict[str, Any]] | None = None,
) -> AsyncIterator[str | dict[str, Any]]:
"""总控(leader)单轮:透传上游 SSE + 末尾 yield 一个 dict 状态帧(由路由层 ``json.dumps``)。
@@ -1346,18 +1354,26 @@ async def _leader_run(
# 调用方可再叠加自己的 skill_stop。后台进程内网关用同一份 policy,保证传参一致。
policy = roundtable_run_policy("leader")
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(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
# 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。
new_message=leader_input,
excluded_tools=policy.excluded_tools,
excluded_tools=leader_excluded,
skill_stop_names=effective_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
# 人工干预/恢复会重发 leader run 到同一总控 thread;抢占掉没结束的旧 run,避免 409。
multitask_strategy="rollback",
# 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。
files=files,
)
t_payload_built = time.perf_counter()
logger.info(
@@ -1532,6 +1548,7 @@ async def _special_run(
display_name: str = "",
no_file: bool = False,
peer_deliveries: list[dict[str, Any]] | None = None,
files: list[dict[str, Any]] | None = None,
) -> AsyncIterator[str | dict[str, Any]]:
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
@@ -1560,12 +1577,18 @@ async def _special_run(
else:
role = "seat"
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(
agent_name=agent_name,
model_name=model_name,
thread_id=thread_id,
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),
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
@@ -1575,6 +1598,8 @@ async def _special_run(
extra_context=(
{"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None
),
# 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。
files=files,
)
t_payload_built = time.perf_counter()
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()
new_message: str = str(payload.get("new_message") 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 用):前端在「派活后让总控判断继续/综合」的轮次置真,网关据此
# 额外把各已交付席位的完整交付正文注入总控输入,供其写最终方案。缺省 False(派活决策轮
# 只给摘要,保持上下文精简)。旧前端不传 → False,行为安全退化。
@@ -1741,6 +1771,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
seat_meta=seat_meta,
synthesis_mode=synthesis_mode,
suppress_pre_broadcast=suppress_pre_broadcast,
files=files,
)
else:
gen = _special_run(
@@ -1748,6 +1779,7 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
display_name=display_name,
no_file=no_file,
peer_deliveries=peer_deliveries,
files=files,
)
async for chunk in gen:
if isinstance(chunk, dict):