前端转 AI 100 天 Day 44:前端集成实战——用 React 给 Agent 做个正式界面

前端转 AI 第 44 天:前端集成实战 - 用 React 给 Agent 做个正式界面

昨日回顾

Day 43 把 43 天所学整合成了一个完整的 Agent 后端。

但后端再牛,用户也得有个界面用。

今天发挥你的老本行------写前端。

Day 44 目标:用 React + Vite 搭一个正式的聊天界面,把 Day 43 的 Agent 包装成"能用的产品"。


一、为什么前端是"后端 Agent 的放大器"

1. 后端能跑 ≠ 用户能用

昨天的 Agent 只有 curl 能调。

用户要的是什么?

  • 一个输入框
  • 一个对话区
  • "正在输入"的反馈
  • 能点停止
  • 能看到历史
  • 能管理会话

这就是"AI 产品"和"AI API"的差距。

2. 前端是"AI 体验"的关键

同样一个 Agent,前端做得好坏,感受天差地别:

体验点 差的前端 好的前端
回答显示 等 5 秒一次弹出 打字机流式
等待反馈 白屏 / 没反应 "思考中..." + 动画
工具调用 无感知 "正在查询天气..."
出错 控制台报错 友好提示 + 重试按钮
长对话 卡顿 虚拟滚动 + 分页
会话管理 单次用完 列表 + 切换 + 删除

ChatGPT 不是因为它最强才火的------是它的"体验"最好。

3. 前端视角:这是"产品思维"

做过前端的你,一直在做这件事:

  • 后端返回 JSON → 前端要"设计成界面"
  • 后端返回错误 → 前端要"翻译成用户看得懂的"
  • 后端慢 → 前端要"用 loading / skeleton 撑住"

AI 产品也一样------ 前端的体验决定了用户是不是"愿意用"。

而且这次你是"全栈"------ 后端也是自己写的,前端可以按自己的设计来。


二、项目技术选型

1. 技术栈

不折腾,选最稳的:

层 选型 理由
构建 Vite 快,生态好
框架 React 18 熟
语言 TypeScript 类型安全
UI Tailwind CSS 快,不引组件库
状态 Zustand 轻,够用
HTTP fetch 原生,SSE 好处理
路由 React Router 会话切换
图标 lucide-react 好看、轻

为什么不用 Next.js / Nuxt?

AI 聊天界面是"客户端 SPA"------不需要 SSR / SEO。Vite 够用。

2. 项目结构

python 复制代码
agent-web/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
└── src/
    ├── main.tsx
    ├── App.tsx
    ├── api/
    │   └── client.ts              # API 封装
    ├── store/
    │   └── chat.ts                # Zustand store
    ├── hooks/
    │   ├── useStreamChat.ts       # 流式 hook
    │   └── useSessions.ts         # 会话 hook
    ├── components/
    │   ├── ChatLayout.tsx         # 整体布局
    │   ├── Sidebar.tsx            # 侧边栏(会话列表)
    │   ├── MessageList.tsx        # 消息列表
    │   ├── MessageItem.tsx        # 单条消息
    │   ├── ToolCallCard.tsx       # 工具调用展示
    │   ├── InputBox.tsx           # 输入框
    │   └── CostBadge.tsx          # 成本显示
    ├── types/
    │   └── index.ts
    └── utils/
        └── format.ts

3. 初始化

bash 复制代码
cd day44
npm create vite@latest agent-web -- --template react-ts
cd agent-web
npm install
npm install zustand lucide-react react-router-dom
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

tailwind.config.js:

js 复制代码
/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: { extend: {} },
  plugins: [],
};

src/index.css:

css 复制代码
@tailwind base;
@tailwind components;
@tailwind utilities;

html, body, #root {
  height: 100%;
  margin: 0;
}

三、类型定义与 API 封装

1. 类型

src/types/index.ts:

typescript 复制代码
export type Role = "user" | "assistant" | "system" | "tool";

export interface Message {
  id: string;
  role: Role;
  content: string;
  timestamp: number;
  // 工具相关
  toolName?: string;
  toolArgs?: Record<string, unknown>;
  toolResult?: string;
  toolStatus?: "running" | "done" | "error";
  // 流式状态
  streaming?: boolean;
}

