Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/playground/src/components/ai-elements/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
className?: string;
/** The run ended before this call finished; show that instead of its live state. */
stopped?: boolean;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
| {
Expand Down Expand Up @@ -86,6 +88,7 @@ export const ToolHeader = ({
title,
type,
state,
stopped,
toolName,
...props
}: ToolHeaderProps) => {
Expand All @@ -103,7 +106,14 @@ export const ToolHeader = ({
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
{stopped ? (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
<CircleIcon className="size-4" />
Stopped
</Badge>
) : (
getStatusBadge(state)
)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
Expand Down
117 changes: 115 additions & 2 deletions apps/playground/src/features/playground/build-result.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import type { BuildFlowchartResult } from "@sketchi/diagram-agent";
import type { UIMessage } from "ai";
import { buildResultOf, deriveBuildState, type FlowchartToolPart } from "./build-result";
import {
buildResultOf,
deriveBuildState,
diagramToolCardStatus,
type DiagramToolPart,
} from "./build-result";
import { DEPLOY_PIPELINE_SCENE, DEPLOY_PIPELINE_SPEC } from "./deploy-pipeline-sample";

function pendingMessage(id: string): UIMessage {
Expand Down Expand Up @@ -40,7 +45,7 @@ describe("build result selectors", () => {
expect(state.ghostLabels).toEqual(DEPLOY_PIPELINE_SPEC.nodes.map((node) => node.label));
});
it("rejects malformed accepted payloads before selecting formats", () => {
const part: FlowchartToolPart = {
const part: DiagramToolPart = {
type: "tool-build_flowchart",
toolCallId: "bad",
state: "output-available",
Expand Down Expand Up @@ -131,4 +136,112 @@ describe("build result selectors", () => {
}),
).toMatchObject({ ok: false, status: "storage_failed" });
});

it("reads sequence build parts: participant ghosts while drawing, then the accepted scene", () => {
const spec = {
title: "Checkout",
participants: [
{ id: "browser", label: "Browser" },
{ id: "api", label: "API" },
],
messages: [{ source: "browser", target: "api", label: "Checkout" }],
};
const drawing: UIMessage = {
id: "drawing",
role: "assistant",
parts: [
{
type: "tool-build_sequence_diagram",
toolCallId: "sequence",
state: "input-available",
input: { spec },
},
],
};
expect(deriveBuildState([drawing], true).ghostLabels).toEqual(["Browser", "API"]);

const accepted: UIMessage = {
id: "accepted",
role: "assistant",
parts: [
{
type: "tool-build_sequence_diagram",
toolCallId: "sequence",
state: "output-available",
input: { spec },
output: {
ok: true,
status: "accepted",
buildId: "build",
issues: [],
normalizedSpec: {
id: "checkout",
title: "Checkout",
participants: spec.participants,
messages: [{ id: "m1", source: "browser", target: "api", label: "Checkout" }],
style: { accentColor: "#8f707f", backgroundColor: "#fffdf8" },
},
quality: {
accepted: true,
checks: [],
score: 10,
threshold: 8,
summary: { nodeCount: 2, edgeCount: 1 },
},
artifact: {
artifactId: "sequence-artifact",
diagramId: "checkout",
formats: [
{ format: "scene", mimeType: "application/json", inline: DEPLOY_PIPELINE_SCENE },
{ format: "excalidraw", mimeType: "application/json" },
],
},
},
},
],
};
const state = deriveBuildState([accepted], false);
expect(state.buildMode).toBe(true);
expect(state.acceptedResult?.ok).toBe(true);
expect(state.scene).toEqual(DEPLOY_PIPELINE_SCENE);
expect(state.artifact?.viewUrl).toBe("/artifacts/sequence-artifact");
expect(
buildResultOf({
type: "tool-build_sequence_diagram",
toolCallId: "rejected",
state: "output-available",
output: { ok: false, status: "invalid_sequence", issues: [] },
}),
).toMatchObject({ ok: false, status: "invalid_sequence" });
});

it("titles a call left waiting after Stop as stopped, for both families", () => {
for (const [type, family] of [
["tool-build_flowchart", "flowchart"],
["tool-build_sequence_diagram", "sequence diagram"],
] as const) {
for (const state of ["input-streaming", "input-available"] as const) {
const part: DiagramToolPart = { type, toolCallId: "call", state, input: {} };
expect(diagramToolCardStatus(part, false)).toEqual({
stopped: true,
title: `Stopped drawing your ${family}`,
});
expect(diagramToolCardStatus(part, true)).toEqual({
stopped: false,
title: `${state === "input-streaming" ? "Drawing" : "Checking"} your ${family}`,
});
}
}
expect(
diagramToolCardStatus(
{
type: "tool-build_sequence_diagram",
toolCallId: "done",
state: "output-available",
output: { ok: false, status: "invalid_sequence", issues: [] },
},
false,
),
).toEqual({ stopped: false, title: "Diagram needs changes" });
});
});
110 changes: 89 additions & 21 deletions apps/playground/src/features/playground/build-result.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {
BuildFlowchartRequestSchema,
BuildFlowchartResultSchema,
BuildSequenceDiagramResultSchema,
BuildSequenceDiagramToolInputSchema,
RenderedDiagramSceneSchema,
type BuildFlowchartResult,
type BuildSequenceDiagramResult,
} from "@sketchi/diagram-agent";
import type { RenderedDiagramScene } from "@sketchi/diagram-renderer";
import { Result, Schema } from "effect";
Expand All @@ -11,28 +14,96 @@ import type { ReadyPlaygroundArtifact } from "./surface";

type MessagePart = UIMessage["parts"][number];

export interface FlowchartToolPart {
type: "tool-build_flowchart";
/** A canonical build result from any Studio build tool. */
export type StudioBuildResult = BuildFlowchartResult | BuildSequenceDiagramResult;

const BUILD_TOOLS = {
"tool-build_flowchart": {
family: "flowchart",
decodeResult: Schema.decodeUnknownResult(BuildFlowchartResultSchema),
labels: (input: unknown) => {
const decoded = Schema.decodeUnknownResult(BuildFlowchartRequestSchema, {
errors: "all",
reportInput: true,
})(input);
return Result.isSuccess(decoded) ? decoded.success.spec.nodes.map((node) => node.label) : [];
},
},
"tool-build_sequence_diagram": {
family: "sequence",
decodeResult: Schema.decodeUnknownResult(BuildSequenceDiagramResultSchema),
labels: (input: unknown) => {
const decoded = Schema.decodeUnknownResult(BuildSequenceDiagramToolInputSchema, {
errors: "all",
reportInput: true,
})(input);
return Result.isSuccess(decoded)
? decoded.success.spec.participants.map((participant) => participant.label)
: [];
},
},
} as const;

export type DiagramToolPartType = keyof typeof BUILD_TOOLS;
export type DiagramToolFamily = (typeof BUILD_TOOLS)[DiagramToolPartType]["family"];

export interface DiagramToolPart {
type: DiagramToolPartType;
toolCallId: string;
state: "input-streaming" | "input-available" | "output-available" | "output-error";
input?: unknown;
output?: unknown;
errorText?: string;
}

export function isFlowchartToolPart(part: MessagePart): part is FlowchartToolPart & MessagePart {
return part.type === "tool-build_flowchart";
export function isDiagramToolPart(part: MessagePart): part is DiagramToolPart & MessagePart {
return Object.hasOwn(BUILD_TOOLS, part.type);
}

export function diagramToolFamily(part: DiagramToolPart): DiagramToolFamily {
return BUILD_TOOLS[part.type].family;
}

const decodeBuildResult = Schema.decodeUnknownResult(BuildFlowchartResultSchema);
const FAMILY_NAMES: Readonly<Record<DiagramToolFamily, string>> = {
flowchart: "flowchart",
sequence: "sequence diagram",
};

export function buildResultOf(part: FlowchartToolPart): BuildFlowchartResult | undefined {
/**
* The tool card heading. A call still waiting for input when its run is no
* longer active was stopped, and never finishes drawing.
*/
export function diagramToolCardStatus(
part: DiagramToolPart,
active: boolean,
): { readonly stopped: boolean; readonly title: string } {
const family = FAMILY_NAMES[diagramToolFamily(part)];
if (part.state === "input-streaming" || part.state === "input-available") {
if (!active) return { stopped: true, title: `Stopped drawing your ${family}` };
return {
stopped: false,
title:
part.state === "input-streaming" ? `Drawing your ${family}` : `Checking your ${family}`,
};
}
if (part.state === "output-error") {
return { stopped: false, title: "Couldn’t finish the diagram" };
}
return {
stopped: false,
title: buildResultOf(part)?.ok ? "Diagram ready" : "Diagram needs changes",
};
}

export function buildResultOf(part: DiagramToolPart): StudioBuildResult | undefined {
if (part.state !== "output-available") return undefined;
const decoded = decodeBuildResult(part.output);
const decoded: Result.Result<StudioBuildResult, unknown> = BUILD_TOOLS[part.type].decodeResult(
part.output,
);
return Result.isSuccess(decoded) ? decoded.success : undefined;
}

export function artifactFromResponse(result: BuildFlowchartResult): ReadyPlaygroundArtifact | null {
export function artifactFromResponse(result: StudioBuildResult): ReadyPlaygroundArtifact | null {
if (!result.ok) {
return null;
}
Expand Down Expand Up @@ -64,7 +135,7 @@ function isRenderedDiagramScene(value: unknown): value is RenderedDiagramScene {
}

export function sceneFromResult(
result: BuildFlowchartResult | undefined,
result: StudioBuildResult | undefined,
): RenderedDiagramScene | null {
if (!result?.ok) {
return null;
Expand All @@ -74,9 +145,9 @@ export function sceneFromResult(
}

export interface PlaygroundBuildState {
activePart: FlowchartToolPart | undefined;
acceptedResult: BuildFlowchartResult | undefined;
displayResult: BuildFlowchartResult | undefined;
activePart: DiagramToolPart | undefined;
acceptedResult: StudioBuildResult | undefined;
displayResult: StudioBuildResult | undefined;
buildMode: boolean;
scene: RenderedDiagramScene | null;
artifact: ReadyPlaygroundArtifact | null;
Expand All @@ -87,23 +158,20 @@ export function deriveBuildState(
messages: readonly UIMessage[],
busy: boolean,
): PlaygroundBuildState {
const toolParts = messages.flatMap((message) => message.parts.filter(isFlowchartToolPart));
const toolParts = messages.flatMap((message) => message.parts.filter(isDiagramToolPart));
const results = toolParts.map(buildResultOf).filter((result) => result !== undefined);
const displayResult = results.at(-1);
const acceptedResult = [...results].reverse().find((result) => result.ok);
const latestAssistant = messages.findLast((message) => message.role === "assistant");
const activePart = busy
? latestAssistant?.parts
.filter(isFlowchartToolPart)
.filter(isDiagramToolPart)
.find((part) => part.state === "input-streaming" || part.state === "input-available")
: undefined;
const input = Schema.decodeUnknownResult(BuildFlowchartRequestSchema, {
errors: "all",
reportInput: true,
})(activePart?.input);
const ghostLabels = Result.isSuccess(input)
? input.success.spec.nodes
.map((node) => node.label.trim())
const ghostLabels = activePart
? BUILD_TOOLS[activePart.type]
.labels(activePart.input)
.map((label) => label.trim())
.filter((label) => label.length > 0)
.slice(0, 24)
: [];
Expand Down
Loading
Loading