576 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|