export interface Session {
  id: string;
  title: string;
  updatedAt: number;
  messageCount: number;
}

// SSE 事件类型
export type StreamEvent =
  | { type: "meta"; session_id: string }
  | { type: "delta"; content: string }
  | { type: "tool_call"; name: string; args: Record<string, unknown> }
  | { type: "tool_result"; name: string; content: string }
  | { type: "done"; message_count: number }
  | { type: "error"; message: string };

export interface CostStats {
  total_calls: number;
  total_cost_yuan: number;
  avg_cost_yuan: number;
  models_used: string[];
}

2. API 封装

src/api/client.ts:

typescript 复制代码
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000";
const API_KEY = import.meta.env.VITE_API_KEY || "test-key";

async function request<T>(
  path: string,
  options: RequestInit = {}
): Promise<T> {
  const res = await fetch(`${API_BASE}${path}`, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": API_KEY,
      ...options.headers,
    },
  });

  if (!res.ok) {
    const error = await res.json().catch(() => ({ detail: res.statusText }));
    throw new Error(error.detail || `HTTP ${res.status}`);
  }

  return res.json();
}

// ============ 非流式对话 ============
export interface ChatRequest {
  message: string;
  session_id?: string;
}

export interface ChatResponse {
  session_id: string;
  reply: string;
  message_count: number;
}

export async function sendMessage(req: ChatRequest): Promise<ChatResponse> {
  return request("/api/chat", {
    method: "POST",
    body: JSON.stringify(req),
  });
}

// ============ 流式对话 ============
export async function* streamMessage(
  req: ChatRequest
): AsyncGenerator<import("../types").StreamEvent> {
  const res = await fetch(`${API_BASE}/api/chat/stream`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": API_KEY,
    },
    body: JSON.stringify(req),
  });

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

  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

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

      buffer += decoder.decode(value, { stream: true });
      const parts = buffer.split("\n\n");
      buffer = parts.pop() || "";

      for (const part of parts) {
        if (!part.trim()) continue;
        const line = part.split("\n").find((l) => l.startsWith("data: "));
        if (!line) continue;

        const data = JSON.parse(line.slice(6));
        yield data as import("../types").StreamEvent;
      }
    }
  } finally {
    reader.releaseLock();
  }
}

// ============ 会话管理 ============
export async function getSessionMessages(sessionId: string) {
  return request<{ session_id: string; messages: any[] }>(
    `/api/sessions/${sessionId}`
  );
}

export async function deleteSession(sessionId: string) {
  return request<{ ok: boolean }>(`/api/sessions/${sessionId}`, {
    method: "DELETE",
  });
}

// ============ 成本统计 ============
export async function getCostStats() {
  return request<import("../types").CostStats>("/api/stats/cost");
}

3. 环境变量

.env:

ini 复制代码
VITE_API_BASE=http://localhost:8000
VITE_API_KEY=test-key

注意: VITE_ 前缀的变量会被打进前端产物 ------所以 VITE_API_KEY 不是"真"密钥。

生产环境应该用登录 / Cookie 认证,不是把密钥写前端。这里为了简化。


四、核心 Hook:流式对话

src/hooks/useStreamChat.ts:

typescript 复制代码
import { useRef, useCallback } from "react";
import { streamMessage } from "../api/client";
import { useChatStore } from "../store/chat";
import type { StreamEvent } from "../types";

