Chuyển đến nội dung chính

第 19 課:React 前端 — 聊天 UI 和儀表板

建立 React 前端:帶有串流媒體的聊天介面、側邊欄導航、會話管理、模型選擇器。儀表板:使用狀況分析、工作流程建構器 UI。 Tailwind CSS,Zustand 狀態。

🧠 人工智慧與機器學習 — 第 18 課 第 19 課:React 前端 — 聊天 UI & 儀表板

從零開始搭建AI代理平台-與xClaw實戰

第 7 部分:前端、監控與生產

亞洲開發網

簡介

後端完成後,需要一個使用者介面供使用者互動。本文建構了具有串流聊天、會話管理和管理儀表板的 React 前端。


1. 項目設置

# packages/web — React + Vite + Tailwind
cd packages/web
npm create vite@latest . -- --template react-ts
npm install zustand react-router-dom react-markdown
npm install -D tailwindcss @tailwindcss/typography

應用程式結構

packages/web/src/
├── main.tsx
├── App.tsx
├── api/
│   ├── client.ts         # HTTP client with auth
│   └── chat.ts           # Chat API + SSE streaming
├── stores/
│   ├── auth-store.ts     # Authentication state
│   ├── chat-store.ts     # Chat sessions & messages
│   └── ui-store.ts       # Sidebar, theme, modals
├── components/
│   ├── Chat/
│   │   ├── ChatWindow.tsx
│   │   ├── MessageBubble.tsx
│   │   ├── ChatInput.tsx
│   │   └── ToolCallIndicator.tsx
│   ├── Sidebar/
│   │   ├── SessionList.tsx
│   │   └── ModelSelector.tsx
│   └── Dashboard/
│       ├── UsageChart.tsx
│       └── WorkflowBuilder.tsx
└── pages/
    ├── ChatPage.tsx
    ├── DashboardPage.tsx
    ├── KnowledgePage.tsx
    └── SettingsPage.tsx

2. 串流聊天用戶端

