第 3 篇:用 Vite + React 调用 Dify API,做一个最小 AI 聊天页面

4 阅读6分钟

项目地址

说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。


前言

上一篇我们已经在 Dify 里搭好了一个最小 RAG 应用:

用户输入 → 知识检索 → LLM → 直接回复

并且解决了一个很容易踩的坑:知识检索节点命中了文档,但 LLM 没有真正读到上下文。

这一篇开始写前端代码。

目标很简单:

用 Vite + React 做一个最小 AI 聊天页面,能够输入问题、发送请求、展示 Dify 返回的回答。

不过这里先说明一下:这一篇会先做一个最小可用版本,重点是跑通前端调用链路。API Key 安全问题会在下一篇用 Express BFF 代理来解决。

最终我们会把调用方式从:

浏览器前端 → Dify API

升级成:

浏览器前端 → Express BFF → Dify API

但学习时最好一步一步来,先看清楚 Dify API 怎么调用,再抽到服务端。


本篇目标

完成后页面应该具备这些能力:

1. 有一个输入框
2. 有一个发送按钮
3. 能把用户问题发送给 Dify
4. 能展示 AI 返回内容
5. 有基础 loading 状态
6. 支持多轮 conversationId

这一篇暂时不做:

流式输出
Markdown 渲染
引用来源
多会话管理
Express 代理
数据库
登录

这些后面都会一步步加。


第一步:创建 Vite React 项目

在本地创建项目:

npm create vite@latest dify-chat-demo -- --template react-ts
cd dify-chat-demo
npm install
npm run dev

启动成功后,浏览器打开:

http://localhost:5173

能看到 Vite 默认页面,说明项目创建成功。


第二步:准备 Dify 应用 API Key

进入 Dify 应用,找到:

访问 API

复制应用 API Key。

一般长这样:

app-xxxxxxxxxxxxxxxx

注意,这个 Key 不要提交到 GitHub。

Dify 的 Chat Messages API 一般是:

https://api.dify.ai/v1/chat-messages

如果你是私有部署 Dify,则换成自己的 API 地址。


第三步:配置环境变量

在项目根目录创建:

.env.local

写入:

VITE_DIFY_API_KEY=你的 Dify 应用 API Key
VITE_DIFY_API_URL=https://api.dify.ai/v1/chat-messages

注意:

这里用了 VITE_ 前缀,是因为 Vite 只有以 VITE_ 开头的环境变量才会暴露给浏览器。

但这也意味着:

这个 Key 会出现在前端构建产物里。

所以这一篇的写法只适合本地学习和快速验证。下一篇我们会把 Key 移到 Express 服务端。

确保 .gitignore 里包含:

.env
.env.local

第四步:封装 Dify API 调用

创建文件:

src/api/dify.ts

写入:

export type DifyChatResponse = {
  answer: string
  conversation_id: string
  [key: string]: unknown
}

export type ChatResponse = {
  answer: string
  conversationId: string
}

const DIFY_API_KEY = import.meta.env.VITE_DIFY_API_KEY
const DIFY_API_URL =
  import.meta.env.VITE_DIFY_API_URL || 'https://api.dify.ai/v1/chat-messages'

export async function sendMessageToDify(
  message: string,
  conversationId?: string
): Promise<ChatResponse> {
  if (!DIFY_API_KEY) {
    throw new Error('Missing VITE_DIFY_API_KEY')
  }

  const response = await fetch(DIFY_API_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${DIFY_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      inputs: {},
      query: message,
      response_mode: 'blocking',
      conversation_id: conversationId || '',
      user: 'vite-demo-user',
    }),
  })

  if (!response.ok) {
    const errorText = await response.text()
    throw new Error(errorText || 'Dify request failed')
  }

  const data = (await response.json()) as DifyChatResponse

  return {
    answer: data.answer,
    conversationId: data.conversation_id,
  }
}

这里有几个关键参数:

inputs

inputs: {}

如果 Dify 应用里定义了额外输入变量,就需要在这里传。我们当前没有,所以传空对象。

query

query: message

用户输入的问题。

response_mode

response_mode: 'blocking'

blocking 表示等 Dify 完整生成回答后一次性返回。

后面做流式输出时,会改成:

response_mode: 'streaming'

conversation_id

conversation_id: conversationId || ''

Dify 用这个字段维持多轮对话。

第一次请求传空字符串,Dify 返回一个新的 conversation_id。后续请求带上它,就能继续同一个会话。

user

user: 'vite-demo-user'

这是 Dify 用来标识终端用户的字段。学习阶段先写固定值即可。


第五步:定义消息类型

创建文件:

src/types/chat.ts

写入:

export type Role = 'user' | 'assistant'

export type Message = {
  role: Role
  content: string
}

消息列表里会有两类消息:

user:用户输入
assistant:AI 回答

第六步:实现最小 App 页面

修改:

src/App.tsx

写入:

import { useState } from 'react'
import { sendMessageToDify } from './api/dify'
import type { Message } from './types/chat'
import './App.css'

