feat:增加历史消息模块

This commit is contained in:
2026-05-31 21:14:32 +08:00
parent 41ef49a04f
commit d47383cd95
9 changed files with 426 additions and 139 deletions

View File

@@ -11,6 +11,7 @@
"dependencies": {
"element-plus": "^2.9.7",
"md-editor-v3": "^6.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.34"
},
"devDependencies": {

99
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
md-editor-v3:
specifier: ^6.4.0
version: 6.5.1(vue@3.5.35(typescript@6.0.3))
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3))
vue:
specifier: ^3.5.34
version: 3.5.35(typescript@6.0.3)
@@ -494,6 +497,15 @@ packages:
'@vue/compiler-ssr@3.5.35':
resolution: {integrity: sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==}
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
'@vue/devtools-kit@7.7.9':
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
'@vue/devtools-shared@7.7.9':
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
'@vue/language-core@3.3.2':
resolution: {integrity: sha512-CLwjSfHlPLhjd2qhuS3tTFtnOIWHXAM5u4X1DxmzlQ8j5bmOYlKCsSusOP7jCRJnlVg0mCTQtHU3vwFvopZGoQ==}
@@ -547,6 +559,9 @@ packages:
async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
@@ -557,6 +572,10 @@ packages:
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
@@ -603,6 +622,9 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
immutable@5.1.5:
resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
@@ -614,6 +636,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -737,6 +763,9 @@ packages:
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
@@ -754,6 +783,9 @@ packages:
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -761,6 +793,15 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
typescript: '>=4.5.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
@@ -773,6 +814,9 @@ packages:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rolldown@1.0.2:
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -787,9 +831,17 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
@@ -1480,6 +1532,24 @@ snapshots:
'@vue/compiler-dom': 3.5.35
'@vue/shared': 3.5.35
'@vue/devtools-api@7.7.9':
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-kit@7.7.9':
dependencies:
'@vue/devtools-shared': 7.7.9
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.6
'@vue/devtools-shared@7.7.9':
dependencies:
rfdc: 1.4.1
'@vue/language-core@3.3.2':
dependencies:
'@volar/language-core': 2.4.28
@@ -1538,6 +1608,8 @@ snapshots:
async-validator@4.2.5: {}
birpc@2.9.0: {}
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
@@ -1554,6 +1626,10 @@ snapshots:
commander@2.20.3: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
crelt@1.0.6: {}
cssfilter@0.0.10: {}
@@ -1596,6 +1672,8 @@ snapshots:
fsevents@2.3.3:
optional: true
hookable@5.5.3: {}
immutable@5.1.5: {}
is-extglob@2.1.1:
@@ -1606,6 +1684,8 @@ snapshots:
is-extglob: 2.1.1
optional: true
is-what@5.5.0: {}
lightningcss-android-arm64@1.32.0:
optional: true
@@ -1720,6 +1800,8 @@ snapshots:
memoize-one@6.0.0: {}
mitt@3.0.1: {}
muggle-string@0.4.1: {}
nanoid@3.3.12: {}
@@ -1731,10 +1813,19 @@ snapshots:
path-browserify@1.0.1: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
picomatch@4.0.4: {}
pinia@3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3)):
dependencies:
'@vue/devtools-api': 7.7.9
vue: 3.5.35(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
postcss@8.5.15:
dependencies:
nanoid: 3.3.12
@@ -1745,6 +1836,8 @@ snapshots:
readdirp@5.0.0: {}
rfdc@1.4.1: {}
rolldown@1.0.2:
dependencies:
'@oxc-project/types': 0.132.0
@@ -1776,8 +1869,14 @@ snapshots:
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
style-mod@4.1.3: {}
superjson@2.2.6:
dependencies:
copy-anything: 4.0.5
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)

View File

