Fast Car

Back

这是【AskAI系列课程】的第4课:将前面构建的智能AI助手真正集成到网站前端,让访客能够直接与AI对话。

2214X1080/p4.gif

在前面的课程中,我们已经完成了整个后端服务的搭建:

  • P1课:接入Agno框架,创建智能Assistant
  • P2课:通过脚本批量上传文档到阿里云百炼知识库
  • P3课:实现从百炼知识库检索并回答用户问题

现在到了最激动人心的时刻——让AI助手真正”现身”在我们的网站上,为访客提供智能问答服务!

你可以在我的网站右下角看到这个可爱的AI助手图标,点击即可体验。

整体架构设计#

在开始具体实现之前,让我先展示整个系统的架构设计。这个AI助手系统主要由三个部分组成:

graph TB subgraph "前端层 (Astro + React)" A[FloatingAskAI.astro] --> B[FloatingAskAI.tsx] B --> C[XChatUI.tsx] C --> D[AntDesign X 聊天组件] C --> E[API调用模块] end subgraph "后端服务层 (Python)" F[FastAPI服务器] --> G[Agno AI框架] G --> H[智能助手Agent] H --> I[知识检索器] end subgraph "知识库层" I --> J[阿里云百炼知识库] J --> K[网站内容文档] end subgraph "AI服务层" H --> L[OpenAI兼容API] L --> M[Kimi大模型] end E -.HTTP Stream.-> F style A fill:#e1f5fe style F fill:#fff3e0 style J fill:#f3e5f5 style L fill:#e8f5e8

这个架构的核心思路是将AI能力封装为后端服务,前端通过HTTP流式请求获取实时回答,同时利用知识库提供准确的上下文信息。

后端架构回顾#

在开始前端集成之前,让我们快速回顾一下后端架构。在前面的课程中,我们已经构建了一个完整的智能助手后端:

核心组件#

  1. Agno Agent:基于P1课搭建的智能助手框架
  2. 知识库检索器:基于P3课实现的百炼知识库集成
  3. FastAPI服务:通过AgentOS提供的Web API接口
# 核心Agent配置(来自前面的课程)
assistant = Agent(
    name="Assistant",
    id="fastcar-assistant",
    model=OpenAILike(
        id=os.getenv("ARK_BIG_THINKING_MODEL"),
        api_key=os.getenv("ARK_API_KEY"),
        base_url=os.getenv("ARK_BASE_URL"),
    ),
    instructions=[
        "你是fastcar.fun网站的AI助手,请根据用户的问题给出回答。",
        "你的回答应该简洁且有礼貌。",
    ],
    markdown=True,
    db=db,
    knowledge_retriever=smart_bailian_retriever,  # P3课实现的检索器
    search_knowledge=True,
)
python

API接口#

后端服务提供了标准的对话接口:

  • POST /agents/fastcar-assistant/runs - 发起对话
  • 支持流式输出 - 实时返回AI回答
  • 自动知识检索 - 根据问题自动调用知识库

现在我们的任务是在前端实现一个用户友好的界面来调用这些API。

前端集成:在Astro中使用React组件#

Astro + React 集成配置#

要在Astro项目中使用React组件,首先需要正确配置:

// astro.config.ts
import react from '@astrojs/react'
import { defineConfig } from 'astro/config'

export default defineConfig({
  integrations: [
    react(), // 启用React集成
    // 其他集成...
  ],
  // 其他配置...
})
typescript
{
  "dependencies": {
    "@astrojs/react": "4.3.1",
    "@ant-design/x": "1.6.1",
    "react": "19.1.1",
    "react-dom": "19.1.1"
  }
}
json

这里需要注意的是,我选择了最新的 React 19 版本,配合 AntDesign X 1.6.1,这个组合提供了最佳的开发体验和性能。

组件架构设计#

前端组件采用三层架构:

graph TD A[FloatingAskAI.astro
Astro组件层] --> B[FloatingAskAI.tsx
React容器组件] B --> C[XChatUI.tsx
聊天界面组件] C --> D[AntDesign X组件
UI组件库] B --> E[拖拽逻辑] B --> F[位置存储] C --> G[聊天状态管理] C --> H[主题适配] C --> I[API通信] style A fill:#e3f2fd style B fill:#fff3e0 style C fill:#f3e5f5 style D fill:#e8f5e8

