使用 NuxtHub 和 NuxtUI 创建您自己的 Cloudflare Workers AI LLM 游乐场

您可能想知道,“又一个 LLM(大型语言模型)游乐场?难道这些游乐场不是已经有很多了吗?”这个问题问得好。但事实是:人工智能的世界在不断发展,每天都会出现一种或另一种新工具。NuxtHub 最近推出了一款这样的人工智能产品我忍不住尝试了一下。说实话,我们中的一些人就是无法抗拒添加“暗模式”,这是 Cloudflare Workers 人工智能模型游乐场尚未采用的。

但除了这些实际原因之外,还有更令人满意的东西:创造和学习的乐趣。所以,如果你准备好深入研究,也许你会学到一些新概念,比如服务器发送事件、响应流、markdown 解析,当然,还可以部署你自己的聊天室,而不必像往常那样大惊小怪。所以,随心所欲地放松一下,让我们开始吧!

项目概况

好吧,那么我们到底要在这里创建什么呢?您可能已经猜到了,我们将创建一个聊天界面,用于与Cloudflare Workers AI支持的不同文本生成模型进行对话。以下是我们将在此过程中构建的功能的简要列表:

  • 能够设置不同的 LLM 参数,如温度、最大令牌、系统提示、top_p、top_k 等,同时保留其中一些可选参数

  • 能够打开/关闭 LLM 响应流

  • 处理服务器和客户端上的流式/非流式 LLM 响应

  • 解析 LLM 响应以进行 markdown 处理并进行适当显示

  • 当响应从 LLM 端点流出时自动滚动聊天容器

  • 添加暗模式(这个很简单,但为了完整性我们在这里添加它)

当我们读完这篇文章时,界面将会是这样的:

LLM 操场聊天界面

您可以在此处尝试:https://hub-chat.nuxt.dev/

我们将在以下章节中详细介绍每个任务。

项目设置 www.cqzlsb.com

现在我们已经为 LLM 游乐场项目做好了准备,让我们深入研究我们将要使用的技术并准备好我们的开发环境。

我们将使用的技术

  1. Nuxt 3:Muxt3 是一个强大的 Vue.js 框架,它将作为我们应用程序的基础。

  2. Nuxt UI:Nuxt 模块,可帮助我们创建一个时尚且响应迅速的界面。

  3. NuxtHub:NuxtHub 是 Nuxt 的部署和管理平台,由 Cloudflare 提供支持。我们可以使用 NuxtHub 访问不同的 Cloudflare 产品,如 D1 数据库、Workers KV、R2 Storage、Workers AI 等。在这个项目中,我们将使用它来访问 LLM 以及部署我们的项目

  4. Nuxt MDC:用于解析和显示聊天消息

先决条件

除了 Node/Npm、代码编辑器和一些 VueJs/Nuxt 知识等基本先决条件外,您还需要以下内容:

  1. Cloudflare 帐户:用于使用 Workers AI 模型,以及在 Cloudflare Pages 上免费部署您的项目。如果您还没有,可以在此处设置。

  2. NuxtHub 管理员帐户: NuxtHub 管理员是一个基于 Web 的仪表板,用于管理 NuxtHub 应用程序。您可以在此处创建您的帐户

设置项目

我们可以从 Nuxt(或 Nuxt UI)模板开始,然后在其上添加 NuxtHub,或者我们可以使用 NuxtHub 入门模板并向其添加 Nuxt UI。我们采用第二种方法:

  1. 创建新的 NuxtHub 项目

    复制
    复制
     # Init the project and install dependencies
     npx nuxthub init cf-playground
    
     # Change into the created dir
     cd cf-playground
    
  2. 添加 Nuxt UI 模块。以下命令将安装@nuxt /ui 依赖项并将其作为模块添加到 nuxt 配置文件中

    复制
    复制
     npx nuxi module add ui
    
  3. 类似地,添加 Nuxt MDC 模块

    复制
    复制
     npx nuxi module add mdc
    

现在我们已经为这个项目设置好了一切。您可以尝试使用pnpm dev(或使用其他包管理器时使用等效命令)运行该项目,然后在浏览器中访问localhost:3000。如果您的系统上启用了暗黑模式,那么您会注意到项目中已经启用了暗黑模式(我们只需要一种方法来切换它)。

