聊天 元件

包含 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>
  );
}

進階功能