function App() {
  const [input, setInput] = useState('')
  const [messages, setMessages] = useState<Message[]>([])
  const [conversationId, setConversationId] = useState<string>()
  const [loading, setLoading] = useState(false)

  async function handleSend() {
    const text = input.trim()

    if (!text || loading) return

    setInput('')
    setLoading(true)

    setMessages(prev => [...prev, { role: 'user', content: text }])

    try {
      const result = await sendMessageToDify(text, conversationId)

      setConversationId(result.conversationId)

      setMessages(prev => [
        ...prev,
        {
          role: 'assistant',
          content: result.answer,
        },
      ])
    } catch (error) {
      console.error(error)

      setMessages(prev => [
        ...prev,
        {
          role: 'assistant',
          content:
            error instanceof Error
              ? `请求失败:${error.message}`
              : '请求失败,请稍后重试。',
        },
      ])
    } finally {
      setLoading(false)
    }
  }

  function handleClear() {
    setMessages([])
    setConversationId(undefined)
  }

  return (
    <div className="app">
      <h1>Frontend AI Assistant</h1>

      <div className="messages">
        {messages.map((message, index) => (
          <div
            key={index}
            className={`message ${message.role === 'user' ? 'user' : 'ai'}`}
          >
            <strong>{message.role === 'user' ? '你' : 'AI'}:</strong>
            <div>{message.content}</div>
          </div>
        ))}

        {loading && <div className="loading">AI 正在思考...</div>}
      </div>

      <textarea
        value={input}
        onChange={event => setInput(event.target.value)}
        onKeyDown={event => {
          if (event.key === 'Enter' && !event.shiftKey) {
            event.preventDefault()
            handleSend()
          }
        }}
        placeholder="请输入你的问题,按 Enter 发送,Shift + Enter 换行"
        rows={4}
      />

      <div className="actions">
        <button onClick={handleSend} disabled={loading || !input.trim()}>
          {loading ? '发送中...' : '发送'}
        </button>
        <button onClick={handleClear} disabled={loading}>
          清空会话
        </button>
      </div>
    </div>
  )
}

export default App

第七步:加一点基础样式

修改:

src/App.css

写入:

.app {
  max-width: 720px;
  margin: 40px auto;
  padding: 16px;
  font-family:
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
}

.messages {
  margin: 24px 0;
}

.message {
  margin-bottom: 12px;
  padding: 12px;
  border-radius: 8px;
  line-height: 1.6;
}

.message.user {
  background: #e8f3ff;
}

.message.ai {
  background: #f5f5f5;
}

.loading {
  color: #666;
  margin-bottom: 12px;
}

textarea {
  width: 100%;
  box-sizing: border-box;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  resize: vertical;
  font: inherit;
}

.actions {
  display: flex;
  gap: 8px;
  margin-top: 12px;
}

button {
  padding: 8px 14px;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

第八步:测试知识库问答

启动项目:

npm run dev

在页面里输入:

前端架构主要包括哪些内容?

如果 Dify 应用配置正确,应该能返回类似:

前端架构主要包括项目分层、组件设计、状态管理、权限控制、性能优化、工程化和可观测性。

再问:

大型前端项目可以分为哪几层?

理想回答:

大型前端项目可以分为 UI 层、业务逻辑层、数据请求层、基础设施层。

如果这两个问题都能回答,说明:

React 页面 → Dify Chat API → Dify 知识库 → LLM 回答

这条链路已经跑通。


第九步:验证多轮对话

Dify 每次返回时会带上:

conversation_id

我们在代码里保存到了:

const [conversationId, setConversationId] = useState<string>()

后续请求会继续带上它:

sendMessageToDify(text, conversationId)

可以这样测试:

第一句问:

前端架构主要包括哪些内容?

第二句追问:

那大型项目怎么分层?

如果 Dify 应用支持上下文,多轮对话就能继续沿用前面的会话。


现在这个版本有什么问题?

这一版虽然能跑,但问题也很明显。

1. API Key 暴露在浏览器里

我们把 Key 写在了:

VITE_DIFY_API_KEY=xxx

只要是 VITE_ 开头的变量,最终都会出现在前端构建结果里。

用户打开浏览器 DevTools,就可能看到请求头里的:

Authorization: Bearer app-xxx

这是不能用于正式项目的。

2. 回答是一次性返回的

当前使用的是:

response_mode: 'blocking'

所以用户要等模型生成完,才能看到完整回答。

体验不如 ChatGPT 那种逐字输出。

3. 不支持 Markdown 渲染

现在回答只是普通文本:

<div>{message.content}</div>

如果 AI 返回代码块、列表、表格,展示效果会比较差。

4. 没有引用来源

RAG 应用非常重要的一点是:

答案来自哪里?

当前页面还没有展示 retriever_resources

5. 刷新页面会丢失会话

messages 和 conversationId 都保存在 React state 里,刷新页面就没了。

这些问题会在后续文章逐个解决。


为什么不要一步到位?

可能有人会觉得:既然 API Key 暴露有问题,为什么还要先写这个版本?

我的理解是:学习一个系统时,最好先让链路可见。

这个最小版本可以帮我们明确几件事:

Dify API 怎么调用
请求参数怎么传
conversation_id 怎么保存
blocking 模式是什么效果
React 页面怎么展示回答

等这些都清楚后,再抽出 Express 代理层,就会非常自然。

如果一开始就把前端、后端、流式、Markdown、多会话全部堆上去,反而不容易理解每一层的职责。


当前代码结构

这一篇结束后,项目结构大概是:

dify-chat-demo/
  src/
    api/
      dify.ts
    types/
      chat.ts
    App.tsx
    App.css
    main.tsx
  .env.local
  package.json
  vite.config.ts

功能链路是:

App.tsx
  ↓
sendMessageToDify
  ↓
Dify Chat Messages API
  ↓
Dify Chatflow
  ↓
知识检索 + LLM

本篇总结

这一篇我们完成了前端最小版本:

Vite + React + TypeScript
调用 Dify Chat API
展示 AI 回答
保存 conversationId
支持基础 loading
支持清空会话

现在项目已经从 Dify 控制台里的 Demo,变成了我们自己的 Web 页面。

但是这个版本还不能算正式项目,因为 Dify API Key 暴露在浏览器里。

下一篇我们就来解决这个问题:

用 Express 做一个 BFF 代理层,把 Dify API Key 放到服务端。