设置好开发环境后,我们就可以开始构建 LLM 游乐场了。在下一节中,我们将开始使用 NuxtUI 组件实现用户界面。

创建 UI 组件

首先,我们将创建一个组件,让我们使用范围滑块或输入框来设置 LLM 设置的数值。我们将其命名为RangeInput

RangeInput 组件

我们利用 NuxtUI 中的 FormGroup 组件来创建此组件。我们在默认插槽中放置一个 Range 滑块,在提示插槽中放置一个输入以实现所需的结果。以下是完整的组件代码

复制
复制
<template>
  <UFormGroup :label="label" :ui="{ container: 'mt-2' }">
    <template #hint>
      <UInput v-model="model" class="w-[72px]" type="number" :min="min" :max="max" :step="step" />
    </template>
    <URange v-model="model" :min="min" :max="max" :step="step" size="sm" />
  </UFormGroup>
</template>

<script setup lang="ts">
const model = defineModel({ type: Number, default: undefined });

defineProps({
  label: {
    type: String,
    required: true,
  },
  min: {
    type: Number,
    default: undefined,
  },
  max: {
    type: Number,
    default: undefined,
  },
  step: {
    type: Number,
    default: undefined,
  },
});
</script>

我们不再将模型值作为 prop,然后手动发出更改,而是使用defineModel宏来实现双向绑定。如果初始模型值未定义,则输入框将为空。这将有助于使某些 LLM 参数成为可选参数。

LLMSettings 组件

LLMSettings 组件利用了我们上面创建的许多 RangeInputs,因为大多数设置都是数值。除了 RangeInputs 之外,我们还使用文本区域作为系统提示、切换按钮来启用/禁用响应流、选择菜单来选择 LLM 模型,以及折叠面板来隐藏可选参数。

以下是该组件的相关部分

复制
复制
<template>
  <div class="h-full flex flex-col overflow-hidden">
    <!-- Settings Header Code -->
    <UDivider />
    <div class="p-4 flex-1 space-y-6 overflow-y-auto">
      <UFormGroup label="Model">
        <USelectMenu v-model="llmParams.model" size="md" :options="models" value-attribute="id" option-attribute="name" />
      </UFormGroup>

      <RangeInput v-model="llmParams.temperature" label="Temperature" :min="0" :max="5" :step="0.1" />

      <RangeInput v-model="llmParams.maxTokens" label="Max Tokens" :min="1" :max="4096" />

      <UFormGroup label="System Prompt">
        <UTextarea v-model="llmParams.systemPrompt" :rows="3" :maxrows="8" autoresize />
      </UFormGroup>

      <div class="flex items-center justify-between">
        <span>Stream Response</span>
        <UToggle v-model="llmParams.stream" />
      </div>

      <UAccordion :items="accordionItems" color="white" variant="solid" size="md" >
        <template #item>
          <UCard :ui="{ body: { base: 'space-y-6', padding: 'p-4 sm:p-4' } }">
            <RangeInput v-model="llmParams.topP" label="Top P" :min="0" :max="2" :step="0.1" />

            <!-- Other optional params -->
          </UCard>
        </template>
      </UAccordion>
    </div>
  </div>
</template>

<script setup lang="ts">
type LlmParams = {
  model: string;
  temperature: number;
  maxTokens: number;
  topP?: number;
  topK?: number;
  frequencyPenalty?: number;
  presencePenalty?: number;
  systemPrompt: string;
  stream: boolean;
};

const llmParams = defineModel('llmParams', {
  type: Object as () => LlmParams,
  required: true,
});

defineEmits(['hideDrawer', 'reset']);

const accordionItems = [
  {
    label: 'Advanced Settings',
    defaultOpen: false,
  },
];

const models = [
  {
    name: 'deepseek-coder-6.7b-base-awq',
    id: '@hf/thebloke/deepseek-coder-6.7b-base-awq',
  },
  { 
    name: 'llama-3-8b-instruct', 
    id: '@cf/meta/llama-3-8b-instruct', 
  },
  // ...other models
]
</script>

ChatPanel 组件

这是最重要的组件,因为它处理应用程序的核心聊天功能。它由三部分组成:

聊天标题

它除了显示一些用于清除聊天、暗模式切换和在移动设备上显示设置抽屉的全局按钮外,还显示应用程序名称/标签

