Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
205 lines
7.5 KiB
TypeScript
205 lines
7.5 KiB
TypeScript
import type { ReactElement } from "react";
|
|
import { useI18n } from "../i18n";
|
|
import { SqlViewer, type SqlBlock } from "./SqlViewer";
|
|
import { SchemaLinkingViewer, type SchemaLinking } from "./SchemaLinkingViewer";
|
|
import { MarkdownView } from "./MarkdownView";
|
|
import { CtePlanViewer } from "./CtePlanViewer";
|
|
import { CteResultViewer } from "./CteResultViewer";
|
|
import { PhaseSummaryViewer } from "./PhaseSummaryViewer";
|
|
import type { CtePlanV2, CteResultV2, PhaseSummaryV2 } from "./artifactV2";
|
|
|
|
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 toCtePlanV2(raw: unknown): CtePlanV2 | null {
|
|
const rec = asRecord(raw);
|
|
if (!rec || rec.schema_version !== 2 || !Array.isArray(rec.ctes)) return null;
|
|
return rec as unknown as CtePlanV2;
|
|
}
|
|
|
|
function toCteResultV2(raw: unknown): CteResultV2 | null {
|
|
const rec = asRecord(raw);
|
|
if (!rec || rec.schema_version !== 2 || typeof rec.sql !== "string" || typeof rec.name !== "string") {
|
|
return null;
|
|
}
|
|
return rec as unknown as CteResultV2;
|
|
}
|
|
|
|
function toPhaseSummaryV2(raw: unknown): PhaseSummaryV2 | null {
|
|
const rec = asRecord(raw);
|
|
if (!rec || rec.schema_version !== 2) return null;
|
|
return rec as unknown as PhaseSummaryV2;
|
|
}
|
|
|
|
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 {
|
|
const { t } = useI18n();
|
|
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>);
|
|
if (entries.length === 0) {
|
|
return <p className="text-sm text-muted-foreground">{t("No artifact content available.")}</p>;
|
|
}
|
|
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 === "cte_result") {
|
|
const resultV2 = toCteResultV2(data);
|
|
if (resultV2) return <CteResultViewer result={resultV2} />;
|
|
}
|
|
if (kind === "sql" || kind === "cte_result") {
|
|
const blocks = toSqlBlocks(data);
|
|
if (blocks) return <SqlViewer blocks={blocks} />;
|
|
}
|
|
if (kind === "cte_plan") {
|
|
const planV2 = toCtePlanV2(data);
|
|
if (planV2) return <CtePlanViewer plan={planV2} />;
|
|
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 === "phase") {
|
|
const phaseV2 = toPhaseSummaryV2(data);
|
|
if (phaseV2) return <PhaseSummaryViewer phase={phaseV2} />;
|
|
}
|
|
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} />;
|
|
}
|