AI 组件库-MateChat 高级玩法:多会话(四)

MateChat 高级玩法:多会话

一、多会话架构

目标:像 GPT 一样左侧是会话列表,右侧是指定会话的 MateChat UI,且页面刷新后仍能找回所有历史。

1. 项目梳理

1.1. 需求梳理
  1. 构建一个基于Vue 3的聊天应用,支持与多种AI模型进行交互
  2. 提供会话管理功能,包括创建新会话、切换会话和保存会话历史
  3. 支持多种AI提供商(DeepSeek、OpenAI、通义千问)的接入和切换
1.2. 项目简单逻辑
  1. 使用Pinia进行状态管理,通过useChatSessions存储和管理会话数据
  2. 实现了会话持久化功能,将聊天记录保存在localStorage中
  3. 通过useChatModelDynamic实现了动态切换不同AI提供商的能力
  4. 使用OpenAI SDK与各AI服务提供商的API进行通信,支持流式响应
  5. 使用MateChat组件库构建聊天界面,包括消息气泡、输入框等
  6. 实现了响应式布局,左侧固定宽度的会话列表和右侧自适应的聊天区域
1.3. 技术栈和依赖
  1. 前端框架:Vue 3 + TypeScript
  2. 状态管理:Pinia
  3. UI组件:Naive UI和MateChat Core
  4. 工具库:dayjs(日期处理)、nanoid(ID生成)
  5. API客户端:OpenAI SDK
  6. 构建工具:Vite

2. 添加依赖

json 复制代码
"dependencies": {
  "@devui-design/icons": "^1.4.0",
  "@matechat/core": "^1.5.2",
  "dayjs": "^1.11.13",
  "naive-ui": "^2.41.1",
  "nanoid": "^5.1.5",
  "openai": "^5.1.1",
  "pinia": "^3.0.3",
  "vue": "^3.5.13",
  "vue-devui": "^1.6.32"
},
"devDependencies": {
  "@vitejs/plugin-vue": "^5.2.3",
  "@vue/tsconfig": "^0.7.0",
  "typescript": "~5.8.3",
  "vite": "^6.3.5",
  "vue-tsc": "^2.2.8"
}

3. 目录结构

shell 复制代码
src
├── App.vue
├── components
│   └── SessionList.vue
├── constants
│   └── llmProviders.ts
├── hooks
│   └── useChatModelDynamic.ts
├── main.ts
├── stores
│   └── useChatSessions.ts

4. 详细代码

4.1. main.ts
typescript 复制代码
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createPinia } from 'pinia'
import naive from 'naive-ui'

import MateChat from '@matechat/core';
import '@devui-design/icons/icomoon/devui-icon.css'; // 图标库

const pinia = createPinia()

createApp(App).use(MateChat).use(pinia).use(naive).mount('#app')
4.2. App.vue
vue 复制代码
<template>
  <div class="layout">
    <!-- 左侧:220 px 固定 -->
    <SessionList />

    <!-- 右侧:flex:1 -->
    <section class="right">
      <!-- 会话容器 800 px 居中 -->
      <div class="chat-wrapper">
        <McLayout class="chat-board">
          <!-- 消息区 -->
          <McLayoutContent class="content">
            <template v-if="activeSession">
              <template v-for="m in activeSession.messages" :key="m.id">
                <McBubble
                  :content="m.content"
                  :loading="m.loading"
                  :align="m.from === 'user' ? 'right' : 'left'"
                  :avatarConfig="m.from === 'user' ? userAvatar : modelAvatar"
                />
              </template>
            </template>
            <template v-else>
              <p class="placeholder">点击左侧「+」新建会话</p>
            </template>
          </McLayoutContent>

          <!-- 底部输入区(固定) -->
          <McLayoutSender v-if="activeSession" class="input-bar">
            <McInput
              :value="input"
              placeholder="请输入内容,Enter 发送"
              @change="(v: string) => input = v"
              @submit="submit"
            >
              <template #extra>
                <n-select
                  v-model:value="provider"
                  :options="providerOptions"
                  size="small"
                  style="width: 140px"
                  @update:value="store.setProvider"
                />
              </template>
            </McInput>
          </McLayoutSender>
        </McLayout>
      </div>
    </section>
  </div>
</template>

<script setup lang="ts">
import SessionList from "./components/SessionList.vue";
import { ref, computed, watch } from "vue";
import { useChatSessions } from "./stores/useChatSessions";
import { providerOptions } from "./constants/llmProviders";
import { useChatModelDynamic } from "./hooks/useChatModelDynamic";
import { NSelect } from "naive-ui";

const store = useChatSessions();
const { send } = useChatModelDynamic();

/* 当前会话 */
const activeSession = computed(() => store.active);

/* 输入框内容 */
const input = ref("");

