update chatbox
This commit is contained in:
@@ -1,132 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ResponseDocument } from './ResponseBlocks';
|
||||
|
||||
/**
|
||||
* Renders the light markup the capabilities emit — `**bold**`, bullets,
|
||||
* numbered items, `_asides_`, and ✓/⚠ check lines.
|
||||
* The thinking state, shown between sending and the first block arriving.
|
||||
*
|
||||
* 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.
|
||||
* Labelled as well as animated: three moving dots mean nothing to a screen
|
||||
* reader, and the visually-hidden text is what tells one that a reply is coming.
|
||||
*/
|
||||
|
||||
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 className="flex items-center gap-2">
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-krow-blue-tint text-krow-blue">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-2.5 py-2 shadow-xs">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="h-1.5 w-1.5 animate-typing rounded-full bg-ink-4"
|
||||
style={{ animationDelay: `${i * 0.18}s` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="sr-only">Krow AI is preparing a response</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.
|
||||
* One turn.
|
||||
*
|
||||
* The user's words sit in a filled bubble; the assistant's reply runs the full
|
||||
* width of the panel. That asymmetry is deliberate — these replies are reports
|
||||
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
|
||||
* column on padding around them.
|
||||
*/
|
||||
export function Message({ role, text, streaming, stopped }) {
|
||||
export const Message = React.memo(({ role, text, blocks, 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">
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] animate-slide-up rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,16 +50,24 @@ export function Message({ role, text, streaming, stopped }) {
|
||||
|
||||
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 className="mb-2 flex items-center gap-1.5">
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-krow-blue-tint text-krow-blue">
|
||||
<Sparkles className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Krow AI</span>
|
||||
</div>
|
||||
|
||||
<ResponseDocument blocks={blocks} streaming={streaming} />
|
||||
|
||||
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Message.displayName = 'Message';
|
||||
|
||||
/** A subtle divider between turns, so a long thread stays readable. */
|
||||
export function TurnDivider() {
|
||||
return <div className="h-px bg-border/60" role="presentation" />;
|
||||
}
|
||||
|
||||
export { cn };
|
||||
|
||||
Reference in New Issue
Block a user