Files
socweb/components/gemini/GeminiMessage.tsx
T

232 lines
6.8 KiB
TypeScript

"use client";
import { useState } from "react";
import { ThumbsUp, ThumbsDown, Copy, RefreshCw, Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp?: Date;
}
interface GeminiMessageProps {
message: Message;
onRegenerate?: (id: string) => void;
}
// Simple markdown-like renderer
function renderContent(content: string) {
const lines = content.split("\n");
const elements: React.ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Code block
if (line.startsWith("```")) {
const lang = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
elements.push(
<pre
key={i}
className="bg-[#1e1e1e] border border-[#3a3a3a] rounded-xl p-4 overflow-x-auto my-3 text-sm font-mono text-[#c4c7c5]"
>
{lang && (
<div className="text-xs text-[#9aa0a6] mb-2 uppercase tracking-wide">{lang}</div>
)}
<code>{codeLines.join("\n")}</code>
</pre>
);
}
// H2
else if (line.startsWith("## ")) {
elements.push(
<h2 key={i} className="text-lg font-semibold text-[#e3e3e3] mt-4 mb-2">
{line.slice(3)}
</h2>
);
}
// H3
else if (line.startsWith("### ")) {
elements.push(
<h3 key={i} className="text-base font-semibold text-[#e3e3e3] mt-3 mb-1.5">
{line.slice(4)}
</h3>
);
}
// Bullet
else if (line.startsWith("- ") || line.startsWith("* ")) {
const items: string[] = [];
while (i < lines.length && (lines[i].startsWith("- ") || lines[i].startsWith("* "))) {
items.push(lines[i].slice(2));
i++;
}
elements.push(
<ul key={i} className="list-disc list-inside space-y-1 my-2 text-[#c4c7c5] text-sm leading-relaxed">
{items.map((item, idx) => (
<li key={idx} dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
))}
</ul>
);
continue;
}
// Numbered list
else if (/^\d+\.\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
items.push(lines[i].replace(/^\d+\.\s/, ""));
i++;
}
elements.push(
<ol key={i} className="list-decimal list-inside space-y-1 my-2 text-[#c4c7c5] text-sm leading-relaxed">
{items.map((item, idx) => (
<li key={idx} dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
))}
</ol>
);
continue;
}
// Empty line
else if (line.trim() === "") {
elements.push(<div key={i} className="h-2" />);
}
// Paragraph
else {
elements.push(
<p
key={i}
className="text-[#c4c7c5] text-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: inlineFormat(line) }}
/>
);
}
i++;
}
return elements;
}
function inlineFormat(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-[#e3e3e3] font-semibold">$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code class="bg-[#2a2a2a] px-1.5 py-0.5 rounded text-xs font-mono text-[#a8c4f5]">$1</code>');
}
const GemIcon = ({ size = 18 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" className="flex-shrink-0 mt-0.5">
<defs>
<linearGradient id="gemGradientMsg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#4285f4" />
<stop offset="100%" stopColor="#a855f7" />
</linearGradient>
</defs>
<path
d="M14 2C14 2 16.5 9.5 20 13C23.5 16.5 26 14 26 14C26 14 23.5 16.5 20 20C16.5 23.5 14 26 14 26C14 26 11.5 23.5 8 20C4.5 16.5 2 14 2 14C2 14 4.5 11.5 8 8C11.5 4.5 14 2 14 2Z"
fill="url(#gemGradientMsg)"
/>
</svg>
);
export function GeminiMessage({ message, onRegenerate }: GeminiMessageProps) {
const [copied, setCopied] = useState(false);
const [feedback, setFeedback] = useState<"up" | "down" | null>(null);
const handleCopy = async () => {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (message.role === "user") {
return (
<div className="flex justify-end mb-4" role="article" aria-label="Your message">
<div className="max-w-[70%] bg-[#2a2a2a] rounded-2xl px-5 py-3.5 text-[#e3e3e3] text-sm leading-relaxed whitespace-pre-wrap">
{message.content}
</div>
</div>
);
}
return (
<div className="mb-6 group" role="article" aria-label="Gemini response">
<div className="flex gap-3">
{/* Gem icon */}
<GemIcon />
{/* Content */}
<div className="flex-1 min-w-0">
<div className="space-y-0.5">{renderContent(message.content)}</div>
{/* Action buttons */}
<div
className={cn(
"flex items-center gap-1 mt-3 transition-opacity duration-150",
"opacity-0 group-hover:opacity-100"
)}
role="toolbar"
aria-label="Message actions"
>
<ActionButton
onClick={() => setFeedback("up")}
active={feedback === "up"}
label="Thumbs up"
>
<ThumbsUp size={15} />
</ActionButton>
<ActionButton
onClick={() => setFeedback("down")}
active={feedback === "down"}
label="Thumbs down"
>
<ThumbsDown size={15} />
</ActionButton>
<ActionButton onClick={handleCopy} label="Copy to clipboard">
{copied ? <Check size={15} className="text-green-400" /> : <Copy size={15} />}
</ActionButton>
{onRegenerate && (
<ActionButton onClick={() => onRegenerate(message.id)} label="Regenerate response">
<RefreshCw size={15} />
</ActionButton>
)}
</div>
</div>
</div>
</div>
);
}
function ActionButton({
onClick,
active,
label,
children,
}: {
onClick: () => void;
active?: boolean;
label: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
aria-label={label}
className={cn(
"p-1.5 rounded-lg transition-colors duration-150 cursor-pointer",
active
? "text-[#4285f4] bg-[#1a2a40]"
: "text-[#9aa0a6] hover:text-[#e3e3e3] hover:bg-[#2a2a2a]"
)}
>
{children}
</button>
);
}