// packages/web/src/api/chat.ts
export async function streamChat(
  message: string,
  sessionId: string,
  onEvent: (event: StreamEvent) => void,
): Promise<void> {
  const response = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${getToken()}`,
    },
    body: JSON.stringify({ message, sessionId }),
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });

    // Parse SSE events
    const lines = buffer.split('\n');
    buffer = lines.pop() || '';

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        try {
          const event = JSON.parse(line.slice(6));
          onEvent(event);
        } catch {
          // Skip malformed events
        }
      }
    }
  }
}

3.聊天視窗元件

// packages/web/src/components/Chat/ChatWindow.tsx
import { useRef, useEffect } from 'react';
import { useChatStore } from '../../stores/chat-store';
import { MessageBubble } from './MessageBubble';
import { ChatInput } from './ChatInput';
import { ToolCallIndicator } from './ToolCallIndicator';

export function ChatWindow() {
  const { messages, isStreaming, streamingContent, activeToolCalls } = useChatStore();
  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages, streamingContent]);

  return (
    <div className="flex flex-col h-full">
      {/* Messages area */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((msg) => (
          <MessageBubble key={msg.id} message={msg} />
        ))}

        {/* Streaming response */}
        {isStreaming && streamingContent && (
          <MessageBubble
            message={{
              id: 'streaming',
              role: 'assistant',
              content: streamingContent,
              timestamp: new Date(),
            }}
            isStreaming
          />
        )}

        {/* Active tool calls */}
        {activeToolCalls.map((tc) => (
          <ToolCallIndicator key={tc.id} toolCall={tc} />
        ))}

        <div ref={bottomRef} />
      </div>

      {/* Input */}
      <ChatInput />
    </div>
  );
}

4.聊天商店(Zustand)

// packages/web/src/stores/chat-store.ts
import { create } from 'zustand';
import { streamChat } from '../api/chat';

interface ChatState {
  messages: Message[];
  isStreaming: boolean;
  streamingContent: string;
  activeToolCalls: ToolCallInfo[];
  sessionId: string | null;

  sendMessage: (content: string) => Promise<void>;
  newSession: () => void;
}

export const useChatStore = create<ChatState>((set, get) => ({
  messages: [],
  isStreaming: false,
  streamingContent: '',
  activeToolCalls: [],
  sessionId: null,

  sendMessage: async (content: string) => {
    const sessionId = get().sessionId || crypto.randomUUID();

    // Add user message
    set((state) => ({
      sessionId,
      messages: [...state.messages, {
        id: crypto.randomUUID(),
        role: 'user',
        content,
        timestamp: new Date(),
      }],
      isStreaming: true,
      streamingContent: '',
    }));

    try {
      await streamChat(content, sessionId, (event) => {
        switch (event.type) {
          case 'text-delta':
            set((state) => ({
              streamingContent: state.streamingContent + event.delta,
            }));
            break;

          case 'tool-call-start':
            set((state) => ({
              activeToolCalls: [...state.activeToolCalls, {
                id: event.toolCallId,
                name: event.toolName,
                status: 'running',
              }],
            }));
            break;

          case 'tool-result':
            set((state) => ({
              activeToolCalls: state.activeToolCalls.filter(
                tc => tc.id !== event.toolCallId
              ),
            }));
            break;

          case 'finish':
            const finalContent = get().streamingContent;
            set((state) => ({
              messages: [...state.messages, {
                id: crypto.randomUUID(),
                role: 'assistant',
                content: finalContent,
                timestamp: new Date(),
              }],
              isStreaming: false,
              streamingContent: '',
              activeToolCalls: [],
            }));
            break;
        }
      });
    } catch (error) {
      set({ isStreaming: false, streamingContent: '' });
    }
  },

  newSession: () => set({
    messages: [],
    sessionId: null,
    streamingContent: '',
    isStreaming: false,
  }),
}));

5. 訊息氣泡

// packages/web/src/components/Chat/MessageBubble.tsx
import ReactMarkdown from 'react-markdown';

interface Props {
  message: Message;
  isStreaming?: boolean;
}

export function MessageBubble({ message, isStreaming }: Props) {
  const isUser = message.role === 'user';

  return (
    <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
      <div
        className={`max-w-[80%] rounded-2xl px-4 py-3 ${
          isUser
            ? 'bg-blue-600 text-white'
            : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
        }`}
      >
        {isUser ? (
          <p className="whitespace-pre-wrap">{message.content}</p>
        ) : (
          <div className="prose dark:prose-invert prose-sm max-w-none">
            <ReactMarkdown>{message.content}</ReactMarkdown>
          </div>
        )}

        {isStreaming && (
          <span className="inline-block w-2 h-4 bg-current animate-pulse ml-1" />
        )}
      </div>
    </div>
  );
}

6. 總結

  • 串流 UI — 逐一令牌顯示、工具呼叫指示器
  • Zustand — 輕量級狀態管理,無樣板
  • SSE 用戶端 — 在瀏覽器中解析伺服器發送的事件
  • Markdown 渲染 — AI 透過程式碼區塊、清單、表格回應

下一篇文章: 監控、可觀察性與生產部署。

祝您成功打造屬於自己的AI平台! 🚀源碼參考: github.com/xdev-asia-labs/xClaw| 生產 |前端、監控、部署 | 19-20 || 訪問 |多租戶、RBAC、通道 | 17-18 || 可擴展性 |技能、領域、插件、MCP | 14-16 || 自動化 |工作流程引擎、驗證| 12-13 路由器知識** | 5-8 || 基金會 | Monorepo、資料庫、網關 | 1-4 ||--------|------------|-----||層 |元件|文章|您已經建立了一個完整的AI代理平台:## 7.系列總結---- [ ] Audit trail for compliance- [ ] Latency percentile tracking (p50, p99)- [ ] Error rate alerts- [ ] Token usage dashboards- [ ] LLM cost tracking per tenant### Monitoring- [ ] Log aggregation pipeline- [ ] Error alerting (Slack/email)- [ ] Database backups (daily)- [ ] Graceful shutdown handling- [ ] Health check endpoints### Reliability- [ ] Image optimization / CDN- [ ] Streaming enabled for chat endpoints- [ ] Response caching for frequent queries- [ ] Connection pooling (PostgreSQL, MongoDB, Redis)### Performance- [ ] Database credentials not in code- [ ] HTTPS only (TLS termination at load balancer)- [ ] Rate limiting enabled- [ ] CORS restricted to known origins- [ ] API keys encrypted at rest- [ ] JWT_SECRET is strong (32+ random bytes)### Securitymarkdown## 6. 生產清單--- docker system prune -f docker compose up -d --remove-orphans docker compose pull cd /opt/xclaw script: | key: ${{ secrets.DEPLOY_KEY }} username: ${{ secrets.DEPLOY_USER }} host: ${{ secrets.DEPLOY_HOST }} with: uses: appleboy/ssh-action@v1 - name: Deploy to server steps: runs-on: ubuntu-latest needs: build deploy: cache-to: type=gha,mode=max cache-from: type=gha tags: ghcr.io/${{ github.repository }}:${{ github.sha }} push: true with: - uses: docker/build-push-action@v5 password: ${{ secrets.GITHUB_TOKEN }} username: ${{ github.actor }} registry: ghcr.io with: - uses: docker/login-action@v3 - uses: docker/setup-buildx-action@v3 - uses: actions/checkout@v4 steps: runs-on: ubuntu-latest needs: test build: - run: npm run test - run: npm run lint - run: npm ci cache: npm node-version: 20 with: - uses: actions/setup-node@v4 - uses: actions/checkout@v4 steps: runs-on: ubuntu-latest test:jobs: branches: [main] push:on:name: Build & Deploy# .github/workflows/deploy.ymlyaml## 5. CI/CD 管道---chatLogger.info('Chat started', { sessionId, model: 'gpt-4o' });const chatLogger = logger.child({ component: 'chat' });const logger = new Logger({ service: 'xclaw' });// Usage} } console.log(JSON.stringify(entry)); // JSON structured logging — parseable by log aggregators }; ...data, ...this.context, message, level, timestamp: new Date().toISOString(), const entry = { private log(level: string, message: string, data?: Record<string, unknown>) { } }); } : undefined, name: error.name, stack: error.stack, message: error.message, error: error ? { ...data, this.log('error', message, { error(message: string, error?: Error, data?: Record<string, unknown>) { } this.log('info', message, data); info(message: string, data?: Record<string, unknown>) { } return new Logger({ ...this.context, ...additionalContext }); child(additionalContext: Record<string, unknown>) { } this.context = context; constructor(context: Record<string, unknown> = {}) { private context: Record<string, unknown>;export class Logger {// packages/core/src/logging/logger.tstypescript## 4. 結構化日誌記錄---} } return { llmStats, toolStats, costByDay }; ]); ]).toArray(), { $sort: { _id: 1 } }, }}, requests: { $sum: 1 }, cost: { $sum: '$cost' }, _id: { $dateToString: { format: '%Y-%m-%d', date: '$timestamp' } }, { $group: { { $match: { tenantId, timestamp: { $gte: since } } }, this.db.collection('llm_usage').aggregate([ // Cost per day ]).toArray(), }}, avgDuration: { $avg: '$durationMs' }, successRate: { $avg: { $cond: ['$success', 1, 0] } }, totalCalls: { $sum: 1 }, _id: '$toolName', { $group: { { $match: { tenantId, timestamp: { $gte: since } } }, this.db.collection('tool_usage').aggregate([ // Tool execution stats ]).toArray(), }}, avgLatency: { $avg: '$latencyMs' }, totalCost: { $sum: '$cost' }, totalTokens: { $sum: { $add: ['$promptTokens', '$completionTokens'] } }, totalRequests: { $sum: 1 }, _id: '$model', { $group: { { $match: { tenantId, timestamp: { $gte: since } } }, this.db.collection('llm_usage').aggregate([ // Total LLM usage const [llmStats, toolStats, costByDay] = await Promise.all([ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); async getUsageAnalytics(tenantId: string, days: number = 30) { // Get usage analytics } }); timestamp: new Date(), ...data, await this.db.collection('tool_usage').insertOne({ }) { durationMs: number; success: boolean; toolName: string; tenantId: string; async recordToolExecution(data: { // Track tool execution } }); timestamp: new Date(), ...data, await this.db.collection('llm_usage').insertOne({ }) { cost: number; latencyMs: number; completionTokens: number; promptTokens: number; model: string; provider: string; userId: string; tenantId: string; async recordLLMUsage(data: { // Track LLM usage private db: Db;export class MonitoringService {// packages/core/src/monitoring/monitoring-service.tstypescript## 3.監控服務---CMD ["node", "packages/server/dist/index.js"]EXPOSE 3000USER xclawCOPY --from=builder --chown=xclaw:nodejs /app/package.json ./COPY --from=builder --chown=xclaw:nodejs /app/packages/*/dist ./packages/COPY --from=builder --chown=xclaw:nodejs /app/node_modules ./node_modulesRUN addgroup -g 1001 -S nodejs && adduser -S xclaw -u 1001WORKDIR /appFROM node:20-alpine AS runner# Production stageRUN npm run buildCOPY . .RUN npm ci --production=falseCOPY packages/*/package.json ./packages/COPY package.json package-lock.json ./WORKDIR /appFROM node:20-alpine AS builder# Dockerfiledockerfile## 2. Dockerfile(多階段建置)--- redisdata: mongodata: pgdata:volumes: - redisdata:/data volumes: image: redis:7-alpine redis: - MONGO_INITDB_DATABASE=xclaw environment: - mongodata:/data/db volumes: image: mongo:7 mongo: retries: 5 timeout: 5s interval: 10s test: ["CMD-SHELL", "pg_isready -U user"] healthcheck: - POSTGRES_PASSWORD=pass - POSTGRES_USER=user - POSTGRES_DB=xclaw environment: - pgdata:/var/lib/postgresql/data volumes: image: postgres:16-alpine postgres: - VITE_API_URL=http://server:3000 environment: - "3001:3001" ports: dockerfile: Dockerfile context: ./packages/web build: web: retries: 3 timeout: 10s interval: 30s test: ["CMD", "curl", "-f", "http://localhost:3000/health"] healthcheck: condition: service_started redis: condition: service_started mongo: condition: service_healthy postgres: depends_on: - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY} - JWT_SECRET=${JWT_SECRET} - REDIS_URL=redis://redis:6379 - MONGODB_URL=mongodb://mongo:27017/xclaw - DATABASE_URL=postgresql://user:pass@postgres:5432/xclaw - NODE_ENV=production environment: - "3000:3000" ports: dockerfile: Dockerfile context: . build: server:services:# docker-compose.ymlyaml## 1. Docker Compose — 生產堆疊---最後一課 — 將 AI Agent Platform 部署到生產環境。監控、警報、成本追蹤和 CI/CD 管道。 ## 簡介--- slug:grind-dung-ai-agent-platform 標題:“從零開始構建 AI 代理平台 — 與 xClaw 的真正戰鬥” id: 019c9619-bb03-7003-c003-tle0300000003course:section_tit. 19video_url: nullis_free:trueduration_分鐘:240 筆記錄。帶有 GitHub Actions 的 CI/CD 管道。 錯誤率。可觀察性:結構化日誌記錄、OpenTelemetry 健康檢查。監控:使用情況分析、成本追蹤、生產部署:Docker Compose、環境配置、描述:>-slug:article-20-monitoring-deploytitle:「第 20 課:監控、可觀察性和生產部署」id:019c961a-aa20-7020-e020-0020000 019c961a-aa19-7019-e019-aa1900000019 標題:“第 19 課:React 前端 — 聊天 UI 和儀表板” slug:bai-19-react-frontend 描述:>- 建構 React 前端:帶有串流的聊天介面、側邊欄 導航、會話管理、模型選擇器。儀表板: 使用情況分析、工作流程建構器 UI。 Tailwind CSS,Zustand 狀態。 持續時間分鐘:240 is_free: 真 影片網址:空 排序順序:18 section_title:“第 7 部分:前端、監控和生產” 課程: 編號: 019c9619-bb03-7003-c003-bb0300000003 標題:《從零開始搭建AI代理平台-與xClaw實戰》 slug:研磨使用人工智慧代理平台

簡介

後端完成後,需要一個使用者介面供使用者互動。本文建構了具有串流聊天、會話管理和管理儀表板的 React 前端。


1. 項目設置

# packages/web — React + Vite + Tailwind
cd packages/web
npm create vite@latest . -- --template react-ts
npm install zustand react-router-dom react-markdown
npm install -D tailwindcss @tailwindcss/typography

應用程式結構

packages/web/src/
├── main.tsx
├── App.tsx
├── api/
│   ├── client.ts         # HTTP client with auth
│   └── chat.ts           # Chat API + SSE streaming
├── stores/
│   ├── auth-store.ts     # Authentication state
│   ├── chat-store.ts     # Chat sessions & messages
│   └── ui-store.ts       # Sidebar, theme, modals
├── components/
│   ├── Chat/
│   │   ├── ChatWindow.tsx
│   │   ├── MessageBubble.tsx
│   │   ├── ChatInput.tsx
│   │   └── ToolCallIndicator.tsx
│   ├── Sidebar/
│   │   ├── SessionList.tsx
│   │   └── ModelSelector.tsx
│   └── Dashboard/
│       ├── UsageChart.tsx
│       └── WorkflowBuilder.tsx
└── pages/
    ├── ChatPage.tsx
    ├── DashboardPage.tsx
    ├── KnowledgePage.tsx
    └── SettingsPage.tsx

2. 串流聊天用戶端

// packages/web/src/api/chat.ts
export async function streamChat(
  message: string,
  sessionId: string,
  onEvent: (event: StreamEvent) => void,
): Promise<void> {
  const response = await fetch('/api/chat/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${getToken()}`,
    },
    body: JSON.stringify({ message, sessionId }),
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });

    // Parse SSE events
    const lines = buffer.split('\n');
    buffer = lines.pop() || '';

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        try {
          const event = JSON.parse(line.slice(6));
          onEvent(event);
        } catch {
          // Skip malformed events
        }
      }
    }
  }
}

