ubiquitous-invention/apps/web/components/ai/message.tsx

198 lines
5.6 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import { Sparkles, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
export type AIMessageProps = {
role: "user" | "assistant" | "system";
content: string;
timestamp?: Date;
};
/** Minimal markdown: **bold**, *italic*, `code`, lists */
function renderAssistantMarkdown(content: string): React.ReactNode {
const lines = content.split("\n");
const blocks: React.ReactNode[] = [];
let listBuf: { ordered: boolean; items: string[] } | null = null;
let blockKey = 0;
const parseInline = (text: string, keyPrefix: string): React.ReactNode[] => {
const nodes: React.ReactNode[] = [];
let remaining = text;
let i = 0;
while (remaining.length > 0) {
const tick = remaining.match(/^`([^`]+)`/);
if (tick) {
nodes.push(
<code
key={`${keyPrefix}-c-${i++}`}
className="rounded bg-muted/80 px-1.5 py-0.5 font-mono text-[0.85em] text-foreground"
>
{tick[1]}
</code>,
);
remaining = remaining.slice(tick[0].length);
continue;
}
const bold = remaining.match(/^\*\*([^*]+)\*\*/);
if (bold) {
nodes.push(
<strong key={`${keyPrefix}-b-${i++}`} className="font-semibold text-foreground">
{bold[1]}
</strong>,
);
remaining = remaining.slice(bold[0].length);
continue;
}
const italic = remaining.match(/^\*([^*]+)\*/);
if (italic) {
nodes.push(
<em key={`${keyPrefix}-i-${i++}`} className="italic">
{italic[1]}
</em>,
);
remaining = remaining.slice(italic[0].length);
continue;
}
const nextSpecial = remaining.search(/[`\*]/);
if (nextSpecial === -1) {
nodes.push(remaining);
break;
}
if (nextSpecial > 0) {
nodes.push(remaining.slice(0, nextSpecial));
remaining = remaining.slice(nextSpecial);
continue;
}
nodes.push(remaining[0]);
remaining = remaining.slice(1);
}
return nodes;
};
const flushList = () => {
if (!listBuf || listBuf.items.length === 0) return;
const { ordered, items } = listBuf;
listBuf = null;
const lis = items.map((line, i) => (
<li key={i} className="ml-1 list-inside leading-relaxed marker:text-muted-foreground">
{parseInline(line, `li-${i}`)}
</li>
));
if (ordered) {
blocks.push(
<ol key={`ol-${blockKey++}`} className="list-decimal space-y-1 pl-4">
{lis}
</ol>,
);
} else {
blocks.push(
<ul key={`ul-${blockKey++}`} className="list-disc space-y-1 pl-4">
{lis}
</ul>,
);
}
};
for (const line of lines) {
const ul = line.match(/^\s*[-*]\s+(.*)$/);
const ol = line.match(/^\s*\d+\.\s+(.*)$/);
if (ul) {
if (listBuf?.ordered) flushList();
if (!listBuf) listBuf = { ordered: false, items: [] };
listBuf.items.push(ul[1] ?? "");
continue;
}
if (ol) {
if (listBuf && !listBuf.ordered) flushList();
if (!listBuf) listBuf = { ordered: true, items: [] };
listBuf.items.push(ol[1] ?? "");
continue;
}
flushList();
if (line.trim() === "") {
blocks.push(<div key={`sp-${blockKey++}`} className="h-2" />);
} else {
blocks.push(
<p key={`p-${blockKey++}`} className="leading-relaxed">
{parseInline(line, `p-${blockKey}`)}
</p>,
);
}
}
flushList();
return <div className="space-y-2 text-sm">{blocks}</div>;
}
export function AIMessage({ role, content, timestamp }: AIMessageProps) {
if (role === "system") {
return (
<div className="flex justify-center px-2 py-1">
<p className="max-w-[90%] text-center text-xs text-muted-foreground">{content}</p>
</div>
);
}
const isUser = role === "user";
return (
<div
className={cn(
"flex gap-3 px-1 py-2",
isUser ? "flex-row-reverse" : "flex-row",
)}
>
<Avatar className="mt-0.5 h-8 w-8 shrink-0 border border-border/60">
<AvatarFallback
className={cn(
"text-xs",
isUser
? "bg-primary/15 text-primary"
: "bg-teal-500/15 text-teal-600 dark:text-teal-400",
)}
>
{isUser ? <User className="size-4" /> : <Sparkles className="size-4" />}
</AvatarFallback>
</Avatar>
<div
className={cn(
"flex min-w-0 max-w-[min(100%,28rem)] flex-col gap-1",
isUser ? "items-end" : "items-start",
)}
>
<div
className={cn(
"rounded-2xl px-4 py-2.5 shadow-sm",
isUser
? "rounded-tr-md bg-primary text-primary-foreground"
: "rounded-tl-md border border-border/80 bg-card text-card-foreground",
)}
>
{isUser ? (
<p className="whitespace-pre-wrap text-sm leading-relaxed">{content}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-1 prose-headings:my-2">
{renderAssistantMarkdown(content)}
</div>
)}
</div>
{timestamp ? (
<time
dateTime={timestamp.toISOString()}
className="text-[10px] text-muted-foreground"
>
{timestamp.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
})}
</time>
) : null}
</div>
</div>
);
}