export function useStreamChat() {
  const abortRef = useRef<AbortController | null>(null);

  const {
    sessionId,
    addMessage,
    appendToLastMessage,
    updateToolCall,
    setSessionId,
    setStreaming,
    setError,
  } = useChatStore();

  const stop = useCallback(() => {
    abortRef.current?.abort();
    abortRef.current = null;
    setStreaming(false);
  }, [setStreaming]);

  const send = useCallback(
    async (text: string) => {
      if (!text.trim()) return;

      setError(null);

      // 1. 加用户消息
      addMessage({ role: "user", content: text });

      // 2. 加一个空的 assistant 消息(流式填充)
      addMessage({ role: "assistant", content: "", streaming: true });

      setStreaming(true);

      const controller = new AbortController();
      abortRef.current = controller;

      try {
        const stream = streamMessage({
          message: text,
          session_id: sessionId || undefined,
        });

        for await (const event of stream) {
          handleEvent(event);
        }
      } catch (err: any) {
        if (err.name === "AbortError") {
          appendToLastMessage("\n\n_[已停止]_");
        } else {
          setError(err.message || "请求失败");
          appendToLastMessage(`\n\n❌ 出错了:${err.message}`);
        }
      } finally {
        setStreaming(false);
        abortRef.current = null;
      }
    },
    [sessionId, addMessage, appendToLastMessage, setSessionId, setStreaming, setError]
  );

  const handleEvent = (event: StreamEvent) => {
    switch (event.type) {
      case "meta":
        setSessionId(event.session_id);
        break;

      case "delta":
        appendToLastMessage(event.content);
        break;

      case "tool_call":
        updateToolCall(event.name, event.args, "running");
        break;

      case "tool_result":
        updateToolCall(event.name, undefined, "done", event.content);
        break;

      case "done":
        // 结束时标记最后一条消息
        setStreaming(false);
        break;

      case "error":
        setError(event.message);
        appendToLastMessage(`\n\n❌ ${event.message}`);
        break;
    }
  };

  return { send, stop };
}

关键设计:

  • AbortController 支持"停止生成"
  • 按 type 分支处理 SSE 事件
  • appendToLastMessage 保证流式字符追加到同一条消息上

五、状态管理:Zustand

src/store/chat.ts:

typescript 复制代码
import { create } from "zustand";
import type { Message, Session } from "../types";

interface ChatStore {
  // 状态
  sessionId: string | null;
  messages: Message[];
  sessions: Session[];
  streaming: boolean;
  error: string | null;

  // 操作
  setSessionId: (id: string | null) => void;
  addMessage: (msg: Partial<Message>) => string;
  appendToLastMessage: (text: string) => void;
  updateToolCall: (
    name: string,
    args: Record<string, unknown> | undefined,
    status: "running" | "done" | "error",
    result?: string
  ) => void;
  setStreaming: (v: boolean) => void;
  setError: (e: string | null) => void;
  clearMessages: () => void;
  setSessions: (sessions: Session[]) => void;
  newSession: () => void;
}

function genId() {
  return Math.random().toString(36).slice(2, 10);
}

export const useChatStore = create<ChatStore>((set, get) => ({
  sessionId: null,
  messages: [],
  sessions: [],
  streaming: false,
  error: null,

  setSessionId: (id) => set({ sessionId: id }),

  addMessage: (msg) => {
    const id = genId();
    const message: Message = {
      id,
      role: msg.role || "user",
      content: msg.content || "",
      timestamp: Date.now(),
      streaming: msg.streaming,
      toolName: msg.toolName,
      toolArgs: msg.toolArgs,
      toolResult: msg.toolResult,
      toolStatus: msg.toolStatus,
    };
    set((state) => ({ messages: [...state.messages, message] }));
    return id;
  },

  appendToLastMessage: (text) => {
    set((state) => {
      const messages = [...state.messages];
      const last = messages[messages.length - 1];
      if (last && last.role === "assistant") {
        messages[messages.length - 1] = { ...last, content: last.content + text };
      }
      return { messages };
    });
  },

  updateToolCall: (name, args, status, result) => {
    set((state) => {
      // 在最后一条 assistant 消息里追加 / 更新一个工具调用
      const messages = [...state.messages];
      const last = messages[messages.length - 1];
      if (!last || last.role !== "assistant") return { messages };

      if (status === "running") {
        // 加新的一条"工具调用"消息
        messages.push({
          id: genId(),
          role: "tool",
          content: "",
          timestamp: Date.now(),
          toolName: name,
          toolArgs: args,
          toolStatus: "running",
        });
      } else {
        // 找最近的同名工具消息,更新
        for (let i = messages.length - 1; i >= 0; i--) {
          if (
            messages[i].role === "tool" &&
            messages[i].toolName === name &&
            messages[i].toolStatus === "running"
          ) {
            messages[i] = {
              ...messages[i],
              toolStatus: status,
              toolResult: result,
            };
            break;
          }
        }
      }

      return { messages };
    });
  },

  setStreaming: (v) => set({ streaming: v }),
  setError: (e) => set({ error: e }),
  clearMessages: () => set({ messages: [] }),
  setSessions: (sessions) => set({ sessions }),

  newSession: () => {
    set({ sessionId: null, messages: [], error: null });
  },
}));

