- ArtifactGateWidget: gate modal 90%→70% of viewport, larger heading, more padding. - ArtifactView StructuredValue: depth-aware hierarchy (top-level hairline dividers, warm bg-muted card surfaces + shadow for object-array items). - index.css: .thot-label micro-labels switched to the mono register (SF Mono) with tightened tracking, distinguishing labels/meta from sans body prose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
166 lines
6.1 KiB
TypeScript
166 lines
6.1 KiB
TypeScript
import type { ReactElement } from "react";
|
|
import { SqlViewer, type SqlBlock } from "./SqlViewer";
|
|
import { SchemaLinkingViewer, type SchemaLinking } from "./SchemaLinkingViewer";
|
|
import { MarkdownView } from "./MarkdownView";
|
|
|
|
type ArtifactData = { kind: string; data?: unknown; content?: unknown; [k: string]: unknown };
|
|
|
|
function asRecord(v: unknown): Record<string, unknown> | null {
|
|
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
}
|
|
|
|
// The payload lives in `data` (harness contract); fall back to `content`, then the
|
|
// artifact object itself, so older/other producers still render something.
|
|
function payload(artifact: ArtifactData): unknown {
|
|
if (artifact.data !== undefined) return artifact.data;
|
|
if (artifact.content !== undefined) return artifact.content;
|
|
return artifact;
|
|
}
|
|
|
|
function toSchemaLinking(raw: unknown): SchemaLinking | null {
|
|
let obj: unknown = raw;
|
|
if (typeof raw === "string") {
|
|
try { obj = JSON.parse(raw); } catch { return null; }
|
|
}
|
|
const rec = asRecord(obj);
|
|
if (!rec || !Array.isArray(rec.candidates)) return null;
|
|
return {
|
|
candidates: rec.candidates as SchemaLinking["candidates"],
|
|
joins: Array.isArray(rec.joins) ? (rec.joins as SchemaLinking["joins"]) : [],
|
|
excluded: Array.isArray(rec.excluded) ? (rec.excluded as SchemaLinking["excluded"]) : [],
|
|
open_questions: Array.isArray(rec.open_questions) ? (rec.open_questions as string[]) : [],
|
|
question: typeof rec.question === "string" ? rec.question : undefined,
|
|
};
|
|
}
|
|
|
|
function toSqlBlocks(raw: unknown): SqlBlock[] | null {
|
|
if (typeof raw === "string") return [{ name: "SQL", sql: raw }];
|
|
const rec = asRecord(raw);
|
|
if (!rec) return null;
|
|
if (typeof rec.sql === "string") return [{ name: "SQL", sql: rec.sql }];
|
|
if (typeof rec.content === "string") return [{ name: "SQL", sql: rec.content }];
|
|
if (Array.isArray(rec.ctes)) {
|
|
const blocks = rec.ctes
|
|
.map((c) => asRecord(c))
|
|
.filter((c): c is Record<string, unknown> => !!c && typeof c.sql === "string")
|
|
.map((c) => ({ name: typeof c.name === "string" ? c.name : "cte", sql: c.sql as string }));
|
|
if (typeof rec.final === "string") blocks.push({ name: "final", sql: rec.final });
|
|
return blocks.length ? blocks : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function toMarkdown(raw: unknown): string | null {
|
|
if (typeof raw === "string") return raw;
|
|
const rec = asRecord(raw);
|
|
if (!rec) return null;
|
|
for (const k of ["markdown", "question", "text"]) {
|
|
if (typeof rec[k] === "string") return rec[k] as string;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function cteNames(raw: unknown): string[] | null {
|
|
if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) return raw as string[];
|
|
const rec = asRecord(raw);
|
|
if (rec && Array.isArray(rec.names) && rec.names.every((x) => typeof x === "string")) {
|
|
return rec.names as string[];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// "recap" -> "Recap", "chiarimenti_risolti" -> "Chiarimenti risolti"
|
|
function humanizeKey(k: string): string {
|
|
const spaced = k.replace(/[_-]+/g, " ").trim();
|
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
}
|
|
|
|
/** Renders any artifact payload as readable text — NEVER raw JSON. Objects become
|
|
* labelled sections, arrays become bullet lists, primitives become paragraphs;
|
|
* strings render as markdown. Nested structures recurse. `depth` drives the
|
|
* visual hierarchy: top-level sections are split by hairline rules, nested fields
|
|
* sit tighter, and object-arrays become grouped cards. */
|
|
function StructuredValue({ value, depth = 0 }: { value: unknown; depth?: number }): ReactElement {
|
|
if (value === null || value === undefined || value === "") {
|
|
return <p className="text-sm text-muted-foreground">—</p>;
|
|
}
|
|
if (typeof value === "string") return <MarkdownView source={value} />;
|
|
if (typeof value === "number" || typeof value === "boolean") {
|
|
return <p className="text-sm leading-relaxed text-foreground/90">{String(value)}</p>;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
const allPrimitive = value.every((v) => v === null || typeof v !== "object");
|
|
if (allPrimitive) {
|
|
return (
|
|
<ul className="list-disc space-y-1 pl-5 text-sm leading-relaxed text-foreground/90 marker:text-muted-foreground">
|
|
{value.map((item, i) => <li key={i}>{String(item)}</li>)}
|
|
</ul>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{value.map((item, i) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-xl border border-border/70 bg-muted/50 p-4 shadow-xs"
|
|
>
|
|
<StructuredValue value={item} depth={depth + 1} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
return (
|
|
<div className="flex flex-col">
|
|
{entries.map(([k, v], i) => (
|
|
<section
|
|
key={k}
|
|
className={
|
|
i === 0
|
|
? undefined
|
|
: depth === 0
|
|
? "mt-5 border-t border-border/50 pt-5"
|
|
: "mt-3"
|
|
}
|
|
>
|
|
<h4 className="thot-label mb-1.5">{humanizeKey(k)}</h4>
|
|
<StructuredValue value={v} depth={depth + 1} />
|
|
</section>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ArtifactView({ artifact }: { artifact: ArtifactData }): ReactElement {
|
|
const kind = artifact.kind ?? "";
|
|
const data = payload(artifact);
|
|
|
|
if (kind === "schema_linking") {
|
|
const linking = toSchemaLinking(data);
|
|
if (linking) return <SchemaLinkingViewer linking={linking} />;
|
|
}
|
|
if (kind === "sql" || kind === "cte_result") {
|
|
const blocks = toSqlBlocks(data);
|
|
if (blocks) return <SqlViewer blocks={blocks} />;
|
|
}
|
|
if (kind === "cte_plan") {
|
|
const names = cteNames(data);
|
|
if (names) {
|
|
return (
|
|
<ol className="list-decimal pl-6 text-sm">
|
|
{names.map((n, i) => (
|
|
<li key={`${n}-${i}`} className="font-mono">{n}</li>
|
|
))}
|
|
</ol>
|
|
);
|
|
}
|
|
}
|
|
if (kind === "question" || kind === "phase") {
|
|
const md = toMarkdown(data);
|
|
if (md !== null) return <MarkdownView source={md} />;
|
|
}
|
|
// Fallback for any other kind / shape: formatted text, never raw JSON.
|
|
return <StructuredValue value={data} />;
|
|
}
|