1. 项目概述

最近在捣鼓一个AI对话应用的原型,想快速验证一些想法,但又不想从零开始搭架子。市面上虽然有不少开源项目,但要么太重,集成了太多我不需要的功能,要么太简陋,二次开发起来很费劲。我的核心需求很简单:一个基于现代前端技术栈(Vue 3 + TypeScript)的、干净的、可快速上手的Chat Bot Web端模板。它应该能轻松对接市面上主流的大模型API,支持流式输出和Markdown渲染,并且代码结构清晰,方便我后续按需定制。

于是,我基于 pdsuwwz/chatgpt-vue3-light-mvp 这个项目进行了一番深度实践和改造。这个项目定位为“最小可行产品”(MVP),核心是单轮对话,每次提问独立响应,不保留上下文。这听起来似乎是个限制,但对于快速原型、客服问答、一次性查询等场景,这种设计反而让逻辑变得极其简单和可控。项目采用了Vite 6、Vue 3、TypeScript、Naive UI、Pinia和UnoCSS这套非常主流且高效的技术栈,并且内置了对DeepSeek、星火、Kimi、SiliconFlow以及本地Ollama等多种模型的支持。

接下来,我将从一个一线开发者的角度,详细拆解这个项目的架构设计、核心实现、配置踩坑经验,以及如何基于它进行二次开发。无论你是想快速搭建一个AI对话Demo,还是想学习如何优雅地在前端集成多种大模型API,这篇文章都能给你提供一份可直接“抄作业”的实战指南。

2. 核心架构与设计思路拆解

2.1 为什么选择“单轮对话”作为MVP?

很多人在设计Chat应用时,会下意识地追求“多轮对话”和“上下文记忆”,认为这才是完整的体验。但对于一个旨在快速验证和搭建原型的项目来说,这是一个典型的“过度设计”陷阱。 chatgpt-vue3-light-mvp 选择单轮对话作为起点,背后有非常务实的考量:

  1. 复杂度骤降 :多轮对话意味着需要维护一个不断增长的对话历史(messages数组),并在每次请求时将其作为上下文发送给API。这不仅增加了前端状态管理的复杂度(何时清理、如何分页),更关键的是,它会迅速消耗大模型的Token,导致API成本飙升和响应变慢。单轮对话将每次交互视为独立事件,状态管理变得极其简单——只需一个“问题”和一个“答案”。
  2. 调试与测试更简单 :由于每次请求都是独立的,你可以非常方便地测试不同问题下的模型表现,而不用担心之前的对话历史对当前结果产生不可预知的干扰。这对于快速评估不同模型的优劣至关重要。
  3. 核心功能聚焦 :MVP阶段,我们应该聚焦在最核心的“问-答”链路上:前端输入、调用API、流式接收、渲染结果。单轮对话完美地剥离了上下文管理这个次级问题,让我们可以更专注地打磨请求封装、错误处理、流式解析和UI展示这些基础但至关重要的能力。
  4. 易于扩展 这并不意味着项目被限制死了 。单轮对话的架构是扩展多轮对话的绝佳基础。当你需要增加上下文时,只需在Pinia store中维护一个 messages: Array<{role: string, content: string}> 数组,并在请求时将其带入即可。项目清晰的模块化设计使得这种扩展变得有章可循。

所以,不要小看这个“单轮”设计。它体现了一种“以终为始”的工程思维:先用最小的成本跑通核心闭环,后续的增强都是在这个稳定闭环上的叠加。

2.2 技术栈选型背后的逻辑