复制
复制
<template>
  <div class="flex items-center justify-between p-4">
    <div class="flex items-center gap-x-4">
      <h2 class="text-xl md:text-2xl text-primary font-bold">Hub Chat</h2>
      <UTooltip text="Clear chat">
        <UButton color="gray" icon="i-heroicons-trash" size="xs" :disabled="clearDisabled" @click="$emit('clear')" />
      </UTooltip>
    </div>
    <div class="flex items-center gap-x-4">
      <ColorMode />
      <UButton icon="i-heroicons-cog-6-tooth" color="gray" variant="ghost" class="md:hidden" @click="$emit('showDrawer')" />
    </div>
  </div>
</template>

<script setup lang="ts">
defineEmits(['clear', 'showDrawer']);

defineProps({
  clearDisabled: {
    type: Boolean,
    default: true,
  },
});
</script>

聊天容器

用于解析和显示聊天消息。当流式传输关闭时,它还会显示消息加载框架,并在需要时显示 NoChats 占位符。

复制
复制
<div ref="chatContainer" class="flex-1 overflow-y-auto p-4 space-y-5">
  <div v-for="(message, index) in chatHistory" :key="`message-${index}`" class="flex items-start gap-x-4" >
    <div class="w-12 h-12 p-2 rounded-full" :class="`${ message.role === 'user' ? 'bg-primary/20' : 'bg-blue-500/20' }`" >
      <UIcon :name="`${ message.role === 'user' ? 'i-mdi-user' : 'i-heroicons-sparkles-solid' }`" class="w-8 h-8" :class="`${ message.role === 'user' ? 'text-primary-400' : 'text-blue-400' }`" />
    </div>
    <div v-if="message.role === 'user'">
      {{ message.content }}
    </div>
    <AssistantMessage v-else :content="message.content" />
  </div>
  <ChatLoadingSkeleton v-if="loading === 'message'" />
  <NoChats v-if="chatHistory.length === 0" class="h-full" />
</div>

为了让用户了解进度,我们在流式传输关闭时显示一条消息加载骨架。为此,我们使用一个可以有三种状态的加载变量:idlemessagestream。因此,当发出非流式传输请求时,我们将引用设置为消息,并显示加载骨架。

用户消息文本框

用于输入用户消息。它显示为单行文本区域,并在需要时自动调整大小。

复制
复制
<div class="flex items-start p-3.5 relative">
  <UTextarea v-model="userMessage" placeholder="How can I help you today?" class="w-full" :ui="{ padding: { xl: 'pr-11' } }" :rows="1" :maxrows="5" :disabled="loading !== 'idle'" autoresize size="xl" @keydown.enter.exact.prevent="sendMessage" @keydown.enter.shift.exact.prevent="userMessage += '\n'" />

  <UButton icon="i-heroicons-arrow-up-20-solid" class="absolute top-5 right-5" :disabled="loading !== 'idle'" @click="sendMessage" />
</div>

为了允许用户在按下回车键时发送消息,我们使用 keydown 事件监听器和所需的修饰符 ( @keydown.enter.exact.prevent)。同样,为了添加换行符,我们使用enter + shift键。

现在我们已经准备好了 UI 组件,让我们将注意力转移到后端。我们将设置 AI 集成并创建一个 API 端点来处理我们的聊天请求。这将弥合我们的前端界面和 AI 模型之间的差距。

设置 AI 和 API 端点

借助 NuxtHub,将 AI 集成到我们的项目中非常简单。让我们看看nuxt.config.ts项目根目录中的文件。

复制
复制
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  compatibilityDate: '2024-07-30',
  // https://nuxt.com/docs/getting-started/upgrade#testing-nuxt-4
  future: { compatibilityVersion: 4 },

  // https://nuxt.com/modules
  modules: ['@nuxthub/core', '@nuxt/eslint', '@nuxtjs/mdc', "@nuxt/ui"],

  // https://hub.nuxt.com/docs/getting-started/installation#options
  hub: {},

  // Env variables - https://nuxt.com/docs/getting-started/configuration#environment-variables-and-private-tokens
  runtimeConfig: {
    public: {
      // Can be overridden by NUXT_PUBLIC_HELLO_TEXT environment variable
      helloText: 'Hello from the Edge 👋',
    },
  },

  // https://eslint.nuxt.com
  eslint: {
    config: {
      stylistic: {
        quotes: 'single',
      },
    },
  },

  // https://devtools.nuxt.com
  devtools: { enabled: true },
});

