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
相关推荐
funfan05174 小时前
Claude4、GPT4、Kimi K2、Gemini2.5、DeepSeek R1、Code Llama等2025主流AI编程大模型多维度对比分析报告
ai编程
草梅友仁4 小时前
草梅 Auth 1.1.0 发布与最新动态 | 2025 年第 30 周草梅周报
开源·github·ai编程
LinXunFeng5 小时前
AI - Gemini CLI 摆脱终端限制
openai·ai编程·gemini
灵感__idea5 小时前
JavaScript高级程序设计(第5版):好的编程就是掌控感
前端·javascript·程序员
程序员X小鹿6 小时前
腾讯还是太全面了,限时免费!超全CodeBuddy IDE保姆级教程!(附案例)
ai编程
烛阴6 小时前
Mix
前端·webgl
代码续发6 小时前
前端组件梳理
前端
试图让你心动7 小时前
原生input添加删除图标类似vue里面移入显示删除[jquery]
前端·vue.js·jquery
陈不知代码7 小时前
uniapp创建vue3+ts+pinia+sass项目
前端·uni-app·sass
小王码农记7 小时前
sass中@mixin与 @include
前端·sass