项目采用的技术栈堪称现代Vue开发的“黄金组合”,每一项选择都经过了深思熟虑:

  • Vite 6 + Vue 3 + TypeScript :这是当前Vue生态下开发体验和类型安全的最佳实践。Vite的极速热更新对于需要频繁调整UI和逻辑的原型开发来说,是巨大的效率提升。TypeScript则确保了在对接不同模型API时,复杂的请求/响应数据结构能有良好的类型提示和约束,减少运行时错误。
  • Naive UI 2.x :选择Naive UI而非Element Plus或Ant Design Vue,我认为主要出于两点:一是其设计风格干净、现代,与AI类产品的调性很搭;二是它 对Tree-shaking的支持非常彻底 ,配合 unplugin-vue-components ,可以实现真正的按需加载,这对于一个追求轻量的MVP项目来说很重要。
  • Pinia :作为Vue官方的状态管理库,Pinia的API比Vuex更简洁,且完美支持TypeScript。在这个项目中,它被用来管理全局的“当前选中模型”、“对话记录”等状态,结构清晰,与组件解耦度高。
  • UnoCSS + Iconify :原子化CSS引擎UnoCSS的引入,使得我们可以摆脱编写大量样式类的负担,通过工具类快速构建UI。集成Iconify则意味着拥有了一个几乎涵盖所有流行图标集的、按需加载的图标方案。这两者结合,实现了极致的样式和图标按需打包,保证了项目的轻量化。
  • Markdown渲染生态 markdown-it 负责解析, highlight.js 负责代码高亮, katex 负责数学公式, @mermaid-js/mermaid 负责图表。这个组合覆盖了技术文档和AI输出中绝大部分的富文本展示需求,且每个库都足够成熟和可配置。

这套技术栈不仅保证了开发效率和应用性能,更重要的是,它们之间的集成度很高,社区生态活跃,你在二次开发中遇到的绝大多数问题都能找到解决方案。

2.3 核心设计模式:适配器模式处理多模型差异

这是本项目最精彩的设计之一。不同的大模型API,其请求地址、请求头、请求体格式、流式响应数据的格式都可能不同。一个笨办法是为每个模型写一套独立的请求逻辑,但这会导致代码重复且难以维护。

项目采用了经典的 适配器模式 来解决这个问题。它定义了一个统一的模型配置列表 ( modelMappingList ),每个模型配置项就像一个“适配器”,对外提供统一的接口,对内处理特定模型的差异。

// 简化后的核心结构示意
interface ModelAdapter {
  label: string; // 显示名,如“DeepSeek-V3”
  modelName: string; // 唯一标识,如“deepseek-v3”
  transformStreamValue: (chunk: Uint8Array | string) => string; // 响应数据转换器
  chatFetch: (text: string) => Promise<Response>; // 请求发送器
}

const modelMappingList: ModelAdapter[] = [
  {
    label: 'DeepSeek-V3',
    modelName: 'deepseek-v3',
    transformStreamValue: (chunk) => {
      // 专门解析DeepSeek流式返回的JSON格式,提取content字段
      const decoder = new TextDecoder();
      const lines = decoder.decode(chunk).split('\n');
      for (const line of lines) {
        if (line.startsWith('data: ') && !line.includes('[DONE]')) {
          const data = JSON.parse(line.slice(6));
          return data.choices[0]?.delta?.content || '';
        }
      }
      return '';
    },
    chatFetch: (text) => {
      // 构建符合DeepSeek API规范的请求
      return fetch('/deepseek/v1/chat/completions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
        body: JSON.stringify({
          model: 'deepseek-chat',
          messages: [{ role: 'user', content: text }],
          stream: true
        })
      });
    }
  },
  {
    label: 'Spark',
    modelName: 'spark',
    transformStreamValue: (chunk) => {
      // 解析星火API特殊的流式格式
      // ... 具体解析逻辑
    },
    chatFetch: (text) => {
      // 构建星火API请求,可能涉及签名生成等
      // ... 具体请求逻辑
    }
  },
  // ... 其他模型
];

这样设计的好处是:

  1. 高内聚低耦合 :每个模型的特殊性都被封装在自己的配置对象里,新增一个模型只需新增一个配置项,不会影响其他模型的逻辑。
  2. UI与逻辑解耦 :前端组件(如 MarkdownPreview )完全不需要知道当前是哪个模型,它只需要调用统一的 createAssistantWriterStylized 方法,并传入当前选中的 modelName 。具体的请求和解析工作,由对应的适配器完成。
  3. 便于维护和测试 :你可以单独测试某个模型的 transformStreamValue 函数是否正确解析了模拟数据,而不需要启动整个应用或连接真实API。

3. 环境搭建与核心配置实战

3.1 从零开始的本地开发环境配置

假设你刚克隆了项目,让我们一步步把它跑起来。

第一步:依赖安装与启动 项目使用 pnpm 作为包管理器,这是目前速度最快、磁盘空间利用最高效的选择。如果你还没安装,建议通过 npm i -g pnpm 安装。

# 克隆项目
git clone https://github.com/pdsuwwz/chatgpt-vue3-light-mvp.git
cd chatgpt-vue3-light-mvp

