/* global React, lucide */
const { useState, useEffect, useMemo, useRef, useCallback } = React;
// ─── parsing helpers ────────────────────────────────────────────
function extractBlock(text, tag) {
if (!text) return null;
const re = new RegExp(`<${tag}>\\s*([\\s\\S]*?)\\s*${tag}>`, 'i');
const m = text.match(re);
return m ? m[1].trim() : null;
}
function parseModelResponse(content) {
const think = extractBlock(content, 'think');
const dagText = extractBlock(content, 'dag');
const code = extractBlock(content, 'code');
let dag = null;
if (dagText) {
try { dag = JSON.parse(dagText); } catch (e) {}
}
return { think, dag, code, hasAll: !!(think && dagText && code) };
}
// ─── tiny SQL highlighter ───────────────────────────────────────
function highlightSQL(sql) {
if (!sql) return [];
const KW = /\b(WITH|AS|SELECT|FROM|WHERE|JOIN|INNER|LEFT|RIGHT|OUTER|ON|GROUP|BY|ORDER|LIMIT|HAVING|UNION|DISTINCT|AND|OR|NOT|IS|NULL|CASE|WHEN|THEN|ELSE|END|IN|EXISTS|COUNT|SUM|AVG|MIN|MAX|CAST|INTEGER|REAL|TEXT|DESC|ASC|EXCEPT|INTERSECT|ALL)\b/gi;
const lines = sql.split('\n');
return lines.map((line, i) => {
const isComment = /^\s*--/.test(line);
if (isComment) {
return
{line || ' '}
;
}
// Tokenize
const parts = [];
let rest = line;
let safety = 0;
while (rest.length && safety++ < 200) {
// strings
let m = rest.match(/^'([^']*)'/);
if (m) { parts.push({t:'str', v:`'${m[1]}'`}); rest = rest.slice(m[0].length); continue; }
// numbers
m = rest.match(/^\b(\d+(?:\.\d+)?)\b/);
if (m) { parts.push({t:'num', v:m[0]}); rest = rest.slice(m[0].length); continue; }
// keyword
m = rest.match(KW);
if (m && rest.indexOf(m[0]) === 0) { parts.push({t:'kw', v:m[0]}); rest = rest.slice(m[0].length); continue; }
// word
m = rest.match(/^[A-Za-z_][\w.]*/);
if (m) { parts.push({t:'id', v:m[0]}); rest = rest.slice(m[0].length); continue; }
// single
parts.push({t:'p', v: rest[0]}); rest = rest.slice(1);
}
return (
{parts.length ? parts.map((p, j) => p.t === 'p' || p.t === 'id'
? {p.v}
: {p.v}) : ' '}
);
});
}
// ─── think text mini-format (markdown-lite for **bold**) ────────
function formatThink(t) {
if (!t) return null;
// Convert **x** to x, `x` to x (re-purpose)
const parts = [];
const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
let last = 0;
let m;
let key = 0;
while ((m = re.exec(t)) !== null) {
if (m.index > last) parts.push(t.slice(last, m.index));
if (m[0].startsWith('**')) parts.push({m[0].slice(2, -2)});
else parts.push({m[0].slice(1, -1)});
last = m.index + m[0].length;
}
if (last < t.length) parts.push(t.slice(last));
return parts;
}
// ─── DAG layout (simple longest-path layered) ───────────────────
function layoutDAG(dag) {
if (!dag || !dag.nodes || !dag.edges) return null;
const nodes = dag.nodes.map(n => ({ ...n }));
const edges = dag.edges.map(e => ({ from: e.from ?? e[0], to: e.to ?? e[1] }));
const byId = new Map(nodes.map(n => [n.id, n]));
// longest path layering
const incoming = new Map(nodes.map(n => [n.id, 0]));
edges.forEach(e => incoming.set(e.to, (incoming.get(e.to) || 0) + 1));
const layer = new Map();
// BFS-ish
const queue = nodes.filter(n => (incoming.get(n.id) || 0) === 0).map(n => n.id);
queue.forEach(id => layer.set(id, 0));
// Iteratively assign max(parent layer)+1
let changed = true, guard = 0;
while (changed && guard++ < 100) {
changed = false;
edges.forEach(e => {
const fl = layer.get(e.from);
if (fl == null) return;
const cur = layer.get(e.to);
const want = fl + 1;
if (cur == null || want > cur) {
layer.set(e.to, want);
changed = true;
}
});
}
// place
const layers = {};
nodes.forEach(n => {
const l = layer.get(n.id) ?? 0;
(layers[l] = layers[l] || []).push(n);
});
const layerKeys = Object.keys(layers).map(Number).sort((a,b)=>a-b);
const colW = 200;
const rowH = 86;
const padX = 30;
const padY = 30;
let maxRows = 0;
const positions = new Map();
layerKeys.forEach((l, ci) => {
const arr = layers[l];
arr.forEach((n, ri) => {
positions.set(n.id, { x: padX + ci * colW, y: padY + ri * rowH });
});
maxRows = Math.max(maxRows, arr.length);
});
const width = padX * 2 + Math.max(1, layerKeys.length) * colW - 60;
const height = padY * 2 + Math.max(1, maxRows) * rowH - 30;
return { nodes, edges, byId, positions, width, height };
}
function DAGView({ dag, activeId }) {
const layout = useMemo(() => layoutDAG(dag), [dag]);
if (!layout) {
return DAG not parseable
;
}
const { nodes, edges, positions, width, height } = layout;
const nodeW = 158, nodeH = 56;
return (
);
}
// ─── Result table ──────────────────────────────────────────────
function ResultTable({ columns, rows, max = 8 }) {
if (!columns || !rows) return null;
const shown = rows.slice(0, max);
return (
{columns.map((c, i) => | {c} | )}
{shown.map((r, i) => (
{(Array.isArray(r) ? r : [r]).map((v, j) => (
| {v == null ? NULL : String(v)} |
))}
))}
{rows.length > max && (
+ {rows.length - max} more rows · showing first {max}
)}
);
}
// ─── Lucide icon ───────────────────────────────────────────────
function Icon({ name, size = 16, stroke = 1.5, ...rest }) {
// Use span wrapper so React owns the node; inject SVG via innerHTML
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
const node = window.lucide?.icons?.[name] || window.lucide?.icons?.[toCamel(name)];
if (node && node.toSvg) {
ref.current.innerHTML = node.toSvg({ width: size, height: size, 'stroke-width': stroke });
} else if (window.lucide?.createIcons) {
// fallback: stamp and let lucide.createIcons replace it
ref.current.innerHTML = ``;
window.lucide.createIcons();
}
}, [name, size, stroke]);
return ;
}
function toCamel(s) { return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()).replace(/^(.)/, m => m.toUpperCase()); }
// ─── Step block ────────────────────────────────────────────────
function Step({ num, title, meta, state, open, onToggle, children }) {
return (
{state === 'done' ? : num}
{title}
{meta && · {meta}}
{state === 'active' && }
{open && (
{children}
)}
);
}
// expose
Object.assign(window, {
parseModelResponse, highlightSQL, formatThink,
DAGView, ResultTable, Icon, Step,
});