update chatbox
This commit is contained in:
409
src/components/ai-assistant/ResponseBlocks.jsx
Normal file
409
src/components/ai-assistant/ResponseBlocks.jsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowDownRight, ArrowUpRight, Check, TriangleAlert } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProgressBar, toneForScore } from '@/components/ds/Progress';
|
||||
|
||||
/**
|
||||
* Renderers for response blocks.
|
||||
*
|
||||
* Every block is drawn with the same tokens as the dashboard — the ink scale,
|
||||
* the shared radius, the score-band colours — so a generated report looks like
|
||||
* it belongs to Krow rather than to a chat library.
|
||||
*
|
||||
* Blocks are memoized individually: a streaming response re-renders on every
|
||||
* snapshot, and settled blocks must not re-render with it.
|
||||
*/
|
||||
|
||||
const INLINE = /(\*\*[^*]+\*\*|_[^_]+_)/g;
|
||||
|
||||
/** Inline `**bold**` and `_italic_`. Kept deliberately small — structure is
|
||||
* carried by blocks, not by markup inside a paragraph. */
|
||||
function Inline({ value }) {
|
||||
const parts = React.useMemo(() => String(value).split(INLINE).filter(Boolean), [value]);
|
||||
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={i} className="font-semibold text-ink-1">{part.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (part.startsWith('_') && part.endsWith('_')) {
|
||||
return <em key={i} className="text-ink-3">{part.slice(1, -1)}</em>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
const TONE_TEXT = {
|
||||
info: 'text-krow-blue',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
risk: 'text-destructive',
|
||||
neutral: 'text-ink-3',
|
||||
};
|
||||
|
||||
const TONE_SURFACE = {
|
||||
info: 'bg-krow-blue-tint border-krow-blue/15',
|
||||
success: 'bg-success-muted border-success/15',
|
||||
warning: 'bg-warning-muted border-warning/20',
|
||||
risk: 'bg-destructive-muted border-destructive/15',
|
||||
neutral: 'bg-surface-subtle border-border',
|
||||
};
|
||||
|
||||
const TONE_BADGE = {
|
||||
info: 'info', success: 'success', warning: 'warning', risk: 'destructive', neutral: 'neutral',
|
||||
};
|
||||
|
||||
/* ── Individual blocks ──────────────────────────────────────────────────── */
|
||||
|
||||
const TextBlock = React.memo(({ block }) => (
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
));
|
||||
TextBlock.displayName = 'TextBlock';
|
||||
|
||||
const HeadingBlock = React.memo(({ block }) => (
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="font-heading text-body-sm font-semibold text-ink-1">{block.text}</h4>
|
||||
{block.sub && <p className="text-caption text-ink-3">{block.sub}</p>}
|
||||
</div>
|
||||
));
|
||||
HeadingBlock.displayName = 'HeadingBlock';
|
||||
|
||||
/** Headline figures. Two columns keeps the numbers large in a 380px panel. */
|
||||
const KpisBlock = React.memo(({ block }) => (
|
||||
<div className="grid grid-cols-2 gap-2 [[data-wide]_&]:grid-cols-3">
|
||||
{block.items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs"
|
||||
>
|
||||
<p className="truncate text-[10px] uppercase tracking-wide text-ink-4">{item.label}</p>
|
||||
<div className="mt-0.5 flex items-baseline gap-1.5">
|
||||
<span className={cn(
|
||||
'font-heading text-title font-bold tabular-nums',
|
||||
item.tone ? TONE_TEXT[item.tone] : 'text-ink-1'
|
||||
)}>
|
||||
{item.value}
|
||||
</span>
|
||||
{typeof item.delta === 'number' && item.delta !== 0 && (
|
||||
<span className={cn(
|
||||
'inline-flex items-center text-[10px] font-semibold',
|
||||
item.delta > 0 ? 'text-success' : 'text-destructive'
|
||||
)}>
|
||||
{item.delta > 0
|
||||
? <ArrowUpRight className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
: <ArrowDownRight className="h-2.5 w-2.5" aria-hidden="true" />}
|
||||
{Math.abs(item.delta)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.sub && <p className="mt-0.5 truncate text-[10px] text-ink-4">{item.sub}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
KpisBlock.displayName = 'KpisBlock';
|
||||
|
||||
/** Pass/warn checks. The icon carries the state so colour is not the only cue. */
|
||||
const StatusBlock = React.memo(({ block }) => (
|
||||
<ul className="space-y-1.5">
|
||||
{block.items.map((item) => (
|
||||
<li key={item.label} className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full',
|
||||
item.ok ? 'bg-success-muted text-success' : 'bg-warning-muted text-warning'
|
||||
)}
|
||||
>
|
||||
{item.ok
|
||||
? <Check className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
: <TriangleAlert className="h-2.5 w-2.5" aria-hidden="true" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm font-medium text-ink-1">{item.label}</span>
|
||||
{item.value && (
|
||||
<span className="shrink-0 text-caption font-semibold tabular-nums text-ink-2">
|
||||
{item.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.note && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.note}</p>}
|
||||
</div>
|
||||
<span className="sr-only">{item.ok ? 'Passing' : 'Needs attention'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
));
|
||||
StatusBlock.displayName = 'StatusBlock';
|
||||
|
||||
const MetersBlock = React.memo(({ block }) => (
|
||||
<div className="space-y-2.5">
|
||||
{block.items.map((item) => (
|
||||
<div key={item.label} className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-caption text-ink-2">{item.label}</span>
|
||||
<span className="flex shrink-0 items-baseline gap-1.5">
|
||||
{item.weight != null && <span className="text-[10px] text-ink-4">{item.weight}%</span>}
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{item.value}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={item.value} max={item.max ?? 100} tone={item.tone ?? 'score'} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
MetersBlock.displayName = 'MetersBlock';
|
||||
|
||||
/**
|
||||
* A table. Scrolls horizontally inside its own container so a wide comparison
|
||||
* never widens the panel.
|
||||
*/
|
||||
const TableBlock = React.memo(({ block }) => {
|
||||
const cell = (value) => {
|
||||
if (value == null || value === '') return <span className="text-ink-4">—</span>;
|
||||
if (typeof value === 'object') {
|
||||
if (value.badge) {
|
||||
return <Badge variant={TONE_BADGE[value.tone] || 'neutral'} size="sm">{value.badge}</Badge>;
|
||||
}
|
||||
return (
|
||||
<span className={cn('tabular-nums', value.tone ? TONE_TEXT[value.tone] : undefined)}>
|
||||
{value.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="tabular-nums">{value}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
|
||||
<table className="w-full border-collapse text-caption">
|
||||
{block.caption && <caption className="sr-only">{block.caption}</caption>}
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-subtle">
|
||||
{block.columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-ink-4',
|
||||
column.align === 'right' ? 'text-right' : 'text-left'
|
||||
)}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
{block.columns.map((column, ci) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-2.5 py-2 align-middle',
|
||||
column.align === 'right' ? 'text-right' : 'text-left',
|
||||
ci === 0 ? 'font-medium text-ink-1' : 'text-ink-2'
|
||||
)}
|
||||
>
|
||||
{cell(row[column.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TableBlock.displayName = 'TableBlock';
|
||||
|
||||
/** Stage progression, widths relative to the largest stage. */
|
||||
const FunnelBlock = React.memo(({ block }) => {
|
||||
const max = Math.max(...block.steps.map((s) => s.count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{block.steps.map((step) => (
|
||||
<div key={step.label}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="text-caption text-ink-2">{step.label}</span>
|
||||
<span className="flex shrink-0 items-baseline gap-1.5">
|
||||
{step.rate != null && <span className="text-[10px] text-ink-4">{step.rate}%</span>}
|
||||
<span className="text-caption font-semibold tabular-nums text-ink-1">{step.count}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-surface-sunken">
|
||||
<div
|
||||
className="h-full rounded-full bg-krow-blue transition-all duration-slow ease-out"
|
||||
style={{ width: `${Math.max((step.count / max) * 100, step.count > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
FunnelBlock.displayName = 'FunnelBlock';
|
||||
|
||||
const ListBlock = React.memo(({ block }) => {
|
||||
const Tag = block.ordered ? 'ol' : 'ul';
|
||||
return (
|
||||
<Tag className="space-y-1.5">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-body-sm leading-relaxed text-ink-2">
|
||||
{block.ordered ? (
|
||||
<span className="mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
||||
{i + 1}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-krow-blue" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0"><Inline value={item} /></span>
|
||||
</li>
|
||||
))}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
ListBlock.displayName = 'ListBlock';
|
||||
|
||||
/** Findings, each in a tinted card so severity is scannable. */
|
||||
const InsightsBlock = React.memo(({ block }) => (
|
||||
<div className="space-y-2">
|
||||
{block.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('rounded-xl border px-3 py-2.5', TONE_SURFACE[item.tone] || TONE_SURFACE.neutral)}
|
||||
>
|
||||
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||
{item.body && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
InsightsBlock.displayName = 'InsightsBlock';
|
||||
|
||||
/** Recommended steps — numbered, because order is the recommendation. */
|
||||
const ActionsBlock = React.memo(({ block }) => (
|
||||
<ol className="space-y-2">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2.5 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-xs">
|
||||
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-[10px] font-bold text-white">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{item.title}</p>
|
||||
{item.body && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-2"><Inline value={item.body} /></p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
));
|
||||
ActionsBlock.displayName = 'ActionsBlock';
|
||||
|
||||
const BadgesBlock = React.memo(({ block }) => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{block.items.map((item) => (
|
||||
<Badge key={item.label} variant={TONE_BADGE[item.tone] || 'soft'} size="lg">
|
||||
{item.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
BadgesBlock.displayName = 'BadgesBlock';
|
||||
|
||||
const TimelineBlock = React.memo(({ block }) => (
|
||||
<ol className="relative space-y-3">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1 h-2 w-2 shrink-0 rounded-full',
|
||||
item.tone === 'success' ? 'bg-success'
|
||||
: item.tone === 'warning' ? 'bg-warning'
|
||||
: item.current ? 'bg-krow-blue' : 'bg-ink-4'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{i < block.items.length - 1 && <span className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pb-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-body-sm font-medium text-ink-1">{item.title}</span>
|
||||
{item.timestamp && (
|
||||
<span className="shrink-0 text-[10px] text-ink-4">{item.timestamp}</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="mt-0.5 text-caption leading-relaxed text-ink-3">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
));
|
||||
TimelineBlock.displayName = 'TimelineBlock';
|
||||
|
||||
const NoteBlock = React.memo(({ block }) => (
|
||||
<p className="border-l-2 border-border pl-2.5 text-caption italic leading-relaxed text-ink-3">
|
||||
<Inline value={block.text} />
|
||||
</p>
|
||||
));
|
||||
NoteBlock.displayName = 'NoteBlock';
|
||||
|
||||
const RENDERERS = {
|
||||
text: TextBlock,
|
||||
heading: HeadingBlock,
|
||||
kpis: KpisBlock,
|
||||
status: StatusBlock,
|
||||
meters: MetersBlock,
|
||||
table: TableBlock,
|
||||
funnel: FunnelBlock,
|
||||
list: ListBlock,
|
||||
insights: InsightsBlock,
|
||||
actions: ActionsBlock,
|
||||
badges: BadgesBlock,
|
||||
timeline: TimelineBlock,
|
||||
note: NoteBlock,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a response document.
|
||||
*
|
||||
* Spacing is decided here rather than by the blocks, so any combination of
|
||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||
* else breathes.
|
||||
*/
|
||||
export const ResponseDocument = React.memo(({ blocks = [], streaming = false }) => (
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, i) => {
|
||||
const Renderer = RENDERERS[block.type];
|
||||
if (!Renderer) return null;
|
||||
const hugsNext = block.type === 'heading' && i < blocks.length - 1;
|
||||
const isLast = i === blocks.length - 1;
|
||||
|
||||
return (
|
||||
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
|
||||
<Renderer block={block} />
|
||||
{/* The cursor trails the final block only while text is still arriving. */}
|
||||
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
|
||||
<span
|
||||
className="ml-0.5 inline-block h-3.5 w-1.5 -mb-0.5 animate-caret bg-krow-blue align-middle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
ResponseDocument.displayName = 'ResponseDocument';
|
||||
|
||||
export { toneForScore };
|
||||
Reference in New Issue
Block a user