# 安装依赖(使用pnpm能利用硬链接,速度极快)
pnpm i

# 启动开发服务器
pnpm dev

执行 pnpm dev 后,Vite会启动开发服务器,通常默认在 http://localhost:2048 。打开浏览器,你应该能看到一个简洁的聊天界面。 此时,由于未配置任何API Key,应用会自动运行在“模拟模式” ,你输入任何问题,它都会返回一段预设的Markdown格式的模拟回答,用于测试UI渲染效果。

第二步:API密钥配置详解 要连接真实的大模型,你需要配置环境变量。项目根目录下有一个 .env.template 文件,复制它并重命名为 .env

cp .env.template .env

然后编辑 .env 文件。这里有几个关键点,是新手最容易踩坑的地方:

# .env 文件示例
# 注意:Vite 要求客户端可访问的环境变量必须以 VITE_ 开头

# 1. DeepSeek: 直接在平台获取的 sk- 开头的密钥
VITE_DEEPSEEK_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# 2. 星火(Spark): 需要拼接!格式为 `APIKey:APISecret`
VITE_SPARK_KEY=your_api_key_here:your_api_secret_here

# 3. SiliconFlow: sk- 开头的密钥
VITE_SILICONFLOW_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# 4. Kimi Moonshot: sk- 开头的密钥
VITE_MOONSHOT_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

重要提示 :星火(Spark)的配置格式是唯一的,需要用冒号 : API Key API Secret 拼接起来。很多开发者直接复制控制台的Key填进去,会导致认证失败。请务必从星火控制台获取这两项并正确拼接。

第三步:理解开发环境代理配置 由于纯前端项目直接调用第三方API会遇到跨域问题,项目在 vite.config.ts 中配置了开发服务器代理。

// vite.config.ts
server: {
  proxy: {
    '/deepseek': {
      target: 'https://api.deepseek.com',
      changeOrigin: true,
      rewrite: (path) => path.replace(/^\/deepseek/, '')
    },
    // ... 其他模型代理
  }
}

这意味着,当你在前端代码中请求 /deepseek/v1/chat/completions 时,Vite开发服务器会将其代理到 https://api.deepseek.com/v1/chat/completions ,从而绕过浏览器的跨域限制。

请注意:这个代理配置仅在开发环境 ( pnpm dev ) 下生效。 当你构建生产版本 ( pnpm build ) 并部署时,需要确保你的生产服务器(如Nginx)有相应的反向代理配置,或者你的前端和API后端处于同一域名下。

3.2 模拟模式与真实模式的切换机制

项目设计了一个巧妙的开关,用于控制是否调用真实API,这在演示和开发初期非常有用。核心逻辑在 src/config/env.ts

// src/config/env.ts
/**
 * 判断是否为Github Pages等演示环境
 * 通过环境变量 VITE_ROUTER_MODE 来控制。
 * 在vite.config.ts中,生产构建且base路径为`/chatgpt-vue3-light-mvp/`时,会设置为'hash'模式。
 */
export const isGithubDeployed = process.env.VITE_ROUTER_MODE === 'hash'

在项目里,会检查 isGithubDeployed 。如果为 true (即在演示环境),则强制使用模拟数据,避免在公开页面上消耗你的API额度。

如何在本地开发中强制使用真实API? 有时你可能想在本地也测试真实API,但不想修改环境变量。你可以直接修改业务逻辑。全局搜索 isGithubDeployed ,通常在 src/store/business/index.ts createAssistantWriterStylized 函数中,你会找到类似下面的判断:

if (isGithubDeployed) {
  // 使用模拟数据
  return mockStreamingResponse(text);
} else {
  // 使用真实API
  return realApiFetch(text);
}

你可以临时将判断条件改为 false ,或者直接注释掉模拟数据的部分,让逻辑直接走到真实API调用分支。 记得测试完后改回来,或者不要提交这部分修改。

3.3 接入本地Ollama模型

对于想完全本地运行、注重隐私或想体验开源模型的开发者,Ollama是一个绝佳选择。它让你可以在自己的电脑上运行如Llama 3、CodeLlama等大型语言模型。

安装与运行Ollama:

  1. 前往 Ollama官网 下载并安装对应操作系统的版本。
  2. 打开终端,拉取并运行一个模型,例如Llama 3:
    ollama run llama3
    
    首次运行会自动下载模型文件(约4-5GB),下载完成后会自动进入交互式对话界面。此时,Ollama的API服务已经在 http://localhost:11434 启动。

