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
相关推荐
华玥作者3 小时前
[特殊字符] VitePress 对接 Algolia AI 问答(DocSearch + AI Search)完整实战(下)
前端·人工智能·ai
Mr Xu_3 小时前
告别冗长 switch-case:Vue 项目中基于映射表的优雅路由数据匹配方案
前端·javascript·vue.js
前端摸鱼匠3 小时前
Vue 3 的toRefs保持响应性:讲解toRefs在解构响应式对象时的作用
前端·javascript·vue.js·前端框架·ecmascript
lang201509283 小时前
JSR-340 :高性能Web开发新标准
java·前端·servlet
好家伙VCC4 小时前
### WebRTC技术:实时通信的革新与实现####webRTC(Web Real-TimeComm
java·前端·python·webrtc
未来之窗软件服务5 小时前
未来之窗昭和仙君(六十五)Vue与跨地区多部门开发—东方仙盟练气
前端·javascript·vue.js·仙盟创梦ide·东方仙盟·昭和仙君
嘿起屁儿整5 小时前
面试点(网络层面)
前端·网络
VT.馒头5 小时前
【力扣】2721. 并行执行异步函数
前端·javascript·算法·leetcode·typescript
phltxy6 小时前
Vue 核心特性实战指南:指令、样式绑定、计算属性与侦听器
前端·javascript·vue.js
Byron07077 小时前
Vue 中使用 Tiptap 富文本编辑器的完整指南
前端·javascript·vue.js