232 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|