配置项目使用Ollama: 项目已经内置了Ollama的支持。你只需要确保Ollama服务正在运行,然后在项目的模型选择下拉框中,选择 Ollama (Llama 3) 即可。

背后的原理: 项目的代理配置同样为Ollama做了转发:

// 注意:项目默认vite配置可能没有Ollama代理,需要手动添加
server: {
  proxy: {
    '/ollama': {
      target: 'http://localhost:11434',
      changeOrigin: true,
      rewrite: (path) => path.replace(/^\/ollama/, '')
    }
  }
}

当选择Ollama模型时,前端请求会发送到 /ollama/api/chat ,被代理到本地的 http://localhost:11434/api/chat 。Ollama的API格式与OpenAI兼容,因此项目中的适配器可以相对容易地处理其流式响应。

实操心得 :使用本地Ollama时,响应速度取决于你的电脑硬件(尤其是GPU和内存)。对于8B参数左右的模型,在拥有16GB内存和较好CPU的机器上,体验已经可以接受。这是进行低成本、高频次原型测试的利器。

4. 核心组件与流式响应实现深度解析

4.1 MarkdownPreview组件:流式渲染的核心

MarkdownPreview 组件是这个项目的“心脏”,它负责两件核心事: 接收流式数据 实时渲染Markdown 。让我们深入其实现。

组件核心Props:

// src/components/MarkdownPreview/index.vue
props: {
  model: { type: String, default: '' }, // 当前模型标识,用于显示不同的占位符
  transformStreamFn: { type: Function, default: null } // 关键!模型特定的流数据解析函数
}

transformStreamFn 这个prop是连接 通用渲染逻辑 特定模型解析逻辑 的桥梁。父组件(通常是业务Store)会根据用户选择的模型,从 modelMappingList 中找到对应的配置项,并将其中的 transformStreamValue 函数传递给 MarkdownPreview

流式接收与渲染流程:

  1. 发起请求 :用户在输入框提问,业务Store调用 createAssistantWriterStylized
  2. 获取ReadableStream :该函数内部会调用对应模型的 chatFetch 方法,得到一个 Response 对象,并通过 response.body 拿到 ReadableStream
  3. 逐块读取与解析 MarkdownPreview 组件(或其调用的方法)使用 reader.read() 循环读取这个流。每读取到一个数据块( Uint8Array ),就调用 transformStreamFn(chunk)
  4. 数据转换 transformStreamFn 是模型相关的,它知道如何从这个二进制块中解析出本次迭代的文本内容(例如,从 data: {...} 的JSON行中提取 delta.content )。
  5. 累积与渲染 :解析出的文本片段被不断追加到一个响应字符串中。同时,这个字符串被传递给 markdown-it 实例,转换为HTML,并通过 v-html 动态渲染到DOM上,形成“打字机”效果。

性能与安全考量:

  • 频繁渲染 :流式响应可能每秒触发多次渲染。项目使用了Vue的响应式系统,将累积的响应文本绑定到组件数据上,Vue会智能地调度DOM更新。对于极高频更新,可以考虑使用 requestAnimationFrame 进行节流,但本项目的数据量下,直接更新体验已足够流畅。
  • XSS防护 :由于使用 v-html 直接渲染Markdown转换后的HTML,必须警惕XSS攻击。 markdown-it 在默认配置下是安全的,它会转义HTML标签。但如果你开启了 html: true 选项以允许原生HTML,就必须非常小心,确保输入源可信。本项目主要渲染AI返回的Markdown,风险可控。

4.2 统一请求封装:createAssistantWriterStylized

这个函数位于 src/store/business/index.ts ,是项目的大脑,协调着模型选择、请求发送和状态更新。

// 函数功能简述
async function createAssistantWriterStylized(prompt: string) {
  // 1. 从Store获取当前选中的模型配置项
  const modelItem = this.currentModelItem;

  // 2. 检查是否为演示模式,是则返回模拟流
  if (isGithubDeployed) {
    return createMockStream(prompt);
  }

  // 3. 调用模型配置项中的 chatFetch 函数,发起网络请求
  const response = await modelItem.chatFetch(prompt);

  // 4. 检查响应是否成功
  if (!response.ok) {
    throw new Error(`API请求失败: ${response.status}`);
  }

  // 5. 返回原生的 ReadableStream,供 MarkdownPreview 消费
  return response.body;
}