为什么用 Zustand?

  • 轻:几行代码搞定,不像 Redux 一堆样板
  • 好测:store 就是普通函数
  • TS 友好:类型推断完美

前端同学会觉得很舒服。


六、组件实现

1. 整体布局

src/components/ChatLayout.tsx:

tsx 复制代码
import { Sidebar } from "./Sidebar";
import { MessageList } from "./MessageList";
import { InputBox } from "./InputBox";
import { CostBadge } from "./CostBadge";

export function ChatLayout() {
  return (
    <div className="flex h-full bg-gray-50">
      {/* 侧边栏 */}
      <Sidebar />

      {/* 主区域 */}
      <div className="flex-1 flex flex-col">
        {/* 顶部栏 */}
        <header className="h-14 px-6 flex items-center justify-between border-b bg-white">
          <h1 className="text-lg font-semibold">AI Agent</h1>
          <CostBadge />
        </header>

        {/* 消息列表 */}
        <div className="flex-1 overflow-hidden">
          <MessageList />
        </div>

        {/* 输入框 */}
        <div className="border-t bg-white">
          <InputBox />
        </div>
      </div>
    </div>
  );
}

2. 侧边栏(会话列表)

src/components/Sidebar.tsx:

tsx 复制代码
import { Plus, MessageSquare, Trash2 } from "lucide-react";
import { useChatStore } from "../store/chat";
import { deleteSession, getSessionMessages } from "../api/client";

export function Sidebar() {
  const {
    sessions,
    sessionId,
    setSessions,
    setSessionId,
    newSession,
    clearMessages,
    addMessage,
  } = useChatStore();

  const handleNew = () => {
    newSession();
  };

  const handleSelect = async (id: string) => {
    if (id === sessionId) return;
    try {
      const data = await getSessionMessages(id);
      clearMessages();
      setSessionId(id);
      data.messages.forEach((m: any) => {
        addMessage({ role: m.role, content: m.content });
      });
    } catch (e) {
      console.error(e);
    }
  };

  const handleDelete = async (id: string, e: React.MouseEvent) => {
    e.stopPropagation();
    if (!confirm("确定删除这个会话?")) return;
    try {
      await deleteSession(id);
      setSessions(sessions.filter((s) => s.id !== id));
      if (sessions.length === 1) newSession();
    } catch (e) {
      console.error(e);
    }
  };

  return (
    <aside className="w-64 bg-gray-900 text-gray-100 flex flex-col">
      {/* 新建按钮 */}
      <button
        onClick={handleNew}
        className="m-3 px-3 py-2 rounded border border-gray-700 hover:bg-gray-800 flex items-center gap-2 text-sm"
      >
        <Plus size={16} />
        新建对话
      </button>

      {/* 会话列表 */}
      <div className="flex-1 overflow-y-auto px-2">
        {sessions.length === 0 ? (
          <div className="text-xs text-gray-500 p-4 text-center">
            还没有会话
          </div>
        ) : (
          sessions.map((s) => (
            <div
              key={s.id}
              onClick={() => handleSelect(s.id)}
              className={`group px-3 py-2 rounded mb-1 cursor-pointer flex items-center gap-2 ${
                s.id === sessionId ? "bg-gray-700" : "hover:bg-gray-800"
              }`}
            >
              <MessageSquare size={14} className="flex-shrink-0" />
              <span className="flex-1 text-sm truncate">{s.title || "未命名"}</span>
              <button
                onClick={(e) => handleDelete(s.id, e)}
                className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-400"
              >
                <Trash2 size={14} />
              </button>
            </div>
          ))
        )}
      </div>
    </aside>
  );
}