要启用 AI,我们只需在上面的 hub 配置选项下添加标志即可ai: true。如果需要,我们可以启用其他 NuxtHub Cloudflare 集成,如D1 databasedatabases: true)、Workers KVkv: true) 等。在这里,我们可以删除运行时配置,因为我们不需要它。(您也可以eslint根据代码编辑器设置删除/修改配置)。

如果我们想在开发模式下使用 AI(我们肯定会这样做),我们需要将这个项目链接到 Cloudflare 项目。这是必要的,因为我们将直接与 Cloudflare API 对话,因为还没有运行任何工作程序(也因为 AI 的使用将链接到您的 Cloudflare 帐户)。这是一项简单的任务,只需运行以下命令

复制
复制
npx nuxthub link

运行链接命令将确保我们登录到我们的 NuxtHub 帐户,并允许我们创建一个新的 NuxtHub 项目(由 Cloudflare 支持)或选择一个现有项目。

创建聊天 API 端点

让我们创建一个聊天 API 端点。在目录中创建一个新文件chat.post.ts"post"文件名中的 表示此端点只接受HTTP POST请求)server/api,并将以下代码添加到其中

复制
复制
export default defineEventHandler(async (event) => {
  const { messages, params } = await readBody(event);
  if (!messages || messages.length === 0 || !params) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Missing messages or LLM params',
    });
  }

  const config = {
    max_tokens: params.maxTokens,
    temperature: params.temperature,
    top_p: params.topP,
    top_k: params.topK,
    frequency_penalty: params.frequencyPenalty,
    presence_penalty: params.presencePenalty,
    stream: params.stream,
  };

  const ai = hubAI();

  try {
    const result = await ai.run(params.model, {
      messages: params.systemPrompt
        ? [{ role: 'system', content: params.systemPrompt }, ...messages]
        : messages,
      ...config,
    });

    return params.stream
      ? sendStream(event, result as ReadableStream)
      : (
          result as {
            response: string;
          }
        ).response;
  } catch (error) {
    console.error(error);
    throw createError({
      statusCode: 500,
      statusMessage: 'Error processing request',
    });
  }
});

hubAI是一个服务器可组合项,它返回一个 Workers AI 客户端,用于与 LLM 交互。我们传递从前端收到的消息以及 LLM 参数,并运行请求的模型。

如果仔细查看上述代码,您会注意到此端点支持流式和非流式响应。要从端点返回流式响应,我们只需调用sendStream实用函数。

我们已经完成了服务器端代码。下一节我们将看看如何在客户端处理流响应。

使用服务器发送的事件

在深入研究如何处理流响应之前,让我们先了解为什么流对于 LLM 交互至关重要,以及服务器发送事件 (SSE) 如何促进这一过程。

为什么要使用服务器发送事件来流式传输 LLM 响应?

大型语言模型 (LLM) 可能需要相当长的时间才能生成完整的响应,尤其是对于复杂的查询。传统上,Web 应用程序会等待整个响应后再向用户显示任何内容。然而,这种方法可能会导致等待时间过长,用户体验也会下降。

服务器发送事件 (SSE)为该问题提供了解决方案。

💡
SSE 是一种允许服务器随时向网页推送数据的技术,实现实时更新,而无需客户端不断请求新信息。

以下是 SSE 对 LLM 响应的益处:

  1. 即时反馈:LLM 开始生成内容后,即可将其发送到客户端并显示,为用户提供即时反馈。

  2. 改进感知性能:用户看到内容逐步出现,给人一种系统更快、响应更快的印象。

  3. 实时交互:文本的逐渐出现模仿人类的打字,创造出更自然、更吸引人的对话体验。

💡
想象一下在餐厅点餐:SSE 允许“厨房”(服务员)在每道“菜”(响应的一部分)准备好后立即将其送出,而不是等待所有菜都准备好后再上菜。这样,您就可以更快地开始“用餐”(阅读和处理),整体体验会更快、更令人满意。

