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
相关推荐
天若有情67311 分钟前
新闻通稿 | 软件产业迈入“智能重构”新纪元:自主进化、人机共生与责任挑战并存
服务器·前端·后端·重构·开发·资讯·新闻
香香爱编程17 分钟前
electron对于图片/视频无法加载的问题
前端·javascript·vue.js·chrome·vscode·electron·npm
程序猿_极客1 小时前
【期末网页设计作业】HTML+CSS+JavaScript 蜡笔小新 动漫主题网站设计与实现(附源码)
前端·javascript·css·html·课程设计·期末网页设计
zl_vslam1 小时前
SLAM中的非线性优-3D图优化之轴角在Opencv-PNP中的应用(一)
前端·人工智能·算法·计算机视觉·slam se2 非线性优化
CDwenhuohuo2 小时前
用spark-md5实现切片上传前端起node模拟上传文件大小,消耗时间
前端
阿桂有点桂2 小时前
React使用笔记(持续更新中)
前端·javascript·react.js·react
自由日记2 小时前
实例:跳动的心,火柴人
前端·css·css3
柯腾啊2 小时前
一文简单入门 Axios
前端·axios·apifox
im_AMBER2 小时前
React 15
前端·javascript·笔记·学习·react.js·前端框架
Kratos开源社区2 小时前
别卷 LangChain 了!Blades AI 框架让 Go 开发者轻松打造智能体
go·agent·ai编程