/* 下拉选择的 provider */
const provider = computed({
  get: () => activeSession.value?.provider ?? "deepseek",
  set: (v) => store.setProvider(v as any),
});

/* 自动聚焦最新会话 */
watch(
  () => store.sessions.length,
  (v) => {
    if (v && !store.activeId) store.activeId = store.sessions[0].id;
  }
);

/* 发送 */
function submit(text: string) {
  if (!text.trim() || !activeSession.value) return;
  send(text).catch((err) => alert(err.message));
  input.value = "";
}

/* 头像 */
const userAvatar = {
  imgSrc: "https://matechat.gitcode.com/png/demo/userAvatar.svg",
};
const modelAvatar = { imgSrc: "https://matechat.gitcode.com/logo.svg" };
</script>

<style scoped>
.layout {
  display: flex;
  height: 100vh;
}

.right {
  flex: 1;
  display: flex;
  justify-content: center;
}

.chat-wrapper {
  width: 800px;
  display: flex;
  flex-direction: column;
}

.chat-board {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 100%;
}

.content {
  flex: 1;
  overflow: auto;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 8px;
}

.placeholder {
  margin: auto;
  color: #bbb;
}

.input-bar {
  display: flex;
  gap: 8px;
  align-items: flex-end;
  padding: 8px;
  border-top: 1px solid #eee;
}
</style>
4.3. SessionList.vue
vue 复制代码
<template>
  <aside class="session-list">
    <header class="header">
      <span class="title">会话</span>
      <n-button size="small" @click="add"> 新增会话 </n-button>
    </header>

    <n-scrollbar style="height: 100%">
      <n-list hoverable clickable>
        <n-list-item
          v-for="s in store.sessions"
          :key="s.id"
          :class="{ active: s.id === store.activeId }"
          @click="store.activeId = s.id"
        >
          <div class="item">
            <span class="name">{{ s.title }}</span>
            <span class="time">{{ dayjs(s.updatedAt).fromNow() }}</span>
          </div>
        </n-list-item>
      </n-list>
    </n-scrollbar>
  </aside>
</template>

<script setup lang="ts">
import { NButton, NScrollbar, NList, NListItem } from "naive-ui";
import { useChatSessions } from "../stores/useChatSessions";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);

const store = useChatSessions();
store.restore();

function add() {
  store.newSession();
}
</script>

<style scoped>
.session-list {
  width: 220px;
  border-right: 1px solid #eee;
  display: flex;
  flex-direction: column;
}