从技术上讲,SSE 的工作原理是建立从服务器到客户端的单向通道。服务器发送以 UTF-8 编码的文本数据,并用换行符分隔。这种简单而有效的机制允许在 Web 应用程序中进行实时更新,使其成为流式传输 LLM 响应的理想选择。

现在我们了解了流式传输对于 LLM 响应的重要性以及 SSE 如何实现这一点,让我们看看如何在我们的 Nuxt 3 应用程序中实现这一点。

使用 Nuxt 3 POST 请求处理服务器发送事件

由于我们的请求是 POST,因此我们需要以不同的方式处理它。Nuxt 3 文档为您提供了一个很好的起点。从文档中复制代码

复制
复制
// Make a POST request to the SSE endpoint
const response = await $fetch<ReadableStream>('/chats/ask-ai', {
  method: 'POST',
  body: {
    query: "Hello AI, how are you?",
  },
  responseType: 'stream',
})

// Create a new ReadableStream from the response with TextDecoderStream to get the data as text
const reader = response.pipeThrough(new TextDecoderStream()).getReader()

// Read the chunk of data as we get it
while (true) {
  const { value, done } = await reader.read()

  if (done)
    break

  console.log('Received:', value)
}

我们需要将请求responseType标志设置为stream,并将响应类型设置$fetchReadableStream。然后我们创建一个流读取器,同时通过将事件通过管道传输到解码接收到的块TextDecoder

以下是从我们的聊天端点收到的事件数据(由 LLM 发送)

复制
复制
data: {"response":"Hello","p":"abcdefghijklmnopqrstuvwxyz0123456789abcdefghij"}

data: {"response":"!","p":"abcdefgh"}

data: [DONE]

这些事件可能每个事件包含多行数据,并且某些事件可能不包含完整的 JSON 对象(对象的其余部分将随下一个事件到达)。为了处理此流,我们可以创建一个具有streamResponse 生成器函数的可组合项,如下所示

复制
复制
export function useChat() {
  async function* streamResponse( url: string, messages: ChatMessage[], llmParams: LlmParams ) {
    let buffer = '';

    try {
      const response = await $fetch<ReadableStream>(url, {
        method: 'POST',
        body: {
          messages,
          params: llmParams,
        },
        responseType: 'stream',
      });

      const reader = response.pipeThrough(new TextDecoderStream()).getReader();

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

        if (done) {
          if (buffer.trim()) {
            console.warn('Stream ended with unparsed data:', buffer);
          }

          return;
        }

        buffer += value;
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice('data: '.length).trim();
            if (data === '[DONE]') {
              return;
            }

            try {
              const jsonData = JSON.parse(data);
              if (jsonData.response) {
                yield jsonData.response;
              }
            } catch (parseError) {
              console.warn('Error parsing JSON:', parseError);
            }
          }
        }
      }
    } catch (error) {
      console.error('Error sending message:', error);

      throw error;
    }
  }

  // For handling non-streaming responses
  async function getResponse() {}

  return {
    getResponse,
    streamResponse,
  };
}

为了处理非流式响应,我们还可以$fetch在同一个可组合函数中创建一个简单的调用包装函数(查看链接的 Github Repo 中的代码)。

现在我们了解了如何使用服务器发送事件和处理流式响应,让我们将所有部分整合到我们的主要聊天界面中。我们将集成我们构建的组件,设置聊天 API 调用,并使用我们的useChat可组合项来管理 LLM 响应。

最终聊天界面页面

以下是索引页的最终代码(我们的应用只有一个页面)。在这里,我们使用之前创建的所有组件,并调用聊天 API 端点。然后我们使用useChat可组合项来处理 LLM 响应。

复制
复制
<template>
  <div class="h-screen flex flex-col md:flex-row">
    <USlideover v-model="isDrawerOpen" class="md:hidden" :ui="{ width: 'max-w-xs' }" >
      <LlmSettings v-model:llmParams="llmParams" @hide-drawer="isDrawerOpen = false" @reset="resetSettings" />
    </USlideover>

    <div class="hidden md:block md:w-1/3 lg:w-1/4">
      <LlmSettings v-model:llmParams="llmParams" @reset="resetSettings" />
    </div>

    <UDivider orientation="vertical" class="hidden md:block" />

    <div class="flex-grow md:w-2/3 lg:w-3/4">
      <ChatPanel :chat-history="chatHistory" :loading="loading" @clear="chatHistory = []" @message="sendMessage" @show-drawer="isDrawerOpen = true" />
    </div>
  </div>
