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
相关推荐
小二·21 分钟前
Python Web 开发进阶实战:无障碍深度集成 —— 构建真正包容的 Flask + Vue 应用
前端·python·flask
niucloud-admin8 小时前
web 端前端
前端
zuozewei10 小时前
7D-AI系列:OpenSpec:AI编程范式的规范驱动框架
人工智能·ai编程
胖者是谁11 小时前
EasyPlayerPro的使用方法
前端·javascript·css
EndingCoder11 小时前
索引类型和 keyof 操作符
linux·运维·前端·javascript·ubuntu·typescript
liux352812 小时前
Web集群管理实战指南:从架构到运维
运维·前端·架构
沛沛老爹12 小时前
Web转AI架构篇 Agent Skills vs MCP:工具箱与标准接口的本质区别
java·开发语言·前端·人工智能·架构·企业开发
小光学长12 小时前
基于Web的长江游轮公共服务系统j225o57w(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
前端·数据库
Joe55613 小时前
vue2 + antDesign 下拉框限制只能选择2个
服务器·前端·javascript
ChangYan.14 小时前
monorepo 多包管理识别不到新增模块,解决办法
前端·chrome