它的精妙之处在于 职责分离

  • 它不关心具体是哪个模型 ,只依赖 currentModelItem
  • 它不解析流数据 ,只返回原始的 ReadableStream
  • 它处理全局性的错误 (如网络错误、HTTP状态码错误)。

这种设计使得增加一个新模型变得非常容易:你只需要在 modelMappingList 里添加一个新的配置项,实现好它的 chatFetch transformStreamValue createAssistantWriterStylized MarkdownPreview 组件无需任何修改就能自动支持。

4.3 不同模型响应数据的解析实战

不同模型的流式响应格式差异很大,这是集成多模型时的主要工作量所在。我们看看项目是如何处理几种典型情况的:

1. OpenAI兼容格式 (DeepSeek, SiliconFlow, Moonshot等) 这是目前最主流的格式,以 data: 为前缀的JSON行。

// 解析函数示例
const transformOpenAIStream = (chunk: Uint8Array) => {
  const decoder = new TextDecoder();
  const lines = decoder.decode(chunk).split('\n');
  let content = '';
  for (const line of lines) {
    if (line.startsWith('data: ') && line !== 'data: [DONE]') {
      try {
        const data = JSON.parse(line.slice(6));
        // 注意:流式响应中,内容通常在 choices[0].delta.content
        content += data.choices[0]?.delta?.content || '';
      } catch (e) {
        console.warn('解析JSON行失败:', line);
      }
    }
  }
  return content;
};

2. 星火(Spark)格式 星火的流式响应是一个连续的JSON文本流,每个块是一个完整的JSON对象,但 payload.choices.text 数组的最后一个元素会不断增长。

// 解析函数示例 (简化)
const transformSparkStream = (chunk: Uint8Array) => {
  const decoder = new TextDecoder();
  const text = decoder.decode(chunk);
  // 星火响应可能包含多个JSON对象,用特定分隔符分隔,这里需要根据实际情况拼接和解析
  // 假设我们能把流还原成一个完整的JSON字符串 `fullJsonStr`
  try {
    const data = JSON.parse(fullJsonStr);
    const texts = data.payload.choices.text;
    if (texts && texts.length > 0) {
      // 取最后一个文本块的最新内容
      return texts[texts.length - 1].content;
    }
  } catch (e) { /* ... */ }
  return '';
};

注意 :星火的流式处理相对复杂,因为需要维护一个缓冲区来拼接可能被TCP拆分的JSON片段。项目源码中的实现会更健壮。

3. Ollama格式 Ollama也基本遵循OpenAI格式,但字段可能略有不同,需要查看其API文档。

// 解析函数示例
const transformOllamaStream = (chunk: Uint8Array) => {
  const decoder = new TextDecoder();
  const lines = decoder.decode(chunk).split('\n');
  for (const line of lines) {
    if (line.trim()) {
      try {
        const data = JSON.parse(line);
        // Ollama 响应中,内容可能在 `message.content`
        return data.message?.content || '';
      } catch (e) { /* ... */ }
    }
  }
  return '';
};

踩坑记录 :在编写 transformStreamValue 函数时,最大的坑在于 流的边界 。网络传输中,一个完整的“数据行”可能会被拆分成多个 chunk 到达,也可能一个 chunk 里包含多行。因此,解析函数必须具备“拼接”和“按行分割”的能力。项目中对DeepSeek等模型的处理,采用 decoder.decode(chunk).split(‘\n’) 是正确且必要的。对于更复杂的格式(如星火),可能需要一个更复杂的缓冲区管理机制。

5. 二次开发指南与高级定制

5.1 如何新增一个自定义大模型?

假设你想接入一个新的模型,比如“通义千问”(Qwen)。以下是清晰的步骤:

第一步:在模型映射列表中添加配置 打开 src/components/MarkdownPreview/models/index.ts ,找到 modelMappingList 数组,新增一个对象。

// src/components/MarkdownPreview/models/index.ts
export const modelMappingList: ModelItem[] = [
  // ... 已有的模型配置
  {
    label: '通义千问', // 下拉框显示的名称
    modelName: 'qwen', // 模型唯一标识,全小写,用短横线连接
    transformStreamValue: transformQwenStream, // 需要实现的解析函数
    chatFetch: createQwenChatFetch, // 需要实现的请求函数
  },
];