</template>

<script setup lang="ts">
import type { ChatMessage, LlmParams, LoadingType } from '~~/types';

const isDrawerOpen = ref(false);

const defaultSettings: LlmParams = {
  model: '@cf/meta/llama-3.1-8b-instruct',
  temperature: 0.6,
  maxTokens: 512,
  systemPrompt: 'You are a helpful assistant.',
  stream: true,
};

const llmParams = reactive<LlmParams>({ ...defaultSettings });
const resetSettings = () => {
  Object.assign(llmParams, defaultSettings);
};

const { getResponse, streamResponse } = useChat();
const chatHistory = ref<ChatMessage[]>([]);
const loading = ref<LoadingType>('idle');
async function sendMessage(message: string) {
  chatHistory.value.push({ role: 'user', content: message });

  try {
    if (llmParams.stream) {
      loading.value = 'stream';
      const messageGenerator = streamResponse(
        '/api/chat',
        chatHistory.value,
        llmParams
      );

      let responseAdded = false;
      for await (const chunk of messageGenerator) {
        if (responseAdded) {
          // add the response to the current message
          chatHistory.value[chatHistory.value.length - 1]!.content += chunk;
        } else {
          // add a new message to the chat history
          chatHistory.value.push({
            role: 'assistant',
            content: chunk,
          });

          responseAdded = true;
        }
      }
    } else {
      loading.value = 'message';
      const response = await getResponse(
        '/api/chat',
        chatHistory.value,
        llmParams
      );

      chatHistory.value.push({ role: 'assistant', content: response });
    }
  } catch (error) {
    console.error('Error sending message:', error);
  } finally {
    loading.value = 'idle';
  }
}
</script>

这样就完成了大部分编码。剩下的只有:

  1. 解析 markdown 并显示响应

  2. 自动滚动聊天容器

我们将在下一节中解决这些问题。

完善聊天界面

我们现在就完成任务清单上剩下的事项,然后就到此为止。请继续关注,这不会花很长时间。

使用 Nuxt MDC 解析并显示消息

如果你查看Chats Container前面部分的代码,你会注意到一个AssistantMessage用于显示响应的组件。在此处重现相关代码

复制
复制
<div v-if="message.role === 'user'">
  {{ message.content }}
</div>
<AssistantMessage v-else :content="message.content" />

我们使用之前包含的parseMarkdown实用函数MDC module来解析内容,然后使用MDCRenderer同一模块中的组件来显示它。为了处理流式响应,我们watch为消息内容添加了一个,并使用可组合项重新进行解析useAsyncData

复制
复制
<template>
  <MDCRenderer class="flex-1 prose dark:prose-invert" :body="ast?.body" />
</template>

<script setup lang="ts">
import { parseMarkdown } from '#imports';

const props = defineProps<{
  content: string;
}>();

const { data: ast, refresh } = await useAsyncData(useId(), () =>
  parseMarkdown(props.content)
);

watch(
  () => props.content,
  () => {
    refresh();
  }
);
</script>
💡
我们可以使用<MDC>组件而不是使用parseMarkdown + <MDCRenderer>组合。<MDC>使用相同的函数在内部处理解析parseMarkdown,那么为什么我们不使用它呢?

<MDC>组件导致覆盖与来自 API 端点的最新消息(消息开头)相似的先前消息。发生这种情况的原因是我们的消息中没有唯一键。以下是来自该<MDC>组件的相关代码。

复制
复制
const key = computed(() => hash(props.value))

const { data, refresh, error } = await useAsyncData(key.value, async () => {
  if (typeof props.value !== 'string') {
    return props.value
  }
  return await parseMarkdown(props.value, props.parserOptions)
})

如您所见,的密钥useAsyncData是根据内容属性值生成的。因此,如果服务器流式响应以相同的字母开头(例如,这是一个笑话……,这是另一个……),则密钥将相同,并且服务器的所有类似的先前响应都会在 UI 中被覆盖。

AssistantMessage组件中我们使用useId可组合项为每个useAsyncData调用生成一个唯一的 ID,因此不会出现此问题。