3.聊天視窗元件

// packages/web/src/components/Chat/ChatWindow.tsx
import { useRef, useEffect } from 'react';
import { useChatStore } from '../../stores/chat-store';
import { MessageBubble } from './MessageBubble';
import { ChatInput } from './ChatInput';
import { ToolCallIndicator } from './ToolCallIndicator';

export function ChatWindow() {
  const { messages, isStreaming, streamingContent, activeToolCalls } = useChatStore();
  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages, streamingContent]);

  return (
    <div className="flex flex-col h-full">
      {/* Messages area */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((msg) => (
          <MessageBubble key={msg.id} message={msg} />
        ))}

        {/* Streaming response */}
        {isStreaming && streamingContent && (
          <MessageBubble
            message={{
              id: 'streaming',
              role: 'assistant',
              content: streamingContent,
              timestamp: new Date(),
            }}
            isStreaming
          />
        )}

        {/* Active tool calls */}
        {activeToolCalls.map((tc) => (
          <ToolCallIndicator key={tc.id} toolCall={tc} />
        ))}

        <div ref={bottomRef} />
      </div>

      {/* Input */}
      <ChatInput />
    </div>
  );
}

4.聊天商店(Zustand)

// packages/web/src/stores/chat-store.ts
import { create } from 'zustand';
import { streamChat } from '../api/chat';

