219 lines
8.3 KiB
TypeScript
219 lines
8.3 KiB
TypeScript
/**
|
|
* 模型生成过程的「步骤卡」 —— Step 1 / Step 2 共享。
|
|
*
|
|
* 视觉与交互对齐主聊天 `chats/new` 的 `MessageGroup`(`src/components/workspace/messages/message-group.tsx`):
|
|
* 1) 顶部 "查看其他 N 个步骤" 折叠按钮 —— 隐藏除最后一个工具调用之外的所有步骤
|
|
* 2) 最后一个工具调用步骤 **始终展开** 显示(图标 + 文案 + args 子节点)
|
|
* 3) 若末尾是 streaming/connecting 等非工具阶段,作为「正在思考」式标签放在卡底部
|
|
*
|
|
* 数据来源:`Step2Dialogue.steps[]` / `InteractiveMessage.steps[]` —— 由对应 hook
|
|
* 在 `onPhaseChange` 回调里累积。每条 `step = { id, key:"phase:toolName", label, ts, args? }`。
|
|
*
|
|
* 历史背景:之前的 `StepsCard` 用 "全收/全展" 的折叠模式 —— 流式时收起、点击全展开,
|
|
* 没法跟随主聊天的「凸显最后一个工具」体验。本组件改为「always-show-last + hide-prior」,
|
|
* 与主聊天观感一致。
|
|
*/
|
|
import { useState } from "react";
|
|
import { ChevronUp } from "lucide-react";
|
|
|
|
import {
|
|
ChainOfThought,
|
|
ChainOfThoughtContent,
|
|
ChainOfThoughtStep,
|
|
} from "@/components/ai-elements/chain-of-thought";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
getStepDisplay,
|
|
renderStepArgs,
|
|
} from "@/roundtable-planning/lib/step-display";
|
|
|
|
/** 单条步骤 —— 与 Step2Dialogue.steps / InteractiveMessage.steps 同形。 */
|
|
export interface BubbleStep {
|
|
id: string;
|
|
/** 形如 `tool_calling:web_search` / `streaming:` / `connecting:`。 */
|
|
key: string;
|
|
/** 原始 fallback 文案(getStepDisplay 在没有工具名时回退到它)。 */
|
|
label: string;
|
|
ts: string;
|
|
args?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface MessageStepsCardProps {
|
|
steps: BubbleStep[];
|
|
/** 是否流式中 —— 决定最后一步显示 `active` 还是 `complete`。 */
|
|
streaming?: boolean;
|
|
className?: string;
|
|
/** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */
|
|
onArtifactStepClick?: (step: BubbleStep) => void;
|
|
/** 把 agent_orchestration 派活的 agent id 解析成中文席位名(用于 chip 显示)。 */
|
|
resolveAgentName?: (idOrName: string) => string;
|
|
}
|
|
|
|
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
|
|
function isToolStep(step: BubbleStep): boolean {
|
|
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
|
|
}
|
|
|
|
export function MessageStepsCard({
|
|
steps,
|
|
streaming,
|
|
className,
|
|
onArtifactStepClick,
|
|
resolveAgentName,
|
|
}: MessageStepsCardProps) {
|
|
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
|
|
// `lastReasoningStep` 划分对齐。
|
|
const toolSteps = steps.filter(isToolStep);
|
|
const lastToolStep = toolSteps[toolSteps.length - 1];
|
|
const priorToolSteps =
|
|
lastToolStep && toolSteps.length > 1 ? toolSteps.slice(0, -1) : [];
|
|
|
|
// 末尾若是非工具步骤(streaming/connecting/tool_result),作为「思考中」尾标签。
|
|
// 找到最后一个工具步骤之后的所有步骤;若没有工具步骤,则全部都算思考。
|
|
const lastToolIdx = lastToolStep ? steps.lastIndexOf(lastToolStep) : -1;
|
|
const trailingReasoning = steps.slice(lastToolIdx + 1).filter((s) => !isToolStep(s));
|
|
const lastReasoning = trailingReasoning[trailingReasoning.length - 1];
|
|
|
|
const [showAbove, setShowAbove] = useState<boolean>(false);
|
|
|
|
// 没有任何步骤 —— 不渲染卡(由 caller 决定是否还要其他占位)。
|
|
if (steps.length === 0) return null;
|
|
|
|
// 只有一种「思考」尾标签、没有任何工具调用 —— 直接渲染一行小灯泡,不用大卡。
|
|
// 这样避免「调度中 / 已连接」这类瞬时阶段被搞成完整的折叠卡。
|
|
if (!lastToolStep && lastReasoning) {
|
|
const display = getStepDisplay(lastReasoning.key, lastReasoning.label, lastReasoning.args);
|
|
const Icon = display.icon;
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"roundtable-chat-message flex items-center gap-2 text-slate-500 py-0.5",
|
|
className,
|
|
)}
|
|
>
|
|
<Icon className="size-3.5 text-slate-400" />
|
|
<span>{display.label}</span>
|
|
<span className="roundtable-chat-meta text-slate-400 font-mono">{lastReasoning.ts}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ChainOfThought
|
|
className={cn("w-full gap-1 rounded-lg border border-slate-200 bg-slate-50/50 p-0.5", className)}
|
|
open={true}
|
|
>
|
|
{/* 顶部:折叠/展开「其他 N 个步骤」。仅当真有 prior 步骤时显示。 */}
|
|
{priorToolSteps.length > 0 && (
|
|
<Button
|
|
className="w-full h-auto items-start justify-start py-1.5 text-left"
|
|
variant="ghost"
|
|
onClick={() => setShowAbove((v) => !v)}
|
|
>
|
|
<ChainOfThoughtStep
|
|
label={
|
|
<span className="roundtable-chat-message opacity-60">
|
|
{showAbove ? "隐藏步骤" : `查看其他 ${priorToolSteps.length} 个步骤`}
|
|
</span>
|
|
}
|
|
icon={
|
|
<ChevronUp
|
|
className={cn(
|
|
"size-4 opacity-60 transition-transform duration-200",
|
|
showAbove ? "rotate-180" : "",
|
|
)}
|
|
/>
|
|
}
|
|
/>
|
|
</Button>
|
|
)}
|
|
|
|
{/* 中段:工具调用步骤。Last 工具始终展开;其它工具按 showAbove 控制。 */}
|
|
{lastToolStep && (
|
|
<ChainOfThoughtContent className="px-3 pb-2">
|
|
{showAbove &&
|
|
priorToolSteps.map((s) => (
|
|
<ToolStep
|
|
key={s.id}
|
|
step={s}
|
|
status="complete"
|
|
onArtifactStepClick={onArtifactStepClick}
|
|
resolveAgentName={resolveAgentName}
|
|
/>
|
|
))}
|
|
<ToolStep
|
|
step={lastToolStep}
|
|
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
|
|
onArtifactStepClick={onArtifactStepClick}
|
|
resolveAgentName={resolveAgentName}
|
|
/>
|
|
</ChainOfThoughtContent>
|
|
)}
|
|
|
|
{/* 底部:streaming 阶段的「正在思考」标签。 */}
|
|
{lastReasoning && (
|
|
<div className="px-3 pb-2">
|
|
<ReasoningStep step={lastReasoning} streaming={streaming === true} />
|
|
</div>
|
|
)}
|
|
</ChainOfThought>
|
|
);
|
|
}
|
|
|
|
function ToolStep({
|
|
step,
|
|
status,
|
|
onArtifactStepClick,
|
|
resolveAgentName,
|
|
}: {
|
|
step: BubbleStep;
|
|
status: "complete" | "active";
|
|
onArtifactStepClick?: (step: BubbleStep) => void;
|
|
resolveAgentName?: (idOrName: string) => string;
|
|
}) {
|
|
const display = getStepDisplay(step.key, step.label, step.args);
|
|
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
|
|
const [, toolName] = step.key.split(":");
|
|
// read_file 也可点 —— 验证类「查看文件…」步骤常出现在最后一条,点击后在沙箱里
|
|
// 查看该文件内容(走虚拟预览或 outputs HTTP)。主聊天只让 write 类可点,这里增强。
|
|
const isArtifactStep =
|
|
toolName === "write_file" ||
|
|
toolName === "str_replace" ||
|
|
toolName === "present_files" ||
|
|
toolName === "read_file";
|
|
|
|
return (
|
|
<ChainOfThoughtStep
|
|
icon={display.icon}
|
|
status={status}
|
|
className={isArtifactStep && onArtifactStepClick ? "cursor-pointer" : undefined}
|
|
onClick={
|
|
isArtifactStep && onArtifactStepClick
|
|
? () => onArtifactStepClick(step)
|
|
: undefined
|
|
}
|
|
label={
|
|
<span className="roundtable-chat-message text-slate-700">
|
|
<span className="font-medium">{display.label}</span>
|
|
<span className="roundtable-chat-meta ml-2 text-slate-400 font-mono">{step.ts}</span>
|
|
</span>
|
|
}
|
|
>
|
|
{argNode}
|
|
</ChainOfThoughtStep>
|
|
);
|
|
}
|
|
|
|
function ReasoningStep({ step, streaming }: { step: BubbleStep; streaming: boolean }) {
|
|
const display = getStepDisplay(step.key, step.label, step.args);
|
|
const Icon = display.icon;
|
|
return (
|
|
<div className="roundtable-chat-message flex items-center gap-2 text-slate-500">
|
|
<Icon className={cn("size-3.5", streaming ? "text-primary animate-pulse" : "text-slate-400")} />
|
|
<span>{display.label}</span>
|
|
<span className="roundtable-chat-meta text-slate-400 font-mono">{step.ts}</span>
|
|
</div>
|
|
);
|
|
}
|