Files
deerflow-code/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx
T

576 lines
22 KiB
TypeScript

import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bot, FileText, LockKeyhole } from "lucide-react";
import { toast } from "sonner";
import { Select as TSelect } from "tdesign-react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import {
bindPositionNodeThread,
completePositionNodeTurn,
getPositionNodeContext,
type PositionRoundtableNode,
} from "@/position-roundtable/api/sessions";
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
import { desensitize } from "@/lib/desensitize";
import {
PositionBuiltinAgentTabs,
type PositionBuiltinAgentKind,
type PositionBuiltinAgentSwitchOption,
} from "@/position-roundtable/components/PositionAgentSwitcher";
interface PositionNodeSwitchOption {
node: PositionRoundtableNode;
agentName?: string | null;
}
interface PositionNodeChatProps {
sessionId: string;
/** Parent task id: all node threads are indexed under this task. */
taskId: string;
chainId: string | null;
node: PositionRoundtableNode;
currentPositionId: string;
sessionStatus: "intent_pending" | "active" | "completed" | "archived";
agentName?: string | null;
positionNodeOptions?: PositionNodeSwitchOption[];
onSelectPositionNode?: (node: PositionRoundtableNode) => void;
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
onNodeUpdate: (node: PositionRoundtableNode) => void;
/** The workspace collapses the configuration panel on the first real turn. */
onConversationStart?: () => void;
onPreviewArtifact?: (artifact: {
threadId: string;
path: string;
name: string;
mimeType: string | null;
auto?: boolean;
}) => void;
onThreadSnapshot?: (snapshot: {
threadId: string;
thread: BaseStream<AgentThreadState>;
}) => void;
}
interface NodeArtifact {
name: string;
path: string;
mimeType: string | null;
}
function readNodeArtifacts(manifest: Array<Record<string, unknown>>): NodeArtifact[] {
return manifest.flatMap((artifact) => {
const path = typeof artifact.path === "string" ? artifact.path : "";
if (!path) return [];
const name =
typeof artifact.name === "string" && artifact.name.trim()
? artifact.name
: path.split("/").filter(Boolean).at(-1) || "未命名产物";
const mimeType =
typeof artifact.mime_type === "string"
? artifact.mime_type
: typeof artifact.mimeType === "string"
? artifact.mimeType
: null;
return [{ name, path, mimeType }];
});
}
function hasMarkdownArtifact(manifest: Array<Record<string, unknown>>): boolean {
return manifest.some((artifact) => {
const path = typeof artifact.path === "string" ? artifact.path.toLowerCase() : "";
const name = typeof artifact.name === "string" ? artifact.name.toLowerCase() : "";
const mime =
typeof artifact.mime_type === "string"
? artifact.mime_type.toLowerCase()
: typeof artifact.mimeType === "string"
? artifact.mimeType.toLowerCase()
: "";
return (
mime.includes("markdown") ||
path.endsWith(".md") ||
path.endsWith(".markdown") ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
});
}
function hasAnyArtifact(manifest: Array<Record<string, unknown>>): boolean {
return manifest.some((artifact) => {
const path = typeof artifact.path === "string" ? artifact.path.trim() : "";
const name = typeof artifact.name === "string" ? artifact.name.trim() : "";
return Boolean(path || name);
});
}
function nodeSwitchStatusLabel(node: PositionRoundtableNode): string {
if (node.invalidatedBy || node.status === "stale") return "需重做";
switch (node.status) {
case "running":
return "进行中";
case "done":
return "已完成";
case "rejected":
return "已驳回";
case "error":
return "异常";
case "locked":
return "未开始";
case "ready":
default:
return "待开始";
}
}
function artifactDisplayName(pathOrUrl: string): string {
let path = pathOrUrl;
if (pathOrUrl.startsWith("write-file:")) {
try {
path = decodeURIComponent(new URL(pathOrUrl).pathname);
} catch {
path = pathOrUrl;
}
}
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
}
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
return new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString();
}
function latestStreamingWriteFile(
messages: AgentThreadState["messages"],
): { path: string; name: string; mimeType: string | null } | null {
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex] as (typeof messages)[number] & {
id?: string;
tool_calls?: Array<{ id?: string; name?: string; args?: Record<string, unknown> }>;
};
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (toolCall?.name !== "write_file" || !toolCall.id) continue;
const rawPath = toolCall.args?.path;
if (typeof rawPath !== "string" || !rawPath.trim()) continue;
const content = toolCall.args?.content;
if (typeof content !== "string" || content.length === 0) continue;
const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id);
return {
path: virtualPath,
name: artifactDisplayName(rawPath),
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
};
}
}
return null;
}
/**
* Direct single-agent chat for one frozen business-chain node.
*
* It deliberately uses the regular `useThreadStream` + `MessageList` +
* `InputBox` stack. The position Session merely records which existing thread
* belongs to the node; it is not a second message persistence mechanism.
*/
export function PositionNodeChat({
sessionId,
taskId,
chainId,
node,
currentPositionId,
sessionStatus,
agentName,
positionNodeOptions,
onSelectPositionNode,
builtinAgentOptions,
onSelectBuiltinAgent,
onNodeUpdate,
onConversationStart,
onPreviewArtifact,
onThreadSnapshot,
}: PositionNodeChatProps) {
const [settings, setSettings] = useLocalSettings();
const { agent } = useAgent(node.agentId);
const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
const switchOptions = useMemo<PositionNodeSwitchOption[]>(
() =>
positionNodeOptions?.length
? positionNodeOptions
: [{ node, agentName }],
[agentName, node, positionNodeOptions],
);
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
const boundThreadIdRef = useRef<string | null>(node.threadId);
const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
const canWrite =
sessionStatus !== "archived" &&
node.positionId === currentPositionId &&
node.status !== "locked" &&
node.status !== "error" &&
!node.invalidatedBy;
useEffect(() => {
setBoundThreadId(node.threadId);
boundThreadIdRef.current = node.threadId;
}, [node.threadId]);
const bindCreatedThread = useCallback(
(threadId: string) => {
boundThreadIdRef.current = threadId;
setBoundThreadId(threadId);
void bindPositionNodeThread(sessionId, node.nodeKey, threadId)
.then(onNodeUpdate)
.catch((error) => {
toast.error(`节点对话绑定失败:${error instanceof Error ? error.message : String(error)}`);
});
},
[node.nodeKey, onNodeUpdate, sessionId],
);
const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId,
assistantId: node.agentId,
context: { ...settings.context, agent_id: node.agentId },
threadMetadata: {
thread_type: "position_roundtable",
// ``taskId`` is the shared task-workspace convention. The two
// position-specific identifiers retain the exact child node for audits
// and restore, even when one position owns more than one chain seat.
taskId,
position_roundtable_task_id: taskId,
position_task_id: `${taskId}:${node.positionId ?? "unassigned"}:${node.nodeKey}`,
position_session_id: sessionId,
position_node_id: node.id,
position_node_key: node.nodeKey,
position_id: node.positionId,
chain_id: chainId,
agent_id: node.agentId,
},
onStart: bindCreatedThread,
onFinish: (state) => {
const threadId = boundThreadIdRef.current;
const latestAi = [...(state.messages ?? [])]
.reverse()
.find((message) => message.type === "ai");
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
if (!threadId || !latestAnswer.trim()) return;
void (async () => {
let artifactManifest: Array<Record<string, unknown>> = [];
try {
const artifacts = await listSessionArtifacts(threadId);
artifactManifest = artifacts.files.map((file) => ({
name: file.name,
path: file.path,
mime_type: file.mimeType,
modified_at: file.modifiedAt,
size_bytes: file.sizeBytes,
}));
} catch {
// Artifact listing is best-effort: a valid text answer must still be
// persisted and unlock downstream work even if files cannot be read.
}
try {
const updated = await completePositionNodeTurn(sessionId, node.nodeKey, {
latestAnswer,
artifactManifest,
upstreamRevision: upstreamRevisionRef.current,
});
onNodeUpdate(updated);
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
}
} catch (error) {
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
}
})();
},
});
const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
const handleOpenMessageArtifact = useCallback(
(filepath: string) => {
onPreviewArtifact?.({
threadId: activeThreadId,
path: filepath,
name: filepath.split("/").filter(Boolean).at(-1) || "Markdown 报告",
mimeType: filepath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
});
},
[activeThreadId, onPreviewArtifact],
);
const hasMultipleSwitchOptions = switchOptions.length > 1;
const switchSelectOptions = useMemo(
() =>
switchOptions.map((option, index) => {
const optionNode = option.node;
const statusLabel = nodeSwitchStatusLabel(optionNode);
const optionName = desensitize(
option.agentName || optionNode.agentId || `智能体 ${index + 1}`,
);
const artifactText = hasAnyArtifact(optionNode.artifactManifest) ? " · 有产物" : "";
return { label: `${optionName} · ${statusLabel}${artifactText}`, value: optionNode.nodeKey };
}),
[switchOptions],
);
const switchSelectStyle = useMemo(() => ({ width: 380, maxWidth: "34vw" }), []);
const switchSelectPopupProps = useMemo(
() => ({ overlayInnerStyle: { minWidth: 380 } }),
[],
);
const handleSwitchNode = useCallback(
(targetNode: PositionRoundtableNode) => {
if (targetNode.nodeKey === node.nodeKey) return;
if (thread.isLoading) {
toast.info("当前智能体正在生成,完成后可切换");
return;
}
onSelectPositionNode?.(targetNode);
},
[node.nodeKey, onSelectPositionNode, thread.isLoading],
);
const handleSwitchSelectChange = useCallback(
(value: unknown) => {
const nextNode = switchOptions.find((option) => option.node.nodeKey === String(value))?.node;
if (nextNode) handleSwitchNode(nextNode);
},
[handleSwitchNode, switchOptions],
);
useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread });
}, [activeThreadId, onThreadSnapshot, thread]);
const streamingArtifact = useMemo(
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
[thread.isLoading, thread.messages],
);
useEffect(() => {
if (!thread.isLoading || !streamingArtifact) return;
const artifactKey = `${activeThreadId}:${streamingArtifact.path}`;
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
autoPreviewedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({
threadId: activeThreadId,
path: streamingArtifact.path,
name: streamingArtifact.name,
mimeType: streamingArtifact.mimeType,
auto: true,
});
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
// watching the actual stream id as well closes the last race where a page
// refresh could happen between creation and the async node-index write.
useEffect(() => {
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
bindCreatedThread(actualThreadId);
}, [actualThreadId, bindCreatedThread]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (!canWrite) {
toast.info(
sessionStatus === "archived"
? "该会话已归档,仅可查看历史记录"
: node.invalidatedBy
? "上游产物已被驳回或重做,当前节点需等待前序节点重新完成"
: node.status === "locked"
? "该节点尚未解锁"
: "当前岗位只能查看该节点对话",
);
return;
}
try {
onConversationStart?.();
const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey);
const upstreamRevision = taskExternalContext.upstream_revision;
upstreamRevisionRef.current =
upstreamRevision && typeof upstreamRevision === "object" && !Array.isArray(upstreamRevision)
? (upstreamRevision as Record<string, unknown>)
: null;
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
agent_id: node.agentId,
task_external_context: taskExternalContext,
});
} catch (error) {
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
}
},
[
canWrite,
node.agentId,
node.nodeKey,
node.status,
onConversationStart,
sendMessage,
sessionId,
sessionStatus,
],
);
const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
const displayThreadId = boundThreadId ?? `position-node-${node.id}`;
const threadContextValue = useMemo(() => ({ thread }), [thread]);
const readOnlyHint =
sessionStatus === "archived"
? "该会话已归档,仅可查看历史对话和产物"
: node.invalidatedBy
? "上游产物已变更,需等待前序节点重新完成后再重做"
: node.status === "locked"
? "等待上游岗位完成后解锁"
: node.positionId !== currentPositionId
? "当前岗位仅可查看该节点的对话和产物"
: "";
return (
<ThreadContext.Provider value={threadContextValue}>
<div className="flex min-h-0 flex-1 flex-col">
<header className="flex min-h-12 shrink-0 items-center gap-2 border-b border-slate-100 px-4 py-2 dark:border-[#2b8ffe]/20">
<span className="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
{node.status === "locked" ? <LockKeyhole className="size-3.5" /> : <Bot className="size-4" />}
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{agentDisplayName}
</h2>
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
{readOnlyHint || `节点状态:${node.status}`}
</p>
</div>
{hasMultipleSwitchOptions && (
<div
className="ml-auto flex min-w-0 shrink-0 items-center gap-2"
title={thread.isLoading ? "当前智能体正在生成,完成后可切换" : "切换同岗位智能体"}
>
<span className="hidden shrink-0 text-[10px] font-semibold text-muted-foreground 2xl:inline">
同岗位智能体
</span>
{thread.isLoading ? (
<div
className="flex h-9 shrink-0 items-center rounded-xl border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#082d5f] dark:text-[#e8edf5]"
style={switchSelectStyle}
>
{agentDisplayName} · 进行中
</div>
) : (
<TSelect
aria-label="同岗位智能体切换"
className="shrink-0 [&_.t-input]:!rounded-xl [&_.t-input]:!border-slate-200 [&_.t-input]:!bg-white [&_.t-input]:!text-xs [&_.t-input]:!font-semibold [&_.t-input]:!text-slate-700 [&_.t-input]:!shadow-sm dark:[&_.t-input]:!border-[#2b8ffe]/35 dark:[&_.t-input]:!bg-[#082d5f] dark:[&_.t-input]:!text-[#e8edf5]"
style={switchSelectStyle}
value={node.nodeKey}
options={switchSelectOptions}
onChange={handleSwitchSelectChange}
popupProps={switchSelectPopupProps}
/>
)}
</div>
)}
{builtinAgentOptions && onSelectBuiltinAgent && (
<PositionBuiltinAgentTabs
activeKind={null}
options={builtinAgentOptions}
isStreaming={thread.isLoading}
onSelect={onSelectBuiltinAgent}
/>
)}
</header>
{nodeArtifacts.length > 0 && (
<section className="shrink-0 border-b border-slate-100 bg-slate-50/70 px-4 py-2.5 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/50">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold text-slate-600 dark:text-[#c5cfe0]">
<FileText className="size-3.5 text-primary" />
节点产物
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
{nodeArtifacts.length}
</span>
</div>
<div className="flex max-h-20 flex-wrap gap-1.5 overflow-y-auto pr-1">
{nodeArtifacts.map((artifact) => (
<button
key={artifact.path}
type="button"
title={`预览 ${artifact.name}`}
onClick={() => {
if (boundThreadId && onPreviewArtifact) {
onPreviewArtifact({
threadId: boundThreadId,
path: artifact.path,
name: artifact.name,
mimeType: artifact.mimeType,
});
}
}}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
>
<FileText className="size-3 shrink-0" />
<span className="max-w-48 truncate">{artifact.name}</span>
</button>
))}
</div>
</section>
)}
{boundThreadId ? (
<MessageList
className="min-h-0 flex-1"
threadId={boundThreadId}
thread={thread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-8 text-center">
<span className="mb-3 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Bot className="size-5" />
</span>
<p className="text-sm font-semibold text-slate-800 dark:text-[#e8edf5]">
{canWrite ? "向该岗位智能体发起第一次问答" : "该节点尚无可查看的对话"}
</p>
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground dark:text-[#8aa0c4]">
{canWrite
? "系统会自动传入任务意图和已完成上游节点的问答结果。"
: readOnlyHint || "请选择已启动的岗位节点。"}
</p>
</div>
)}
<div className="shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<InputBox
className="w-full"
isNewThread={!boundThreadId}
threadId={displayThreadId}
assistantId={node.agentId}
status={status}
context={settings.context}
disabled={!canWrite}
hideAdminRecommendedQuestions
onContextChange={(context) => setSettings("context", context)}
onSubmit={(message) => void handleSubmit(message)}
onStop={() => void thread.stop()}
/>
</div>
</div>
</ThreadContext.Provider>
);
}