fix: polish position roundtable artifact preview

This commit is contained in:
董宏凯 committed 2026-07-22 18:41:46 +08:00
1 parent 7bd032ec77
commit 359bd28f2c
2 files changed
+238 -35

No files matched your search

@@ -2,6 +2,7 @@ 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";
@@ -22,6 +23,11 @@ import {
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
import { desensitize } from "@/lib/desensitize";
interface PositionNodeSwitchOption {
node: PositionRoundtableNode;
agentName?: string | null;
}
interface PositionNodeChatProps {
sessionId: string;
/** Parent task id: all node threads are indexed under this task. */
@@ -31,6 +37,8 @@ interface PositionNodeChatProps {
currentPositionId: string;
sessionStatus: "intent_pending" | "active" | "completed" | "archived";
agentName?: string | null;
positionNodeOptions?: PositionNodeSwitchOption[];
onSelectPositionNode?: (node: PositionRoundtableNode) => void;
onNodeUpdate: (node: PositionRoundtableNode) => void;
/** The workspace collapses the configuration panel on the first real turn. */
onConversationStart?: () => void;
@@ -91,6 +99,36 @@ function hasMarkdownArtifact(manifest: Array<Record<string, unknown>>): boolean
});
}
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:")) {
@@ -153,6 +191,8 @@ export function PositionNodeChat({
currentPositionId,
sessionStatus,
agentName,
positionNodeOptions,
onSelectPositionNode,
onNodeUpdate,
onConversationStart,
onPreviewArtifact,
@@ -161,6 +201,13 @@ export function PositionNodeChat({
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 artifactsCtx = useArtifactsOptional();
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
const boundThreadIdRef = useRef<string | null>(node.threadId);
@@ -284,6 +331,35 @@ export function PositionNodeChat({
const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
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 handleSwitchNode = useCallback(
(targetNode: PositionRoundtableNode) => {
if (targetNode.nodeKey === node.nodeKey) return;
if (thread.isLoading) {
toast.info("当前智能体正在生成,完成后可切换");
return;
}
onSelectPositionNode?.(targetNode);
},
[node.nodeKey, onSelectPositionNode, thread.isLoading],
);
useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread });
@@ -360,6 +436,7 @@ export function PositionNodeChat({
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"
? "该会话已归档,仅可查看历史对话和产物"
@@ -372,9 +449,9 @@ export function PositionNodeChat({
: "";
return (
<ThreadContext.Provider value={{ thread }}>
<ThreadContext.Provider value={threadContextValue}>
<div className="flex min-h-0 flex-1 flex-col">
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-slate-100 px-4 dark:border-[#2b8ffe]/20">
<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>
@@ -386,6 +463,32 @@ export function PositionNodeChat({
{readOnlyHint || `节点状态:${node.status}`}
</p>
</div>
{hasMultipleSwitchOptions && (
<div
className="ml-auto mr-12 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 xl:inline">
同岗位智能体
</span>
<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={{ width: 380, maxWidth: "34vw" }}
value={node.nodeKey}
options={switchSelectOptions}
onChange={(value) => {
const nextNode = switchOptions.find(
(option) => option.node.nodeKey === String(value),
)?.node;
if (nextNode) {
handleSwitchNode(nextNode);
}
}}
popupProps={{ overlayInnerStyle: { minWidth: 380 } }}
/>
</div>
)}
</header>
{nodeArtifacts.length > 0 && (
@@ -41,7 +41,7 @@ import { toast } from "sonner";
import { agentColor } from "@/components/workspace/agent-icon-color";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { useAgentModuleSidebarData } from "@/components/workspace/agents/agent-module-sidebar";
import { ArtifactFileDetail, useArtifactsOptional } from "@/components/workspace/artifacts";
import { ArtifactFileDetail, ArtifactFilePreview, useArtifactsOptional } from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context";
import {
Dialog,
@@ -55,6 +55,7 @@ import { useTheme } from "@/contexts/ThemeContext";
import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls";
import { getAPIClient } from "@/core/api/api-client";
import { useAgent } from "@/core/agents";
import { loadArtifactContent } from "@/core/artifacts/loader";
import type { Agent } from "@/core/agents";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadState } from "@/core/threads/types";
@@ -123,6 +124,11 @@ type IntentEditDraft = {
constraints: string;
assumptions: string;
};
type SandboxThreadSnapshot = {
threadId: string;
thread: BaseStream<AgentThreadState>;
signature: string;
};
function sandboxThreadSignature(thread: BaseStream<AgentThreadState>): string {
const messages = thread.messages ?? [];
@@ -439,11 +445,10 @@ export default function PositionRoundtablePage({
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
const closedSandboxKeyRef = useRef<string | null>(null);
const [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState<{
threadId: string;
thread: BaseStream<AgentThreadState>;
signature: string;
} | null>(null);
const [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState<SandboxThreadSnapshot | null>(null);
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
const pendingSandboxThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
const sandboxSnapshotFrameRef = useRef<number | null>(null);
const [flowDetailItem, setFlowDetailItem] = useState<ResultItem | null>(null);
const [taskEditorOpen, setTaskEditorOpen] = useState(false);
const [taskDraft, setTaskDraft] = useState<PositionTask>(fallbackTask);
@@ -463,6 +468,41 @@ export default function PositionRoundtablePage({
useEffect(() => {
sandboxArtifactRef.current = sandboxArtifact;
}, [sandboxArtifact]);
const flushSandboxThreadSnapshot = useCallback(() => {
sandboxSnapshotFrameRef.current = null;
const pending = pendingSandboxThreadSnapshotRef.current;
pendingSandboxThreadSnapshotRef.current = null;
if (!pending) return;
const currentArtifact = sandboxArtifactRef.current;
if (!currentArtifact || currentArtifact.threadId !== pending.threadId) {
return;
}
setSandboxThreadSnapshot((current) =>
current?.threadId === pending.threadId && current.signature === pending.signature
? current
: pending,
);
}, []);
const queueSandboxThreadSnapshot = useCallback(
(snapshot: SandboxThreadSnapshot) => {
pendingSandboxThreadSnapshotRef.current = snapshot;
if (sandboxSnapshotFrameRef.current !== null) return;
if (typeof window === "undefined") {
flushSandboxThreadSnapshot();
return;
}
sandboxSnapshotFrameRef.current = window.requestAnimationFrame(flushSandboxThreadSnapshot);
},
[flushSandboxThreadSnapshot],
);
useEffect(
() => () => {
if (sandboxSnapshotFrameRef.current !== null) {
window.cancelAnimationFrame(sandboxSnapshotFrameRef.current);
}
},
[],
);
const frozenIntent = useMemo(
() => parseIntentSummary(positionSession?.intentSnapshot),
[positionSession?.intentSnapshot],
@@ -706,6 +746,14 @@ export default function PositionRoundtablePage({
if (!activeNode) return null;
return activeChain?.seats.find((seat) => seat.agent_id === activeNode.agentId)?.name ?? null;
}, [activeChain?.seats, activeNode]);
const currentPositionNodeOptions = useMemo(
() =>
currentPositionNodes.map((node) => ({
node,
agentName: activeChain?.seats.find((seat) => seat.agent_id === node.agentId)?.name ?? null,
})),
[activeChain?.seats, currentPositionNodes],
);
useEffect(() => {
const nextNodeKey = activeNode?.nodeKey ?? null;
if (activeNodeKey !== nextNodeKey) setActiveNodeKey(nextNodeKey);
@@ -1060,6 +1108,9 @@ export default function PositionRoundtablePage({
? current
: artifact,
);
setSandboxThreadSnapshot((current) =>
current?.threadId === artifact.threadId ? current : null,
);
const ctx = artifactsCtxRef.current;
if (ctx?.selectedArtifact !== artifact.path) {
@@ -1069,8 +1120,12 @@ export default function PositionRoundtablePage({
if (!ctx?.open) {
ctx?.setOpen(true);
}
const latestSnapshot = latestNodeThreadSnapshotRef.current;
if (latestSnapshot?.threadId === artifact.threadId) {
queueSandboxThreadSnapshot(latestSnapshot);
}
setRightPanelOpen(false);
}, []);
}, [queueSandboxThreadSnapshot]);
const handlePreviewFlowArtifact = useCallback(
(item: ResultItem) => {
const threadId = item.artifactThreadId || item.node?.threadId;
@@ -1124,9 +1179,7 @@ export default function PositionRoundtablePage({
const handleStartedIntentSend = useCallback(() => {
toast.info("业务链已启动,任务意图已冻结;如需重新识别请新建会话");
}, []);
const sandboxOpen = Boolean(
sandboxArtifact && artifactsCtx?.open && artifactsCtx.selectedArtifact,
);
const sandboxOpen = Boolean(sandboxArtifact && artifactsCtx);
// ArtifactFileDetail is shared with the normal LangGraph chat surface and
// therefore reads ThreadContext for streaming state. The position workspace
// renders its sandbox as a sibling of PositionNodeChat, so it is outside the
@@ -1154,17 +1207,22 @@ export default function PositionRoundtablePage({
: sandboxThreadStub,
[sandboxArtifact, sandboxThreadSnapshot, sandboxThreadStub],
);
const sandboxThreadContextValue = useMemo(
() => ({ thread: sandboxThread, isMock: false }),
[sandboxThread],
);
const handleNodeThreadSnapshot = useCallback(
(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) => {
const signature = sandboxThreadSignature(snapshot.thread);
setSandboxThreadSnapshot((current) => {
const sameThreadData =
current?.threadId === snapshot.threadId &&
current.signature === signature;
return sameThreadData ? current : { ...snapshot, signature };
});
const nextSnapshot: SandboxThreadSnapshot = { ...snapshot, signature };
latestNodeThreadSnapshotRef.current = nextSnapshot;
const currentArtifact = sandboxArtifactRef.current;
if (!currentArtifact || currentArtifact.threadId !== snapshot.threadId) {
return;
}
queueSandboxThreadSnapshot(nextSnapshot);
},
[],
[queueSandboxThreadSnapshot],
);
const closeSandbox = useCallback(() => {
const current = sandboxArtifactRef.current;
@@ -1175,9 +1233,18 @@ export default function PositionRoundtablePage({
if (current || ctx?.open) {
ctx?.setOpen(false);
}
pendingSandboxThreadSnapshotRef.current = null;
setSandboxArtifact(null);
setSandboxThreadSnapshot(null);
}, []);
const handleSelectPositionNode = useCallback(
(nextNode: PositionRoundtableNode) => {
closeSandbox();
setActiveNodeKey(nextNode.nodeKey);
setSelectedAgentId(nextNode.agentId);
},
[closeSandbox],
);
const renderIntentChatPanel = (chainStarted: boolean) => (
<div className="contents">
{intent.intentInitError && (
@@ -1414,6 +1481,8 @@ export default function PositionRoundtablePage({
currentPositionId={positionId}
sessionStatus={positionSession.status}
agentName={activeNodeAgentName}
positionNodeOptions={currentPositionNodeOptions}
onSelectPositionNode={handleSelectPositionNode}
onNodeUpdate={handleNodeUpdate}
onConversationStart={() => setRightPanelOpen(false)}
onPreviewArtifact={handlePreviewArtifact}
@@ -1518,7 +1587,7 @@ export default function PositionRoundtablePage({
</div>
</TDrawer>
)}
{sandboxOpen && sandboxArtifact && artifactsCtx?.selectedArtifact && (
{sandboxOpen && sandboxArtifact && artifactsCtx && (
<aside className="flex w-[min(48vw,720px)] min-w-[440px] shrink-0 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:shadow-none">
<header className="hidden h-12 shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-4 dark:border-[#2b8ffe]/25">
<div className="min-w-0">
@@ -1537,7 +1606,7 @@ export default function PositionRoundtablePage({
title="关闭沙箱"
/>
</header>
<ThreadContext.Provider value={{ thread: sandboxThread, isMock: false }}>
<ThreadContext.Provider value={sandboxThreadContextValue}>
<ArtifactFileDetail
className="min-h-0 flex-1"
filepath={sandboxArtifact.path}
@@ -3901,7 +3970,6 @@ function DeliverableDetailDialog({
item,
onOpenChange,
onReject,
onPreviewArtifact,
onAskBuiltin,
askSubmitting,
showReject = true,
@@ -3920,6 +3988,32 @@ function DeliverableDetailDialog({
const status = item?.status ? nodeStatusMeta(item.status) : null;
const StatusIcon = status?.Icon;
const canReject = showReject && item?.node ? canRejectNodeDelivery(item.node) : false;
const artifactThreadId = item?.artifactThreadId || item?.node?.threadId || null;
const artifactPathValue = item?.artifactPath || "";
const artifactContentQuery = useQuery({
queryKey: [
"position-roundtable",
"deliverable-artifact-content",
artifactThreadId,
artifactPathValue,
],
queryFn: async () => {
if (!artifactThreadId || !artifactPathValue) return "";
const result = await loadArtifactContent({
filepath: artifactPathValue,
threadId: artifactThreadId,
isMock: false,
});
return result.content;
},
enabled: Boolean(item && artifactThreadId && artifactPathValue),
staleTime: 30_000,
});
const artifactContent =
typeof artifactContentQuery.data === "string" && artifactContentQuery.data.trim()
? artifactContentQuery.data
: null;
const displayedContent = artifactContent || item?.summary || "暂无产物正文";
const builtinKind =
showBuiltinAsk && onAskBuiltin && (item?.kind === "summary" || item?.kind === "action_plan")
? item.kind
@@ -3938,7 +4032,7 @@ function DeliverableDetailDialog({
return (
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[82vh] w-[720px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0">
<DialogContent className="flex max-h-[86vh] w-[900px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="flex items-center gap-2 text-base">
<FileOutput className="size-4 text-primary" />
@@ -4006,20 +4100,26 @@ function DeliverableDetailDialog({
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">
{item.kind === "artifact" ? "文件产物" : "产物内容"}
</p>
{item.artifactPath && (
<TButton
size="small"
variant="outline"
icon={<FileText className="size-3.5" />}
onClick={() => onPreviewArtifact(item)}
>
查看文件
</TButton>
</div>
<div className="h-[52vh] min-h-[320px] overflow-hidden rounded-lg border border-slate-100 bg-slate-50/60 dark:border-[#2b8ffe]/20 dark:bg-[#021d3d]/70">
{artifactContentQuery.isLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground dark:text-[#8aa0c4]">
<Loader2 className="size-4 animate-spin" />
正在加载 Markdown 文件内容…
</div>
) : (
<ArtifactFilePreview
content={displayedContent}
language="markdown"
threadId={artifactThreadId ?? ""}
/>
)}
</div>
<p className="whitespace-pre-wrap text-xs leading-6 text-slate-600 dark:text-[#c5cfe0]">
{desensitize(item.summary || "暂无产物正文")}
</p>
{artifactContentQuery.isError && (
<p className="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
Markdown 文件内容加载失败,当前展示的是节点摘要。
</p>
)}
</div>
{builtinKind && item.status === "done" && (
<div className="mt-4 rounded-xl border border-primary/20 bg-primary/[0.035] p-3 dark:border-[#2b8ffe]/35 dark:bg-[#032650]/75">