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

二、总结
- 用 Pinia Store + LocalStorage / IndexedDB;provider 与 messages 同级存储
- Pinia + IndexedDB:消息分片分页存储;内存仅留当前分页
- 会话分栏:左侧 SessionList、右侧 McLayout