@@ -1,145 +1,100 @@
<template>
<div class="app">
<!-- 头部 -->
<header class="header">食刻 AI</header>
<div class="layout">
<!-- 移动端抽屉 -->
<el-drawer
v-model="drawerVisible"
direction="ltr"
:size="250"
class="mobile-drawer"
>
<div class="sidebar"><session-message /></div>
</el-drawer>
<!-- 聊天区域 -->
<main v-if="messages.length" class="chat-box">
<div v-for="(item, index) in messages" :key="index" class="message" :class="item.role">
<!-- 用户 -->
<div v-if="item.role === 'user'" class="bubble">
{{ item.text }}
<el-aside v-if="!isMobile" width="250px" class="pc-aside">
<div class="sidebar"><session-message /></div>
</el-aside>
<!-- 主内容 -->
<el-container class="main-container">
<el-header class="header">
<el-icon v-if="isMobile" @click="drawerVisible = true"><Fold/></el-icon>
<div class="website-title">
网站标题
</div>
</el-header>
<!-- AI -->
<MdPreview v-else :modelValue="item.text" class="md"/>
</div>
<div v-if="loading" class="loading">
<el-icon class="is-loading"><Loading /></el-icon>
思考中
</div>
</main>
<!-- 输入区 -->
<footer class="input-area">
<el-input v-model="input" placeholder="请输入你的食材" @keyup.enter="send" clearable/>
<el-button type="primary" :loading="loading" @click="send">发送</el-button>
</footer>
<el-main class="main-body">
<agent />
</el-main>
</el-container>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { Loading } from "@element-plus/icons-vue";
import { MdPreview } from 'md-editor-v3';
import 'md-editor-v3/lib/preview.css';
<script lang="ts" setup>
import Agent from "./components/Agent.vue";
import SessionMessage from "./components/SessionMessage.vue";
import { ref, onMounted, onUnmounted } from 'vue'
import { Fold } from '@element-plus/icons-vue'
interface Message {
role: "user" | "ai";
text: string;
const isMobile = ref(window.innerWidth < 768)
const drawerVisible = ref(false)
const onResize = () => {
isMobile.value = window.innerWidth < 768
if (!isMobile.value) drawerVisible.value = false
}
const input = ref("");
const messages = ref<Message[]>([]);
const loading = ref(false);
const send = async () => {
if (!input.value.trim()) return;
const userMsg = input.value;
input.value = "";
messages.value.push({ role: "user", text: userMsg });
loading.value = true;
try {
const response = await fetch(
"/chief-agent-api/chat/stream",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: userMsg })
}
);
messages.value.push({ role: "ai", text: "" });
const reader = response.body!.getReader();
const decoder = new TextDecoder("utf-8");
let aiText = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
aiText += decoder.decode(value, { stream: true });
messages.value[messages.value.length - 1].text = aiText;
}
} finally {
loading.value = false;
}
};
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
</script>
<style lang="scss" scoped>
.app {
<style lang="scss">
.layout {
display: flex;
flex-direction: column;
gap: 10px;
padding: 5px;
height: 100%;
.header {
text-align: center;
font-size: 18px;
font-weight: 600;
}
.chat-box {
background-color: #FBF9F5;
flex: 1;
overflow-y: auto;
padding: 10px;
.message {
margin-bottom: 12px;
}
.user {
text-align: right;
}
.ai {
text-align: left;
}
.bubble {
display: inline-block;
padding: 5px 10px;
border-radius: 14px;
max-width: 85%;
word-break: break-word;
line-height: 1.6;
background: #409eff;
.pc-aside {
background: gainsboro;
color: #fff;
}
.md {
background: #f0f2f5;
border-radius: 14px;
padding: 5px;
}
.sidebar {
padding: 16px;
color: #333;
}
.input-area {
.main-container {
flex: 1;
min-width: 0;
display: flex;
gap: 8px;
flex-direction: column;
.header {
height: 50px;
background: #fff;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: center;
position: relative;
.el-icon {
position: absolute;
left: 12px;
cursor: pointer;
}
.loading {
text-align: center;
color: #909399;
font-size: 14px;
margin: 8px 0;
.website-title {
font-size: 18px;
font-weight: 600;
}
}
.main-body {
flex: 1;
padding: 10px;
}
}
}
</style>

159
src/components/Agent.vue Normal file
View File

@@ -0,0 +1,159 @@
<template>
<div class="agent-container">
<!-- 聊天区域 -->
<main class="chat-container">
<div v-for="(item, index) in agentStore.chatMessages" :key="index" class="message" :class="item.role">
<!-- 用户 -->
<div v-if="item.role === 'user'" class="bubble">
{{ item.content }}
</div>
<!-- AI -->
<MdPreview v-else :modelValue="item.content" class="md"/>
</div>
<div v-if="agentStore.loading" class="loading">
<el-icon class="is-loading"><Loading /></el-icon>
思考中
</div>
</main>
<!-- 输入区 -->
<footer class="input-container">
<el-input v-model="agentStore.input" placeholder="请输入你的食材" @keyup.enter="sendMessage" clearable/>
<el-button type="primary" :loading="agentStore.loading" @click="sendMessage">发送</el-button>
</footer>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { Loading } from "@element-plus/icons-vue";
import { MdPreview } from 'md-editor-v3';
import 'md-editor-v3/lib/preview.css';
import { useAgentStore } from "../stores/agent.ts";
const agentStore = useAgentStore();
onMounted(() => {
getSessionMessage()
})
const getSessionMessage = async () => {
const response = await fetch(
"/chief-agent-api/chat/messages/session?username=Cxx0822",
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
agentStore.sessionMessages = await response.json();
agentStore.threadId = crypto.randomUUID()
}
const sendMessage = async () => {
if (!agentStore.input.trim()) return;
const userMsg = agentStore.input;
agentStore.input = "";
agentStore.chatMessages.push({ role: "user", content: userMsg });
agentStore.loading = true;
try {
agentStore.agentMessage = {
username: 'Cxx0822',
message: userMsg,
thread_id: agentStore.threadId,
}
const response = await fetch(
"/chief-agent-api/chat/stream",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(agentStore.agentMessage)
}
);
agentStore.chatMessages.push({ role: "ai", content: "" });
const reader = response.body!.getReader();
const decoder = new TextDecoder("utf-8");
let aiText = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
aiText += decoder.decode(value, { stream: true });
agentStore.chatMessages[agentStore.chatMessages.length - 1].content = aiText;
}
} finally {
agentStore.loading = false;
}
};
</script>
<style lang="scss">
.agent-container {
height: 100%;
background-color: whitesmoke;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 5px;
.chat-container {
flex: 1;
overflow-y: auto;
padding: 10px;
.message {
margin-bottom: 12px;
}
.user {
text-align: right;
}
.ai {
text-align: left;
}
.bubble {
display: inline-block;
padding: 5px 10px;
border-radius: 14px;
max-width: 85%;
word-break: break-word;
line-height: 1.6;
background: #409eff;
color: #fff;
}
.md {
background: #f0f2f5;
border-radius: 14px;
padding: 5px;
}
}
.input-container {
background-color: white;
width: 100%;
padding: 10px;
box-sizing: border-box;
display: flex;
gap: 8px;
}
.loading {
text-align: center;
color: #909399;
font-size: 14px;
margin: 8px 0;
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div class="session-message-container">
<el-scrollbar>
<div
v-for="item in agentStore.sessionMessages"
:key="item.thread_id"
class="scrollbar-demo-item"
@click="viewMessage(item)"
>
{{ item.title }}
</div>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import { useAgentStore } from "../stores/agent.ts";
import type {ISessionMessage} from "../types/agent.ts";
const agentStore = useAgentStore();
const viewMessage = async (session: ISessionMessage) => {
const response = await fetch(
`/chief-agent-api/chat/messages?thread_id=${session.thread_id}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
agentStore.chatMessages = await response.json();
agentStore.threadId = session.thread_id
}
</script>
<style lang="scss">
.scrollbar-demo-item {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
padding: 12px;
border-radius: 4px;
background: darkgray;
color: white;
}
</style>

View File

@@ -1,4 +1,5 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
// 引入element-ui组件
@@ -8,8 +9,11 @@ import 'element-plus/theme-chalk/dark/css-vars.css'
import 'dayjs/locale/zh-cn'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import './style.css'
// 创建Vue3实例
const app = createApp(App)
app.use(createPinia())
// 使用Element UI Plus
app.use(ElementPlus, { locale: zhCn })
// 挂载到根组件上

15
src/stores/agent.ts Normal file
View File

@@ -0,0 +1,15 @@
// src/stores/chat.ts
import {defineStore} from 'pinia'
import type {IAgentMessage, IChatMessage, ISessionMessage} from "../types/agent.ts";
export const useAgentStore = defineStore('agent', {
state: () => ({
threadId: '0',
chatMessages: [] as IChatMessage[],
sessionMessages: [] as ISessionMessage[],
agentMessage: {} as IAgentMessage,
input: '',
loading: false,
}),
actions: {}
})

View File

@@ -28,18 +28,7 @@ a:hover {
text-decoration: none;
}
html {
font-size: 16px;
}
@media (max-width: 1280px) {
html {
font-size: 15px;
}
}
@media (max-width: 480px) {
html {
font-size: 14px;
}
html, body, #app {
height: 100%;
margin: 0;
}

16
src/types/agent.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface IChatMessage {
role: "user" | "ai";
content: string;
}
export interface IAgentMessage {
username: string;
message: string;
thread_id: string;
}
export interface ISessionMessage {
username: string;
title: string;
thread_id: string;
}