使用 MutationObserver 自动滚动聊天容器

在 ChatPanel 组件中,唯一滚动的部分是聊天容器。可以有其他方法来观察容器中的变化,但我发现最简单的方法是MutationObserver

💡
MutationObserver接口提供了监视 DOM 树所发生的变化的能力。

对于流式传输,我们会不断将新内容附加到正在显示的最新消息中。因此,为了有效地处理这个问题,我们创建了一个 MutationObserver,为其指定一个要监视的目标(ChatsContainer)和一些要监视的标准(childList, subtree & characterData)。

以下是执行此操作的相关代码

复制
复制
const chatContainer = ref<HTMLElement | null>(null);
let observer: MutationObserver | null = null;

onMounted(() => {
  if (chatContainer.value) {
    observer = new MutationObserver(() => {
      if (chatContainer.value) {
        chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
      }
    });

    observer.observe(chatContainer.value, {
      childList: true,
      subtree: true,
      characterData: true,
    });
  }
});

onUnmounted(() => {
  if (observer) {
    observer.disconnect();
  }
});

当 ChatsPanel 安装时,我们会创建一个新的 MutationObserver,根据给定的条件,我们会让聊天容器滚动到最大程度。

奖励:处理暗模式

如前所述,暗黑模式已经可用;我们只需要一种方法来切换它。我们还可以更改应用中所需的灰色风格。这可以通过app.config.ts在应用目录中添加文件来完成。

复制
复制
// app.config.ts
export default defineAppConfig({
  ui: {
    primary: 'orange',
    gray: 'slate',
  },
});

在您的文件中添加以下内容app.vue以设置所需的背景颜色。

复制
复制
<script setup lang="ts">
useHead({
  bodyAttrs: {
    class: 'bg-white dark:bg-gray-900',
  },
});
</script>

并在您的目录中添加一个新ColorMode组件app/components

复制
复制
<template>
  <ClientOnly>
    <UButton :icon="isDark ? 'i-heroicons-moon-20-solid' : 'i-heroicons-sun-20-solid'" color="gray" variant="ghost" aria-label="Theme" @click="isDark = !isDark" />

    <template #fallback>
      <div class="w-8 h-8" />
    </template>
  </ClientOnly>
</template>

<script setup lang="ts">
const colorMode = useColorMode();

const isDark = computed({
  get() {
    return colorMode.value === 'dark';
  },
  set() {
    colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark';
  },
});
</script>

现在,您可以在任何需要的地方使用此颜色模式切换按钮。在我们的应用中,我们已将其添加到组件中ChatHeader

呼!我们已经完成了本文开头设定的所有任务。

部署应用程序

您可以通过多种方式部署项目。我建议通过 NuxtHub 管理控制台进行部署。将您的代码推送到 Github 存储库,将此存储库与 NuxtHub 链接,然后从管理控制台进行部署。

但如果你想立即看到它,那么你可以使用以下命令

复制
复制
npx nuxthub deploy
💡
如果您使用 NuxtHub CLI 进行首次部署,则由于 Cloudflare 的限制,您将无法稍后附加您的 GitHub/GitLab 存储库。

有关部署的更多详细信息,您可以访问NuxtHub 文档

源代码

我在这里只介绍了源代码中最重要的部分。您可以访问链接的 GIthub Repo 以获取完整的源代码。它应该是不言自明的,但如果您有疑问,请随时在下面发表评论。

结论

恭喜!您已通过 NuxtHub 使用 Nuxt 3、NuxtUI 和 Cloudflare Workers AI 成功构建了一个功能丰富的 LLM 游乐场。我们涵盖了广泛的主题,包括:

  • 使用服务器发送事件处理流式响应

  • 解析并显示聊天消息中的 markdown 内容

  • 实现自动滚动以获得更好的用户体验等。

您可以将该项目作为起点,并通过以下方式进一步改进它:

  • 增加与其他类型的 LLM 对话的能力,例如文本到图像、图像到文本、语音识别等。

  • 实现用户身份验证和会话管理

  • 添加对多个对话线程等的支持。

感谢您坚持到最后。我希望您从本文中学到了一些新概念。请在评论部分告诉我您的学习成果。您的反馈和经验不仅对我很有价值,而且对探索这一迷人领域的整个开发者社区也很有价值。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值