第二步:实现请求函数 ( createQwenChatFetch ) 这个函数负责构建符合通义千问API规范的HTTP请求。

// 在同文件或新建一个 qwen.ts 中实现
import { getEnv } from '@/config/env';

export const createQwenChatFetch = (text: string): Promise<Response> => {
  const apiKey = getEnv('VITE_QWEN_KEY'); // 假设你在.env中配置了 VITE_QWEN_KEY
  if (!apiKey) {
    throw new Error('未配置通义千问 API Key');
  }

  // 查阅通义千问API文档,确定其端点、请求头和请求体格式
  const url = '/qwen/v1/chat/completions'; // 需要在vite.config.ts中配置代理
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${apiKey}`,
  };
  const body = JSON.stringify({
    model: 'qwen-max', // 指定模型名称
    messages: [{ role: 'user', content: text }],
    stream: true, // 启用流式
    // ... 其他可能参数,如 temperature, max_tokens
  });

  return fetch(url, { method: 'POST', headers, body });
};

第三步:实现响应解析函数 ( transformQwenStream ) 你需要查阅通义千问的流式响应文档,编写对应的解析逻辑。如果它兼容OpenAI格式,那可以直接复用 transformOpenAIStream

// 假设通义千问也使用 data: 前缀格式
export const transformQwenStream = transformOpenAIStream; // 如果兼容,直接复用

// 如果不兼容,则需要独立实现,例如:
// export const transformQwenStream = (chunk: Uint8Array) => { ... };

第四步:配置开发环境代理 vite.config.ts server.proxy 对象中,为通义千问添加代理规则。

// vite.config.ts
server: {
  proxy: {
    // ... 其他代理
    '/qwen': {
      target: 'https://dashscope.aliyuncs.com', // 通义千问的实际API地址
      changeOrigin: true,
      rewrite: (path) => path.replace(/^\/qwen/, ''),
    },
  },
},

第五步:在UI中启用 通常,模型选择下拉框的数据源就是 modelMappingList 。完成以上步骤后,刷新页面,你应该能在下拉框中看到“通义千问”选项了。

5.2 从单轮对话扩展到多轮对话

如前所述,项目的架构很容易扩展为多轮对话。以下是改造思路:

  1. 修改状态管理 :在Pinia Store ( src/store/business/index.ts ) 中,增加一个 conversationHistory 状态,用于存储对话历史。

    state: () => ({
      systemModelName: 'deepseek-v3',
      conversationHistory: [] as Array<{role: 'user' | 'assistant', content: string}>,
      // ...
    }),
    
  2. 修改请求逻辑 :在 createAssistantWriterStylized 或各模型的 chatFetch 函数中,不再只发送当前用户消息,而是发送整个 conversationHistory

    const body = JSON.stringify({
      model: 'deepseek-chat',
      messages: [
        // 可以在这里添加一个系统提示词
        { role: 'system', content: '你是一个有帮助的助手。' },
        ...this.conversationHistory, // 注入历史对话
        { role: 'user', content: prompt }, // 当前问题
      ],
      stream: true,
    });
    
  3. 更新历史记录 :在收到AI回复后,需要将用户的问题和AI的回复都追加到 conversationHistory 中。

    // 用户发送问题时
    this.conversationHistory.push({ role: 'user', content: prompt });
    
    // AI流式回复完成后
    this.conversationHistory.push({ role: 'assistant', content: fullAssistantReply });
    
  4. UI展示历史 :在聊天界面组件中,遍历 conversationHistory 来渲染所有的对话气泡。

  5. 添加历史管理功能 :可以增加“新建对话”(清空历史)、“删除某条消息”等功能。

注意事项

  • Token限制 :历史越长,消耗的Token越多,API成本越高,响应可能越慢。需要设计一个截断策略,例如只保留最近N轮对话,或者当总Token数超过模型上限时,丢弃最早的消息。
  • 性能 :在Vue中,频繁操作一个可能很大的数组( conversationHistory )并触发响应式更新,可能会影响性能。可以考虑使用 shallowRef 或手动控制更新时机。

5.3 样式与UI定制

项目使用Naive UI和UnoCSS,定制起来非常灵活。

  • 主题定制 :Naive UI支持全局主题配置。你可以在 src/App.vue 或入口文件中,使用 n-config-provider 组件来定制主题色、组件尺寸等。
    <template>
      <n-config-provider :theme="theme">
        <App />
      </n-config-provider>
    </template>
    <script setup>
    import { darkTheme } from 'naive-ui';
    const theme = darkTheme; // 使用暗色主题
    </script>
    
  • 组件覆盖 :如果你觉得某个Naive UI组件的样式不符合预期,可以直接通过CSS覆盖。由于UnoCSS的原子化特性,你的自定义样式需要确保有足够的选择器优先级。
  • 布局调整 :项目的主布局在 src/layouts/default.vue 和各个页面组件中。你可以自由调整聊天窗口、输入框、侧边栏(如果需要)的位置和大小。

6. 部署上线与生产环境注意事项

6.1 构建与部署

使用以下命令进行生产构建:

pnpm build

构建产物会生成在 dist 目录下。你可以将这个目录部署到任何静态网站托管服务,如:

  • Vercel / Netlify :直接关联Git仓库,自动部署。
  • GitHub Pages :项目已配置好,使用 pnpm deploy 命令即可。
  • 自有服务器 :将 dist 目录上传到Nginx或Apache的Web根目录。

6.2 解决生产环境跨域问题

开发时我们用Vite代理解决了跨域,但生产环境是静态文件,无法使用此代理。有三种主流解决方案:

方案一:配置后端CORS(如果API服务由你控制) 让你的后端API服务器在响应头中添加 Access-Control-Allow-Origin: https://你的前端域名.com

方案二:使用Nginx反向代理(推荐) 这是最通用和可控的方案。在你的Nginx配置中,将针对特定路径的请求代理到大模型API服务器。

server {
    listen 80;
    server_name your-domain.com;

    location / {
        root /path/to/your/dist;
        index index.html;
        try_files $uri $uri/ /index.html; # 支持Vue Router的history模式
    }

    # 代理DeepSeek API请求
    location /deepseek/ {
        proxy_pass https://api.deepseek.com/;
        proxy_set_header Host api.deepseek.com;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        # 如果需要,在这里添加API Key等认证头(注意安全风险)
        # proxy_set_header Authorization 'Bearer $http_deepseek_token';
    }

    # 同理,代理其他模型...
    location /spark/ {
        proxy_pass https://spark-api-open.xf-yun.com/;
        # ... 其他配置
    }
}

关键点 :这样配置后,前端代码中的请求地址依然是 /deepseek/v1/... ,Nginx会将其转发到真实的API地址。 务必注意,不要在Nginx配置中硬编码你的API Key ,这会导致密钥泄露。前端应通过安全的方(如环境变量构建时注入)将Key包含在请求头中。

方案三:使用后端服务中转(最安全) 构建一个简单的后端服务(如用Express、Next.js API Route等),前端所有请求都发到这个自己的后端,再由后端去调用各大模型API。这样可以将API Key完全保存在服务器端,避免暴露给客户端。这是企业级应用的标准做法,但会引入后端开发成本。

6.3 安全与优化建议

  1. API密钥保护 :永远不要将真实的API Key提交到Git仓库。 .env 文件必须列入 .gitignore 。在生产环境,通过构建平台(如Vercel, Netlify)的环境变量功能,或服务器环境变量来设置。
  2. 速率限制与用量监控 :在前端或你自建的后端中,添加请求频率限制,防止用户滥用导致账单爆炸。同时,关注各大模型平台的用量统计。
  3. 错误处理与用户提示 :完善网络错误、API配额不足、模型超时等情况的UI提示,提升用户体验。
  4. 代码分割与懒加载 :如果项目变得庞大,可以考虑使用Vue Router的懒加载和Vite的动态导入来分割代码,优化首屏加载速度。
  5. PWA支持 :可以考虑添加PWA(渐进式Web应用)支持,让应用可以离线使用(当然,AI功能仍需网络),并具备更好的移动端体验。

这个 chatgpt-vue3-light-mvp 项目作为一个起点,已经提供了一个非常坚实和优雅的基础。它清晰地展示了如何用现代前端技术构建一个复杂AI应用的核心交互界面。无论是用于学习、内部工具开发,还是作为商业产品的原型,它都能为你节省大量初期搭建的时间。希望这篇详细的拆解能帮助你更好地理解、使用和扩展它。在实际开发中,最宝贵的往往不是代码本身,而是这种清晰解耦、易于扩展的设计思想。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