interface ChatState {
  messages: Message[];
  isStreaming: boolean;
  streamingContent: string;
  activeToolCalls: ToolCallInfo[];
  sessionId: string | null;

  sendMessage: (content: string) => Promise<void>;
  newSession: () => void;
}

export const useChatStore = create<ChatState>((set, get) => ({
  messages: [],
  isStreaming: false,
  streamingContent: '',
  activeToolCalls: [],
  sessionId: null,

  sendMessage: async (content: string) => {
    const sessionId = get().sessionId || crypto.randomUUID();

    // Add user message
    set((state) => ({
      sessionId,
      messages: [...state.messages, {
        id: crypto.randomUUID(),
        role: 'user',
        content,
        timestamp: new Date(),
      }],
      isStreaming: true,
      streamingContent: '',
    }));

    try {
      await streamChat(content, sessionId, (event) => {
        switch (event.type) {
          case 'text-delta':
            set((state) => ({
              streamingContent: state.streamingContent + event.delta,
            }));
            break;

          case 'tool-call-start':
            set((state) => ({
              activeToolCalls: [...state.activeToolCalls, {
                id: event.toolCallId,
                name: event.toolName,
                status: 'running',
              }],
            }));
            break;

          case 'tool-result':
            set((state) => ({
              activeToolCalls: state.activeToolCalls.filter(
                tc => tc.id !== event.toolCallId
              ),
            }));
            break;

          case 'finish':
            const finalContent = get().streamingContent;
            set((state) => ({
              messages: [...state.messages, {
                id: crypto.randomUUID(),
                role: 'assistant',
                content: finalContent,
                timestamp: new Date(),
              }],
              isStreaming: false,
              streamingContent: '',
              activeToolCalls: [],
            }));
            break;
        }
      });
    } catch (error) {
      set({ isStreaming: false, streamingContent: '' });
    }
  },

  newSession: () => set({
    messages: [],
    sessionId: null,
    streamingContent: '',
    isStreaming: false,
  }),
}));