Astro组件包装器#

首先是最外层的Astro组件,它的作用是将React组件嵌入到Astro页面中:

---
import FloatingAskAIComponent from './FloatingAskAI.tsx'
---

<!-- 悬浮 AskAI 按钮 -->
<FloatingAskAIComponent client:load />
astro

这里的 client:load 指令告诉Astro在页面加载时立即在客户端渲染这个React组件。这对于交互性强的组件是必需的。

React容器组件实现#

接下来是核心的React容器组件,它管理着悬浮按钮的所有行为:

import React, { useState, useRef, useEffect, Suspense, lazy } from 'react'

const XChatUI = lazy(() => import('./XChatUI'))

const FloatingAskAI: React.FC = () => {
  const [open, setOpen] = useState(false)
  const [position, setPosition] = useState<Position>({ x: 0, y: 0 })
  const [isDragging, setIsDragging] = useState(false)
  
  // 位置恢复和保存逻辑
  useEffect(() => {
    const savedPosition = localStorage.getItem('askAI-position')
    if (savedPosition) {
      const parsed = JSON.parse(savedPosition)
      // 验证位置是否在视窗范围内
      if (parsed.x >= 0 && parsed.x <= window.innerWidth - 68) {
        setPosition(parsed)
      }
    } else {
      // 默认位置:右下角
      setPosition({
        x: window.innerWidth - 68,
        y: window.innerHeight - 68
      })
    }
  }, [])
  
  // 拖拽处理逻辑...
  
  return (
    <>
      <button
        className="fixed z-50 flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-lg border border-gray-200"
        style={{
          left: `${position.x}px`,
          top: `${position.y}px`,
          cursor: isDragging ? 'grabbing' : 'grab',
        }}
        onClick={() => setOpen(true)}
      >
        <img src="https://img.icons8.com/fluency/96/bard.png" alt="AskAI" />
      </button>

      {open && (
        <Suspense fallback={null}>
          <XChatUI onClose={() => setOpen(false)} />
        </Suspense>
      )}
    </>
  )
}
typescript

这个组件有几个巧妙的设计:

  1. 懒加载:使用 lazy()Suspense 来懒加载聊天界面,避免首屏加载时间过长
  2. 位置持久化:将按钮位置保存到 localStorage,用户下次访问时会记住位置
  3. 拖拽功能:支持用户自由拖拽按钮到合适的位置
  4. 响应式设计:自动处理不同屏幕尺寸下的位置限制

AntDesign X 聊天界面#

聊天界面是用户体验的核心,我选择了蚂蚁集团开源的 AntDesign X 框架:

import { Bubble, Sender, useXAgent, useXChat, Welcome, XProvider } from '@ant-design/x'
import type { BubbleProps } from '@ant-design/x'

const XChatUI: React.FC<{ onClose: () => void }> = ({ onClose }) => {
  const agent = useRealAgent()
  const { messages, onRequest } = useXChat({ agent })
  
  // Markdown渲染配置
  const renderMarkdown: BubbleProps['messageRender'] = (content) => {
    return (
      <Typography>
        <div dangerouslySetInnerHTML={{ __html: md.render(content) }} />
      </Typography>
    )
  }
  
  return (
    <XProvider theme={themeConfig}>
      <div className="fixed inset-0 z-[60] flex items-center justify-center">
        <div className="absolute inset-0 bg-black/40" onClick={onClose} />
        
        <div className="relative mx-auto h-[100svh] w-[100svw] md:h-[80vh] md:max-w-4xl md:w-full md:rounded-2xl bg-background">
          {/* 聊天内容区域 */}
          <div className="min-h-0 flex-1 overflow-y-auto p-4">
            {messages.length === 0 && (
              <Welcome
                icon="✨"
                title="欢迎来到我的网站!"
                description="我是您的智能助手,可以帮您解答各种关于我的网站的问题。"
              />
            )}
            
            {messages.length > 0 && (
              <Bubble.List 
                items={messages.map(msg => ({
                  content: msg.message,
                  placement: msg.status === 'local' ? 'end' : 'start',
                  messageRender: msg.status === 'local' ? undefined : renderMarkdown
                }))} 
              />
            )}
          </div>
          
          {/* 输入区域 */}
          <div className="border-t p-3">
            <Sender
              placeholder="请输入你的问题…"
              onSubmit={(text) => {
                if (text.trim()) {
                  onRequest(text)
                }
              }}
            />
          </div>
        </div>
      </div>
    </XProvider>
  )
}
typescript

