聊天 元件
包含 Markdown、工具卡片和自動滾動的完整聊天 UI 元件。
一個完整的聊天元件範例,包含 Markdown 渲染、工具卡片視覺化和自動滾動功能。
相依套件
首先,安裝 Markdown 渲染所需的相依套件:
安裝相依套件
bun add marked
bun add -d @types/marked基本元件實作
此元件包含以下 UI 區塊與功能:
- Header 列 — 顯示標題與「New Chat」按鈕,點擊可重置對話 session。
- 訊息列表 — 以氣泡樣式呈現使用者與 AI 的訊息;AI 回覆支援 Markdown 渲染(粗體、列表、程式碼區塊等)。
- 工具卡片 — 當 AI 呼叫工具時即時顯示執行狀態(pending / completed),讓使用者清楚看到背後的工具執行過程。
- 載入動畫 — 無工具卡片時顯示三點跳動動畫,表示 AI 正在思考。
- 錯誤提示 — 發生錯誤時在訊息列表底部顯示錯誤訊息。
- 輸入框 — 支援多行輸入(Shift + Enter 換行)、自動調整高度;按 Enter 送出,載入中時切換為「Stop」按鈕可中斷回覆。
- 自動捲動 — 每次新增訊息或工具卡片時自動捲動到最底部。
元件程式碼
src/components/LensChat.tsx
"use client";
import { useState, useEffect, useRef } from "react";
import { useLensAgent } from "@lens-os/sdk/react";
import type { SSEEvent } from "@lens-os/sdk";
import { marked } from "marked";
// Markdown configuration
marked.setOptions({ gfm: true, breaks: true });
interface ToolCard {
id: string;
name: string;
parameters: Record<string, any>;
result?: any;
status: "pending" | "completed" | "error";
}
interface LensChatProps {
userId: string;
className?: string;
}
export function LensChat({ userId, className = "" }: LensChatProps) {
const [input, setInput] = useState("");
const [toolCards, setToolCards] = useState<ToolCard[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const {
messages,
isLoading,
error,
sendMessage,
abort,
newSession,
} = useLensAgent({
endpoint: "/api/lens/agent/chat",
userId,
onEvent: (event: SSEEvent) => {
if (event.type === "tool_call") {
setToolCards((prev) => [
...prev,
{
id: `tool-${Date.now()}`,
name: event.name!,
parameters: event.parameters,
status: "pending",
},
]);
}
if (event.type === "tool_result") {
setToolCards((prev) =>
prev.map((card) =>
card.name === event.name && card.status === "pending"
? { ...card, result: event.result, status: "completed" }
: card
)
);
}
},
});
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, toolCards]);
// Auto-adjust textarea height
const adjustHeight = () => {
const textarea = inputRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
}
};
const handleSend = async () => {
if (!input.trim() || isLoading) return;
const message = input;
setInput("");
setToolCards([]);
if (inputRef.current) {
inputRef.current.style.height = "auto";
}
await sendMessage(message);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
}
};
const parseMarkdown = (text: string) => {
return marked.parse(text) as string;
};
return (
<div className={`flex flex-col h-full bg-white rounded-lg shadow ${className}`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="font-semibold">AI Assistant</span>
<button
onClick={newSession}
className="text-sm text-gray-500 hover:text-gray-700"
>
New Chat
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="text-center text-gray-400 py-8">
Start a conversation!
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] p-3 rounded-lg ${
msg.role === "user"
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-800"
}`}
>
{msg.role === "assistant" ? (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{
__html: parseMarkdown(
typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content)
),
}}
/>
) : (
<span>
{typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content)}
</span>
)}
</div>
</div>
))}
{/* Tool Cards */}
{toolCards.map((tool) => (
<div
key={tool.id}
className={`flex items-center gap-2 p-2 rounded text-sm ${
tool.status === "pending"
? "bg-yellow-50 text-yellow-700"
: "bg-green-50 text-green-700"
}`}
>
{tool.status === "pending" ? (
<span className="animate-pulse">●</span>
) : (
<span>✓</span>
)}
<span>{tool.name}</span>
</div>
))}
{/* Loading */}
{isLoading && toolCards.length === 0 && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-lg p-3">
<div className="flex gap-1">
<span className="animate-bounce">●</span>
<span className="animate-bounce delay-100">●</span>
<span className="animate-bounce delay-200">●</span>
</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded">
Error: {error.message}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t p-4">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
adjustHeight();
}}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Shift+Enter for new line)"
rows={1}
className="flex-1 resize-none border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={isLoading ? abort : handleSend}
disabled={!isLoading && !input.trim()}
className={`px-4 py-2 rounded-lg font-medium transition ${
isLoading
? "bg-red-500 hover:bg-red-600 text-white"
: input.trim()
? "bg-blue-500 hover:bg-blue-600 text-white"
: "bg-gray-200 text-gray-400 cursor-not-allowed"
}`}
>
{isLoading ? "Stop" : "Send"}
</button>
</div>
</div>
</div>
);
}