5. 訊息氣泡

// packages/web/src/components/Chat/MessageBubble.tsx
import ReactMarkdown from 'react-markdown';

interface Props {
  message: Message;
  isStreaming?: boolean;
}

export function MessageBubble({ message, isStreaming }: Props) {
  const isUser = message.role === 'user';

  return (
    <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
      <div
        className={`max-w-[80%] rounded-2xl px-4 py-3 ${
          isUser
            ? 'bg-blue-600 text-white'
            : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
        }`}
      >
        {isUser ? (
          <p className="whitespace-pre-wrap">{message.content}</p>
        ) : (
          <div className="prose dark:prose-invert prose-sm max-w-none">
            <ReactMarkdown>{message.content}</ReactMarkdown>
          </div>
        )}

        {isStreaming && (
          <span className="inline-block w-2 h-4 bg-current animate-pulse ml-1" />
        )}
      </div>
    </div>
  );
}

6. 總結

  • 串流 UI — 逐一令牌顯示、工具呼叫指示器
  • Zustand — 輕量級狀態管理,無樣板
  • SSE 用戶端 — 在瀏覽器中解析伺服器發送的事件
  • Markdown 渲染 — AI 透過程式碼區塊、清單、表格回應

下一篇文章: 監控、可觀察性與生產部署。