为什么选择AntDesign X?#

在选择聊天界面框架时,我考虑了多个方案,最终选择AntDesign X有以下原因:

  1. 专业的聊天体验:AntDesign X是专门为AI对话场景设计的,提供了流式输出、加载状态、错误处理等开箱即用的功能
  2. React 19兼容性:与最新的React版本有良好的兼容性
  3. 主题适配:支持深色/浅色主题自动切换,与我的网站主题系统无缝集成
  4. Markdown支持:内置支持Markdown渲染,AI回答可以包含格式化文本
  5. TypeScript支持:完整的类型定义,开发体验很好

API通信:实现流式对话#

流式请求处理#

为了提供流畅的对话体验,我实现了基于Server-Sent Events的流式通信:

export const sendStreamRequest = async (
  message: string,
  callbacks: StreamCallbacks
): Promise<void> => {
  const { onMessage, onError, onComplete } = callbacks
  
  const formData = new URLSearchParams()
  formData.set('message', message)
  formData.set('stream', 'true')
  
  try {
    const response = await fetch(apiEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
      },
      body: formData.toString(),
    })
    
    const reader = response.body?.getReader()
    const decoder = new TextDecoder()
    let buffer = ''
    
    while (true) {
      const { done, value } = await reader.read()
      
      if (done) {
        onComplete?.()
        break
      }
      
      buffer += decoder.decode(value, { stream: true })
      const lines = buffer.split('\n')
      buffer = lines.pop() || ''
      
      for (const line of lines) {
        if (line.startsWith('data:')) {
          const jsonText = line.slice(5).trim()
          try {
            const parsed = JSON.parse(jsonText)
            if (parsed.event === 'RunContent' && parsed.content) {
              onMessage?.(parsed.content)
            }
          } catch (e) {
            console.warn('Failed to parse SSE data:', e)
          }
        }
      }
    }
  } catch (error) {
    onError?.(error as Error)
  }
}
typescript

这个实现的关键在于正确处理SSE数据流的分包问题,确保每个JSON事件都能被正确解析和处理。

环境配置管理#

在Astro项目中,环境变量的处理需要特别注意:

const getApiBaseUrl = (): string => {
  // 在Astro中使用 import.meta.env 访问环境变量
  const serverUrl = import.meta.env.PUBLIC_AGNO_SERVER
  
  if (serverUrl) {
    return serverUrl
  }
  
  // 开发环境默认值
  return 'http://localhost:7777'
}
typescript

注意这里的环境变量必须以 PUBLIC_ 开头,才能在客户端代码中访问。

主题集成:无缝融入网站设计#

动态主题适配#

我的网站支持深色和浅色主题切换,AI助手界面也需要跟随主题变化:

const XChatUI: React.FC = ({ onClose }) => {
  const [isDark, setIsDark] = useState(false)
  
  // 主题检测
  useEffect(() => {
    const detectTheme = () => {
      const isDarkMode = document.documentElement.classList.contains('dark')
      setIsDark(isDarkMode)
    }
    
    detectTheme()
    
    // 监听主题变化
    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
          detectTheme()
        }
      })
    })
    
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['class']
    })
    
    return () => observer.disconnect()
  }, [])
  
  // 动态主题配置
  const themeConfig = useMemo(() => ({
    token: {
      colorBgContainer: isDark ? 'hsl(var(--card))' : 'hsl(var(--background))',
      colorText: isDark ? 'hsl(var(--card-foreground))' : 'hsl(var(--foreground))',
      colorPrimary: isDark ? 'hsl(var(--primary))' : 'hsl(var(--primary))',
      // 更多主题配置...
    }
  }), [isDark])
  
  return (
    <XProvider theme={themeConfig}>
      {/* 聊天界面内容 */}
    </XProvider>
  )
}
typescript

这样当用户切换网站主题时,AI助手界面也会自动跟随变化,保持视觉一致性。

CSS变量系统#

为了更好地集成主题,我利用了CSS变量系统:

.dark-input {
  background-color: hsl(240 10% 3.9%);
  color: hsl(0 0% 98%);
  border-color: hsl(240 3.7% 19.9%);
}