3. 消息列表

src/components/MessageList.tsx:

tsx 复制代码
import { useEffect, useRef } from "react";
import { useChatStore } from "../store/chat";
import { MessageItem } from "./MessageItem";

export function MessageList() {
  const messages = useChatStore((s) => s.messages);
  const bottomRef = useRef<HTMLDivElement>(null);

  // 自动滚动到底部
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  if (messages.length === 0) {
    return (
      <div className="h-full flex items-center justify-center">
        <div className="text-center text-gray-400">
          <h2 className="text-2xl font-semibold mb-2">开始对话</h2>
          <p className="text-sm">试试问:"北京天气怎么样?"</p>
        </div>
      </div>
    );
  }

  return (
    <div className="h-full overflow-y-auto px-6 py-4">
      <div className="max-w-3xl mx-auto space-y-4">
        {messages.map((m) => (
          <MessageItem key={m.id} message={m} />
        ))}
        <div ref={bottomRef} />
      </div>
    </div>
  );
}

4. 单条消息

src/components/MessageItem.tsx:

tsx 复制代码
import type { Message } from "../types";
import { ToolCallCard } from "./ToolCallCard";

export function MessageItem({ message }: { message: Message }) {
  // 工具调用
  if (message.role === "tool") {
    return <ToolCallCard message={message} />;
  }

  const isUser = message.role === "user";

  return (
    <div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
      <div
        className={`max-w-[80%] rounded-lg px-4 py-2 ${
          isUser
            ? "bg-blue-600 text-white"
            : "bg-white border border-gray-200 text-gray-900"
        }`}
      >
        {!isUser && (
          <div className="text-xs font-semibold text-gray-500 mb-1">AI</div>
        )}
        <div className="whitespace-pre-wrap break-words">
          {message.content}
          {message.streaming && (
            <span className="inline-block w-2 h-4 bg-gray-400 ml-1 animate-pulse" />
          )}
        </div>
      </div>
    </div>
  );
}

5. 工具调用卡片

src/components/ToolCallCard.tsx:

tsx 复制代码
import { Loader2, CheckCircle2, XCircle, Wrench } from "lucide-react";
import type { Message } from "../types";

export function ToolCallCard({ message }: { message: Message }) {
  const { toolName, toolArgs, toolStatus, toolResult } = message;

  const icon = {
    running: <Loader2 size={14} className="animate-spin text-blue-500" />,
    done: <CheckCircle2 size={14} className="text-green-500" />,
    error: <XCircle size={14} className="text-red-500" />,
  }[toolStatus || "running"];

  const label = {
    running: "正在调用",
    done: "已完成",
    error: "失败",
  }[toolStatus || "running"];

  return (
    <div className="flex justify-start">
      <div className="max-w-[80%] bg-blue-50 border border-blue-200 rounded-lg px-4 py-2 text-sm">
        <div className="flex items-center gap-2 text-blue-700">
          <Wrench size={14} />
          <span className="font-medium">调用工具</span>
          <code className="bg-white px-1.5 py-0.5 rounded text-xs">{toolName}</code>
          {icon}
          <span className="text-xs text-blue-500">{label}</span>
        </div>

        {toolArgs && (
          <div className="mt-2 text-xs text-gray-600">
            <span className="text-gray-400">参数:</span>
            <code className="bg-white px-1 py-0.5 rounded">
              {JSON.stringify(toolArgs)}
            </code>
          </div>
        )}

        {toolResult && (
          <div className="mt-2 text-xs text-gray-600 border-t border-blue-100 pt-2">
            <span className="text-gray-400">结果:</span>
            <span className="break-all">{toolResult.slice(0, 200)}</span>
          </div>
        )}
      </div>
    </div>
  );
}

用户能"看到"AI 在做什么------不再是黑盒。

6. 输入框

src/components/InputBox.tsx:

tsx 复制代码
import { useState, useRef, useEffect } from "react";
import { Send, Square } from "lucide-react";
import { useChatStore } from "../store/chat";
import { useStreamChat } from "../hooks/useStreamChat";

export function InputBox() {
  const [text, setText] = useState("");
  const streaming = useChatStore((s) => s.streaming);
  const { send, stop } = useStreamChat();
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const handleSubmit = () => {
    if (streaming || !text.trim()) return;
    send(text);
    setText("");
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSubmit();
    }
  };

  // 自动调整高度
  useEffect(() => {
    const el = textareaRef.current;
    if (!el) return;
    el.style.height = "auto";
    el.style.height = Math.min(el.scrollHeight, 200) + "px";
  }, [text]);

  return (
    <div className="max-w-3xl mx-auto p-4">
      <div className="flex items-end gap-2 border rounded-xl bg-white shadow-sm focus-within:border-blue-400 transition">
        <textarea
          ref={textareaRef}
          value={text}
          onChange={(e) => setText(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="输入消息... (Enter 发送,Shift+Enter 换行)"
          className="flex-1 resize-none px-4 py-3 outline-none bg-transparent"
          rows={1}
          disabled={streaming}
        />

        {streaming ? (
          <button
            onClick={stop}
            className="m-2 p-2 bg-gray-200 hover:bg-gray-300 rounded-lg"
            title="停止生成"
          >
            <Square size={18} />
          </button>
        ) : (
          <button
            onClick={handleSubmit}
            disabled={!text.trim()}
            className="m-2 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
            title="发送"
          >
            <Send size={18} />
          </button>
        )}
      </div>

      <div className="mt-2 text-xs text-gray-400 text-center">
        AI 也会犯错,请核实重要信息
      </div>
    </div>
  );
}

细节:

  • textarea 自适应高度
  • Enter 发送,Shift+Enter 换行
  • 生成中显示"停止"按钮
  • 禁用状态有明显反馈

7. 成本显示

src/components/CostBadge.tsx:

tsx 复制代码
import { useEffect, useState } from "react";
import { DollarSign } from "lucide-react";
import { getCostStats } from "../api/client";
import type { CostStats } from "../types";
import { useChatStore } from "../store/chat";

export function CostBadge() {
  const [stats, setStats] = useState<CostStats | null>(null);
  const streaming = useChatStore((s) => s.streaming);

  useEffect(() => {
    getCostStats().then(setStats).catch(() => {});
  }, [streaming]);   // 每次对话完成刷新

  if (!stats || stats.total_calls === 0) return null;

  return (
    <div className="flex items-center gap-3 text-xs text-gray-500">
      <div className="flex items-center gap-1">
        <DollarSign size={14} />
        <span>累计 ¥{stats.total_cost_yuan.toFixed(4)}</span>
      </div>
      <div className="text-gray-300">|</div>
      <div>{stats.total_calls} 次调用</div>
    </div>
  );
}

让用户"感知成本"------这是个很酷的功能,也是"AI 产品"的特色。


七、入口与路由

src/App.tsx:

tsx 复制代码
import { useEffect } from "react";
import { ChatLayout } from "./components/ChatLayout";
import { useChatStore } from "./store/chat";
import { getCostStats } from "./api/client";

function App() {
  const setSessions = useChatStore((s) => s.setSessions);

  useEffect(() => {
    // 加载会话列表(这里简化:实际应该有后端接口)
    setSessions([]);
  }, [setSessions]);

  return <ChatLayout />;
}

export default App;

src/main.tsx:

tsx 复制代码
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

vite.config.ts:

typescript 复制代码
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      // 开发时不需要跨域
      "/api": {
        target: "http://localhost:8000",
        changeOrigin: true,
      },
    },
  },
});

开发时用 Vite 代理------避免了跨域问题,生产环境用 Nginx。


八、启动测试

1. 启动后端

bash 复制代码
cd day44/ai-agent   # Day 43 的项目
uvicorn ai_agent.api.app:app --reload

2. 启动前端

bash 复制代码
cd day44/agent-web
npm run dev

**浏览器打开 http://localhost:5173**:

arduino 复制代码
┌────────────────────────────────────────────┐
│ AI Agent                          ¥0.0012  │
├─────────┬──────────────────────────────────┤
│         │                                  │
│ + 新建  │        开始对话                   │
│         │                                  │
│ 会话 1  │  试试问:"北京天气怎么样?"        │
│ 会话 2  │                                  │
│ 会话 3  │                                  │
│         │                                  │
│         ├──────────────────────────────────┤
│         │  ┌──────────────────────────┐   │
│         │  │ 输入消息...          📤  │   │
│         │  └──────────────────────────┘   │
└─────────┴──────────────────────────────────┘

3. 测试流式对话

输入:"北京天气怎么样?顺便算一下 25 的平方"

实时效果:

css 复制代码
你:北京天气怎么样?顺便算一下 25 的平方

🔧 调用工具 get_weather {city: "北京"} ✅ 已完成
   结果:{"city":"北京","temp":18,"condition":"晴"}

🔧 调用工具 calculator {expression: "25**2"} ✅ 已完成
   结果:{"expression":"25**2","result":625}

AI:北京今天晴,18°C。25 的平方是 625。

用户能"看到"AI 做了什么,这是最有魅力的体验。

4. 测试停止

发一条复杂请求,生成中点击"停止"按钮:

效果:

css 复制代码
AI:这是一段很长的回答... [已停止]

AbortController 会中断请求。

5. 测试历史会话

刷新页面,点击侧边栏的会话------消息恢复。

(这个功能需要后端实现 /api/sessions 列表接口,这里简化了,实际项目要加上。)


九、部署:前端 + 后端一体

1. 构建前端

bash 复制代码
cd day44/agent-web
npm run build
# 产物:dist/

dist/ 是纯静态文件。

2. 用 Nginx 一起部署

nginx/conf.d/agent.conf:

nginx 复制代码
server {
    listen 443 ssl;
    http2 on;
    server_name agent.your-domain.com;

    ssl_certificate /etc/nginx/certs/live/agent.your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/live/agent.your-domain.com/privkey.pem;

    # 前端静态文件
    root /var/www/agent;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # API 反向代理
    location /api/ {
        proxy_pass http://api_backend;

        # 流式关键
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_buffering off;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 流式请求可能很久
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }
}

一个域名,前后端全搞定。

3. Docker 化(可选)

agent-web/Dockerfile:

dockerfile 复制代码
# ============ Stage 1: 构建 ============
FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build


# ============ Stage 2: Nginx ============
FROM nginx:alpine

COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

agent-web/nginx.conf:

nginx 复制代码
server {
    listen 80;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://api:8000;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_buffering off;
    }
}

加到 Day 30 的 docker-compose.prod.yml 里:

yaml 复制代码
services:
  web:
    build: ./agent-web
    container_name: agent-web
    ports:
      - "80:80"
    depends_on:
      - api
    restart: always

一键部署前后端。


十、前端视角:这次你终于"用上了"老本行

44 天来,你一直在"学后端"。

今天,你终于回到了"舒适区"------ 写前端。

而且这次是"更有意思的前端":

传统前端 AI 前端
展示表格 展示"AI 思考过程"
表单校验 流式打字机
WebSocket 聊天 SSE 流式
Loading 转圈 "思考中..."动画
分页 会话列表
图表 成本可视化

"AI 前端"是前端的一个新方向------ 它需要的不只是"技术",更是"体验设计"。

你有"用户体验"的直觉------这正是"纯后端"工程师缺乏的。

从今天起,你不只是"前端",还是"AI 产品的界面设计师"。


