代码提交
This commit is contained in:
1 parent
742bbdf3ac
commit
920fde9cd0
5 files changed
+263
-43
No files matched your search
@@ -18,6 +18,7 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
Cpu,
|
||||
FileIcon,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
Pause,
|
||||
@@ -76,6 +77,8 @@ export interface Step2PanelProps {
|
||||
dialogue: Step2Dialogue,
|
||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||
) => void;
|
||||
/** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
// ─── Setters ──────────────────────────────────────────────────
|
||||
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -112,6 +115,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
activeArtifactThreadId,
|
||||
seatStubMessages,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
setIsRoundTableRunning,
|
||||
setClarificationDraft,
|
||||
setCustomAgentInput,
|
||||
@@ -231,6 +235,11 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) {
|
||||
openArtifactPreview(msg.agentThreadId, path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -377,9 +386,15 @@ interface Step2MessageProps {
|
||||
onArtifactStepClick?: (
|
||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||
) => void;
|
||||
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
|
||||
onFileCardClick?: (path: string) => void;
|
||||
}
|
||||
|
||||
function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) {
|
||||
function Step2Message({
|
||||
msg,
|
||||
onArtifactStepClick,
|
||||
onFileCardClick,
|
||||
}: Step2MessageProps) {
|
||||
const isInjected = msg.sender === "用户 (插话干预)";
|
||||
const isStreaming = msg.streaming === true;
|
||||
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
|
||||
@@ -388,6 +403,7 @@ function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) {
|
||||
const rawContent = msg.content ?? "";
|
||||
const { content, reasoning } = splitInlineReasoning(rawContent);
|
||||
const hasContent = content.length > 0;
|
||||
const presentedFiles = collectPresentedFiles(msg.steps);
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
@@ -417,6 +433,122 @@ function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) {
|
||||
<MarkdownContent>{content}</MarkdownContent>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</div>
|
||||
|
||||
{presentedFiles.length > 0 && (
|
||||
<PresentFilesRow
|
||||
files={presentedFiles}
|
||||
onClick={onFileCardClick}
|
||||
disabled={!msg.agentThreadId}
|
||||
/>
|
||||
)}
|
||||
</MessageBubble>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。
|
||||
* 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。
|
||||
*/
|
||||
function collectPresentedFiles(
|
||||
steps: Step2Dialogue["steps"],
|
||||
): string[] {
|
||||
if (!Array.isArray(steps) || steps.length === 0) return [];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const step of steps) {
|
||||
if (step.key !== "tool_calling:present_files") continue;
|
||||
const fps = step.args?.filepaths;
|
||||
if (!Array.isArray(fps)) continue;
|
||||
for (const fp of fps) {
|
||||
if (typeof fp !== "string" || !fp || seen.has(fp)) continue;
|
||||
seen.add(fp);
|
||||
out.push(fp);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function PresentFilesRow({
|
||||
files,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
files: string[];
|
||||
onClick?: (path: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{files.map((path) => (
|
||||
<PresentFileCard
|
||||
key={path}
|
||||
path={path}
|
||||
disabled={disabled || !onClick}
|
||||
onClick={() => onClick?.(path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FILE_EXT_LABELS: Record<string, string> = {
|
||||
md: "Markdown",
|
||||
json: "JSON",
|
||||
csv: "CSV",
|
||||
txt: "TXT",
|
||||
py: "Python",
|
||||
js: "JS",
|
||||
ts: "TS",
|
||||
tsx: "TSX",
|
||||
jsx: "JSX",
|
||||
html: "HTML",
|
||||
css: "CSS",
|
||||
xml: "XML",
|
||||
yaml: "YAML",
|
||||
yml: "YAML",
|
||||
pdf: "PDF",
|
||||
png: "PNG",
|
||||
jpg: "JPG",
|
||||
jpeg: "JPEG",
|
||||
svg: "SVG",
|
||||
zip: "ZIP",
|
||||
};
|
||||
|
||||
function PresentFileCard({
|
||||
path,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
path: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const filename = path.split("/").pop() ?? path;
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={disabled ? "席位线程未就绪,暂无法预览" : path}
|
||||
className="flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span
|
||||
className="truncate text-[12px] font-medium text-slate-700"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-slate-500">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">点击预览</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -91,6 +91,12 @@ export type Step2Snapshot = {
|
||||
hasConsensus: boolean;
|
||||
consensusPercentage: number;
|
||||
budgetLimit: number;
|
||||
/**
|
||||
* 每个 seat thread 的 stub ai messages —— 加载草稿后点击历史 write_file 步骤,
|
||||
* 沙箱要能复读 args.content。不存这部分 stub 的话,虚拟 URL 解析会拿不到内容。
|
||||
* 老草稿可能没有这字段,反序列化时按空对象兜底。
|
||||
*/
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -514,6 +520,39 @@ export function useStep2Orchestration(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* 兜底:从 LangGraph messages-tuple 帧里扫描完整 tool_calls,把 args 持续灌进 stub。
|
||||
* 与 `handleArtifactToolPhase` 相比:
|
||||
* - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开;
|
||||
* - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界,
|
||||
* 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。
|
||||
* 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。
|
||||
*/
|
||||
const handleUpstreamFrame = useCallback(
|
||||
(frame: unknown, seatThreadId: string | undefined) => {
|
||||
if (!seatThreadId) return;
|
||||
if (!Array.isArray(frame) || frame.length === 0) return;
|
||||
const chunk = frame[0];
|
||||
if (!chunk || typeof chunk !== "object") return;
|
||||
const obj = chunk as Record<string, unknown>;
|
||||
const toolCalls = obj.tool_calls;
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
|
||||
for (const tc of toolCalls) {
|
||||
if (!tc || typeof tc !== "object") continue;
|
||||
const tcObj = tc as Record<string, unknown>;
|
||||
const name = typeof tcObj.name === "string" ? tcObj.name : "";
|
||||
if (name !== "write_file" && name !== "str_replace") continue;
|
||||
const tcArgs = tcObj.args;
|
||||
if (!tcArgs || typeof tcArgs !== "object") continue;
|
||||
const argsObj = tcArgs as Record<string, unknown>;
|
||||
const path = argsObj.path;
|
||||
if (typeof path !== "string" || !path) continue;
|
||||
upsertSeatToolCall(seatThreadId, name, argsObj);
|
||||
}
|
||||
},
|
||||
[upsertSeatToolCall],
|
||||
);
|
||||
|
||||
// ─── Dialogue / Speaker helpers ────────────────────────────────────
|
||||
const appendDialogue = useCallback(
|
||||
(card: {
|
||||
@@ -890,6 +929,8 @@ export function useStep2Orchestration(
|
||||
seatThreadId,
|
||||
);
|
||||
},
|
||||
onUpstreamFrame: (frame) =>
|
||||
handleUpstreamFrame(frame, seatThreadId),
|
||||
});
|
||||
if (orchestrationCancelRef.current) {
|
||||
finalizeDialogue(
|
||||
@@ -958,6 +999,7 @@ export function useStep2Orchestration(
|
||||
clearAllStreamingSpeakers,
|
||||
triggerToast,
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
resolveSeatThreadId,
|
||||
],
|
||||
);
|
||||
@@ -1236,6 +1278,9 @@ export function useStep2Orchestration(
|
||||
setHasConsensus(snapshot.hasConsensus);
|
||||
setConsensusPercentage(snapshot.consensusPercentage);
|
||||
setBudgetLimit(snapshot.budgetLimit);
|
||||
const restoredStubs = snapshot.seatStubMessages ?? {};
|
||||
seatStubMessagesRef.current = restoredStubs;
|
||||
setSeatStubMessages(restoredStubs);
|
||||
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
||||
// 调用方需要的话再 resetInitGate()。
|
||||
step2InitRef.current = true;
|
||||
|
||||
@@ -194,6 +194,7 @@ export default function RoundtablePlanningPage() {
|
||||
hasConsensus: step2.hasConsensus,
|
||||
consensusPercentage: step2.consensusPercentage,
|
||||
budgetLimit: step2.budgetLimit,
|
||||
seatStubMessages: step2.seatStubMessages,
|
||||
}),
|
||||
hydrateStep1: step1.hydrateFromDraft,
|
||||
hydrateStep2: step2.hydrateFromDraft,
|
||||
@@ -494,6 +495,7 @@ export default function RoundtablePlanningPage() {
|
||||
activeArtifactThreadId={step2.activeArtifactThreadId}
|
||||
seatStubMessages={step2.seatStubMessages}
|
||||
openArtifactFromStep={step2.openArtifactFromStep}
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
setIsRoundTableRunning={step2.setIsRoundTableRunning}
|
||||
setClarificationDraft={step2.setClarificationDraft}
|
||||
setCustomAgentInput={step2.setCustomAgentInput}
|
||||
|
||||
@@ -35,6 +35,11 @@ export interface DraftStep2Snapshot {
|
||||
hasConsensus: boolean;
|
||||
consensusPercentage: number;
|
||||
budgetLimit: number;
|
||||
/**
|
||||
* Per-seat stub AI messages used by the sandbox `write-file:` virtual URL
|
||||
* loader. Older drafts won't have this — callers should default to `{}`.
|
||||
*/
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
}
|
||||
|
||||
export interface DraftRecord {
|
||||
|
||||
Reference in new issue
Block a user