.light-input {
  background-color: hsl(210 33% 99%);
  color: hsl(240 10% 3.9%);
  border-color: hsl(240 5.9% 88%);
}
css

这些样式类会根据主题状态动态应用,确保界面元素在不同主题下都有良好的可读性。

数据流全链路分析#

让我通过一个完整的交互流程来展示整个系统的数据流:

sequenceDiagram participant User as 用户 participant FloatingBtn as 悬浮按钮 participant ChatUI as 聊天界面 participant API as API客户端 participant Backend as 后端服务 participant KB as 知识库 participant LLM as 大模型 User->>FloatingBtn: 点击悬浮按钮 FloatingBtn->>ChatUI: 打开聊天界面 ChatUI->>ChatUI: 显示欢迎消息 User->>ChatUI: 输入问题 ChatUI->>API: 发送流式请求 API->>Backend: HTTP POST /agents/fastcar-assistant/runs Backend->>KB: 查询相关知识 KB-->>Backend: 返回匹配文档 Backend->>LLM: 发送增强后的提示 LLM-->>Backend: 流式返回回答 Backend-->>API: SSE数据流 API-->>ChatUI: 实时文本片段 ChatUI->>ChatUI: 渐进式显示回答 ChatUI->>User: 展示完整回答

这个流程有几个关键特点:

  1. 实时性:从用户提问到看到回答开始出现,通常在1-2秒内
  2. 渐进式:回答是逐字显示的,用户可以实时看到AI的思考过程
  3. 准确性:通过知识库检索,确保回答基于真实的网站内容
  4. 体验性:整个交互过程流畅自然,就像在和真人对话

开发环境配置#

后端服务启动#

确保你的后端服务正在运行:

# 进入后端目录
cd ask-ai-server

# 启动开发服务器
python fastcar_os.py
bash

服务默认运行在 http://localhost:7777,提供API接口给前端调用。

环境变量配置#

在项目根目录的 .env 文件中配置前端环境变量:

# 后端API服务地址
PUBLIC_AGNO_SERVER=http://localhost:7777
bash

注意:在Astro中,客户端可访问的环境变量必须以 PUBLIC_ 开头。

跨域配置#

确保后端服务已正确配置CORS:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://fastcar.fun", "http://localhost:4321"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["*"],
)
python

这样前端就能正常与后端API通信了。

总结与思考#

通过这次完整的AI助手集成项目,我深刻体会到了现代Web开发中前后端分离架构的强大和灵活性。这个项目不仅让我的网站变得更加智能和互动,也让我对以下几个技术方向有了更深的理解:

技术架构方面

  • Astro的客户端渲染指令让我们能够精确控制哪些组件需要客户端交互
  • React 19与AntDesign X的结合提供了出色的开发体验
  • 流式API设计大大提升了用户体验

用户体验方面

  • 可拖拽的悬浮按钮让用户可以自定义界面布局
  • 渐进式回答显示增加了对话的真实感
  • 主题适配确保了视觉一致性

部署运维方面

  • 容器化部署简化了生产环境管理
  • 环境变量配置让不同环境的切换变得简单
  • 健康检查和日志管理提升了系统可靠性

这个完整的AskAI系列到此告一段落,我们从零开始构建了一个完整的智能问答系统:

P1课 - 搭建了基于Agno的智能助手框架
P2课 - 实现了文档自动同步到知识库的脚本
P3课 - 完成了知识库检索功能的集成
P4课 - 将AI助手真正部署到了网站前端

通过这个系列,我们不仅实现了一个实用的功能,更重要的是掌握了现代AI应用开发的完整技术栈。

接下来,我计划继续完善这个系统,比如增加对话历史保存、多轮对话优化、语音输入支持等功能。同时也会探索更多AI应用场景,如会议助手、客户信息收集、日志分析等。

如果你对这个系列感兴趣,或者有任何问题想要交流,欢迎通过右下角的AI助手直接与我对话,或者在评论区留言讨论。让我们一起探索AI时代Web开发的无限可能!

【AskAI系列课程】:P4.将AI助手集成到Astro网站前端
https://astro-pure.js.org/blog/website-rag/p4-add-ask-ai-to-my-site
Author Oliver Yeung
Published at 2025年9月23日
Comment seems to stuck. Try to refresh?✨