十一、今日踩坑

  1. EventSource 不支持 POST → 用 fetch 手动处理 SSE
  2. TextDecoder 没加 { stream: true } → 中文跨 chunk 时乱码
  3. buffer.split("\n\n") 最后一段残留 → 要 pop() 保留不完整的部分
  4. VITE_API_KEY 是"公开的" → 生产不能用密钥,要用登录认证
  5. AbortController 忘了 abort() → 停止按钮失效
  6. textarea 高度不自动调整 → 长文本溢出,加 useEffect 调整
  7. setMessages 直接 push 后不更新 → Zustand 要创建新数组
  8. 流式回答的 streaming 光标不消失 → done 时记得清掉
  9. getSessionMessages 类型不对 → 后端返回的 messages 是 any[],要做类型转换
  10. Vite 代理没配 /api → 开发时跨域
  11. Nginx 没配 proxy_buffering off → 流式失效(最重要)
  12. tool_call 和 tool_result 重复渲染 → 要区分"新建"和"更新"
  13. sessionId 跨请求丢失 → 用 useRef 或 store 保证
  14. useEffect 依赖数组漏字段 → ESLint 警告,要加上
  15. 前端打包体积太大 → 用 lucide-react 按需引入,别 import 全部

十二、今日总结

今天核心收获三件事:

  1. 完整的 AI 前端:流式对话 + 工具可视化 + 会话管理 + 成本显示
  2. SSE 前端处理模式 :fetch + ReadableStream + TextDecoder + 按 \n\n 分割
  3. AI 前端的独特之处:不只是"展示数据",是"展示过程"(思考 / 工具调用 / 流式)

一个感受:

43 天你一直在学"AI 后端",今天你终于"用了自己的老本行"。

而且发现------前端经验在 AI 时代更值钱了:

  • 流式输出的体验设计 → 前端最懂
  • 复杂状态管理 → 前端最熟
  • 实时交互 → 前端本行
  • 视觉呈现 → 前端强项

AI 产品最缺的就是"体验"------很多 AI 产品后端很强,前端像 demo。

你能做"好看、好用、好体验"的 AI 产品------这就是你的差异化。

"前端转 AI"不是"放弃前端",是"把前端能力用到 AI 上"。

Day 44 完成度:✅

明日预告(Day 45)

  • 阶段复盘 :
    • Day 31-44 完整回顾
    • 从"LLM 应用"到"AI 产品"的跨越
    • 完整项目的简历包装
    • RAG 阶段预告:让 Agent "读懂文档"

最后

如果这篇对你有用,点个赞让我知道有人在看。

评论区互动: 你的前端界面长什么样?跑起来了吗?欢迎截图分享(或者描述一下)。

有问题评论区见,我们第 45 天继续。

100 天,第 45 天见。


附录:系列目录

第一阶段(已完成 ✅)

阶段 天数 内容
Python 基础 01-15 语法 + 命令行助手
后端工程化 16-30 数据库/测试/FastAPI/Docker/上线
LLM 应用 31-42 调用/流式/工具/结构化/成本/多模型

进行中 🎯

阶段 天数 内容
LLM 应用 43-45 综合项目 + 前端集成 + 复盘
RAG 46-60 向量数据库 + 知识库
Agent 61-80 LangChain / ReAct / 工具调用
完整项目 81-100 上线一个真实 Agent 产品

44 天已过,56 天继续。

一起从切图仔变成 Agent 开发者。

相关推荐
jay神17 分钟前
基于 YOLOv8 的手势识别系统(Web 版)
前端·人工智能·python·深度学习·yolo·计算机视觉·计算机毕业设计
DeepVisionary25 分钟前
阿里云上线旗舰多模态新模型,不公布跑分且限时14天退役
python·自动化
东莞市云毅网络有限公司26 分钟前
FAQ 覆盖度打分:TF-IDF 与向量嵌入双路召回的 Python 实现
python·数据清洗·rag·企业知识库·文档解析
爱笑的k1140 分钟前
nano-vllm 源码剖析:千行 Python 手写离线推理引擎的调度、显存与并行时序
开发语言·python·vllm
用户8356290780511 小时前
使用 Python 控制 Excel 工作表的分页位置
后端·python
用户8356290780512 小时前
Python 如何在 Word 中添加和管理文档变量
后端·python
用户019027581614 小时前
Dify / Coze 低代码 Agent 怎么接 A 股行情?
python
Dumbness5 小时前
不传 day 参数,座位图显示全空:用 Python 标准库手写 Web 框架,请求到业务代码前的 5 个细节
后端·python
databook6 小时前
收上来一堆 Excel,怎么保证没改错
python·数据分析