.header {
  padding: 8px 12px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.item {
  display: flex;
  flex-direction: column;
  line-height: 1.2;
}

.name {
  font-weight: 500;
}

.time {
  font-size: 11px;
  color: #888;
}

.active {
  background: #f2f3ff;
}
</style>
4.4. llmProviders.ts
typescript 复制代码
export const LLM_PROVIDERS = {
  deepseek: {
    label: "DeepSeek",
    baseURL: "https://api.deepseek.com",
    model: "deepseek-reasoner",
    apiKey: "sk-xxx",
  },
  openai: {
    label: "OpenAI",
    baseURL: "https://api.openai.com/v1",
    model: "gpt-4o",
    apiKey: "sk-xxx",
  },
  qwen: {
    label: "Qwen · 通义千问",
    baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
    model: "qwen-max",
    apiKey: "sk-xxx",
  },
} as const;

export type ProviderName = keyof typeof LLM_PROVIDERS;
export const providerOptions = Object.entries(LLM_PROVIDERS).map(
  ([value, { label }]) => ({ label, value })
);
4.5. useChatModelDynamic.ts
typescript 复制代码
import OpenAI from "openai";
import { watch } from "vue";
import { LLM_PROVIDERS } from "../constants/llmProviders";
import { useChatSessions } from "../stores/useChatSessions";

export function useChatModelDynamic() {
  const store = useChatSessions();
  let client = createClient();

  watch(
    () => [store.activeId, store.active?.provider],
    () => {
      client = createClient();
    }
  );

  function createClient() {
    const p = store.active?.provider || "deepseek";
    const cfg = LLM_PROVIDERS[p];
    return new OpenAI({
      baseURL: cfg.baseURL,
      apiKey: cfg.apiKey,
      dangerouslyAllowBrowser: true,
    });
  }

  async function send(text: string) {
    store.pushMessage({ from: "user", content: text });
    store.pushMessage({ from: "model", content: "", loading: true });
    const idx = store.active!.messages.length - 1;

    const cfg = LLM_PROVIDERS[store.active!.provider];
    const stream = await client.chat.completions.create({
      model: cfg.model,
      messages: [{ role: "user", content: text }],
      stream: true,
    });

    store.active!.messages[idx].loading = false;
    for await (const chunk of stream) {
      store.active!.messages[idx].content +=
        chunk.choices[0]?.delta?.content || "";
    }
  }

  return { send };
}
4.6. useChatSessions.ts
typescript 复制代码
import { defineStore } from "pinia";
import { nanoid } from "nanoid";

export interface ChatMessage {
  id: string;
  from: "user" | "model";
  content: string;
  loading?: boolean;
  createdAt: number;
}

export interface ChatSession {
  id: string;
  title: string;
  provider: "deepseek" | "openai" | "qwen";
  messages: ChatMessage[];
  createdAt: number;
  updatedAt: number;
}

export const useChatSessions = defineStore("chatSessions", {
  state: () => ({
    sessions: [] as ChatSession[],
    activeId: "",
  }),
  getters: {
    active(state) {
      return state.sessions.find((s) => s.id === state.activeId);
    },
  },
  actions: {
    newSession(
      title = "新会话",
      provider: ChatSession["provider"] = "deepseek"
    ) {
      const id = nanoid();
      this.sessions.unshift({
        id,
        title,
        provider,
        messages: [],
        createdAt: Date.now(),
        updatedAt: Date.now(),
      });
      this.activeId = id;
      this.persist();
    },
    pushMessage(partial: Omit<ChatMessage, "id" | "createdAt">) {
      const s = this.active;
      if (!s) return;
      s.messages.push({ id: nanoid(), createdAt: Date.now(), ...partial });
      s.updatedAt = Date.now();
      this.persist();
    },
    setProvider(provider: ChatSession["provider"]) {
      if (this.active) {
        this.active.provider = provider;
        this.persist();
      }
    },
    persist() {
      localStorage.setItem("matechat-sessions", JSON.stringify(this.sessions));
    },
    restore() {
      const raw = localStorage.getItem("matechat-sessions");
      if (raw) this.sessions = JSON.parse(raw);
      if (!this.activeId && this.sessions.length)
        this.activeId = this.sessions[0].id;
    },
  },
});

5. 流程图

5.1. markdown
markdown 复制代码
graph TD
    A[用户界面] --> B[App.vue]
    B --> C[SessionList组件]
    B --> D[聊天区域]
    
    %% 状态管理流程
    C --> E[useChatSessions Store]
    D --> E
    E -->|本地存储| F[localStorage]
    F -->|恢复数据| E
    
    %% 消息处理流程
    D -->|用户输入| G[发送消息]
    G --> H[useChatModelDynamic]
    H -->|创建Client| I[OpenAI Client]
    I -->|读取配置| J[LLM_PROVIDERS 常量]
    I -->|API请求| K[AI服务提供商]
    K -->|流式响应| L[更新消息内容]
    L --> E
    
    %% 会话管理流程
    C -->|新建会话| M[newSession]
    C -->|切换会话| N[activeId更新]
    M --> E
    N --> E
    E -->|更新UI| B
    
    %% 提供商切换
    D -->|选择提供商| O[setProvider]
    O --> E
    O -->|触发重建Client| H
    
    %% 子图:数据流
    subgraph 数据流
        E
        F
    end
    
    %% 子图:UI组件
    subgraph UI组件
        B
        C
        D
    end
    
    %% 子图:AI交互
    subgraph AI交互
        H
        I
        J
        K
    end

6. 页面 UI

二、总结

  1. 用 Pinia Store + LocalStorage / IndexedDB;provider 与 messages 同级存储
  2. Pinia + IndexedDB:消息分片分页存储;内存仅留当前分页
  3. 会话分栏:左侧 SessionList、右侧 McLayout
相关推荐
chimpro几秒前
都2025年了还不会flex?一篇文章带你理顺flex!
前端
LeeAt几秒前
AI单词拍照识别移动端项目(一)
前端·react.js·ai编程
Mintopia8 分钟前
三维空间的 “切蛋糕大师”:八叉树的底层奥秘与妙用
前端·javascript·计算机图形学
布兰妮甜20 分钟前
单例模式在前端(JavaScript)中的实现与应用
前端·javascript·单例模式
Mintopia21 分钟前
Three.js 加载模型文件:从二进制到像素的奇幻漂流
前端·javascript·three.js
前端小巷子39 分钟前
跨域问题解决方案:JSONP
前端·javascript·面试
eric*16881 小时前
尚硅谷张天禹老师课程配套笔记
前端·vue.js·笔记·vue·尚硅谷·张天禹·尚硅谷张天禹
程序员爱钓鱼1 小时前
Go语言中的反射机制 — 元编程技巧与注意事项
前端·后端·go
GIS之路1 小时前
GeoTools 结合 OpenLayers 实现属性查询(二)
前端·信息可视化
烛阴2 小时前
一文搞懂 Python 闭包:让你的代码瞬间“高级”起来!
前端·python