151 lines
4.8 KiB
JavaScript
151 lines
4.8 KiB
JavaScript
import * as React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* Renders the light markup the capabilities emit — `**bold**`, bullets,
|
|
* numbered items, `_asides_`, and ✓/⚠ check lines.
|
|
*
|
|
* A full markdown renderer is available in the project, but this content is
|
|
* produced locally in a known shape, and parsing five constructs keeps the
|
|
* assistant's typography inside the design system rather than inheriting a
|
|
* library's defaults.
|
|
*/
|
|
|
|
const BOLD = /\*\*(.+?)\*\*/g;
|
|
|
|
function inline(line, keyPrefix) {
|
|
const parts = [];
|
|
let last = 0;
|
|
let match;
|
|
BOLD.lastIndex = 0;
|
|
|
|
while ((match = BOLD.exec(line)) !== null) {
|
|
if (match.index > last) parts.push(line.slice(last, match.index));
|
|
parts.push(
|
|
<strong key={`${keyPrefix}-${match.index}`} className="font-semibold text-ink-1">
|
|
{match[1]}
|
|
</strong>
|
|
);
|
|
last = match.index + match[0].length;
|
|
}
|
|
if (last < line.length) parts.push(line.slice(last));
|
|
return parts.length ? parts : line;
|
|
}
|
|
|
|
export function RichText({ text, className }) {
|
|
const lines = React.useMemo(() => String(text).split('\n'), [text]);
|
|
|
|
return (
|
|
<div className={cn('space-y-1.5 text-body-sm text-ink-2', className)}>
|
|
{lines.map((line, i) => {
|
|
if (!line.trim()) return <div key={i} className="h-1.5" aria-hidden="true" />;
|
|
|
|
/* Aside — a caveat the capabilities wrap in underscores. */
|
|
if (line.startsWith('_') && line.endsWith('_')) {
|
|
return (
|
|
<p key={i} className="border-l-2 border-border pl-2.5 text-caption italic text-ink-3 leading-relaxed">
|
|
{inline(line.slice(1, -1), i)}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/* Bullet. */
|
|
if (line.startsWith('• ')) {
|
|
return (
|
|
<div key={i} className="flex gap-2 leading-relaxed">
|
|
<span className="text-krow-blue shrink-0" aria-hidden="true">•</span>
|
|
<span className="min-w-0">{inline(line.slice(2), i)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* Indented continuation of a numbered item. */
|
|
if (line.startsWith(' ')) {
|
|
return (
|
|
<p key={i} className="pl-6 text-caption text-ink-3 leading-relaxed">
|
|
{inline(line.trim(), i)}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/* Numbered item. */
|
|
const numbered = line.match(/^(\d+)\.\s+(.*)$/);
|
|
if (numbered) {
|
|
return (
|
|
<div key={i} className="flex gap-2.5 leading-relaxed">
|
|
<span className="grid place-items-center w-4 h-4 mt-0.5 shrink-0 rounded-full bg-krow-blue-tint text-[10px] font-bold text-krow-blue">
|
|
{numbered[1]}
|
|
</span>
|
|
<span className="min-w-0">{inline(numbered[2], i)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* Health-check line. */
|
|
if (line.startsWith('✓ ') || line.startsWith('⚠ ')) {
|
|
const ok = line.startsWith('✓');
|
|
return (
|
|
<div key={i} className="flex gap-2 leading-relaxed">
|
|
<span className={cn('shrink-0 font-semibold', ok ? 'text-success' : 'text-warning')} aria-hidden="true">
|
|
{ok ? '✓' : '⚠'}
|
|
</span>
|
|
<span className="min-w-0">{inline(line.slice(2), i)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <p key={i} className="leading-relaxed">{inline(line, i)}</p>;
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The three-dot indicator shown before the first delta arrives. */
|
|
export function ThinkingIndicator() {
|
|
return (
|
|
<div className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-3 py-2.5 shadow-xs">
|
|
{[0, 1, 2].map((i) => (
|
|
<span
|
|
key={i}
|
|
className="w-1.5 h-1.5 rounded-full bg-ink-4 animate-typing"
|
|
style={{ animationDelay: `${i * 0.18}s` }}
|
|
/>
|
|
))}
|
|
<span className="sr-only">Assistant is thinking</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One turn in the conversation. The user's own words sit in a filled bubble; the
|
|
* assistant's answer runs full width, because these answers are structured
|
|
* multi-paragraph reports and a chat bubble would waste a third of a 380px
|
|
* column on padding.
|
|
*/
|
|
export function Message({ role, text, streaming, stopped }) {
|
|
if (role === 'user') {
|
|
return (
|
|
<div className="flex justify-end animate-slide-up">
|
|
<div className="max-w-[85%] rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
|
|
{text}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="animate-slide-up">
|
|
<RichText text={text} />
|
|
{streaming && (
|
|
<span
|
|
className="inline-block w-1.5 h-3.5 ml-0.5 -mb-0.5 bg-krow-blue animate-caret"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
{stopped && (
|
|
<p className="mt-1.5 text-caption italic text-ink-4">Stopped early.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|