feat:增加美食分析助手
This commit is contained in:
@@ -31,6 +31,10 @@ server {
|
|||||||
proxy_pass http://host.docker.internal:3000/;
|
proxy_pass http://host.docker.internal:3000/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /agent-api/ {
|
||||||
|
proxy_pass http://host.docker.internal:8000/;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
<i class="fa-solid fa-chart-pie"></i>
|
<i class="fa-solid fa-chart-pie"></i>
|
||||||
<span>统计</span>
|
<span>统计</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-if="path.includes('bill')" to="/bill/search"
|
<router-link v-if="path.includes('bill')" to="/bill/agent"
|
||||||
@click="routerClick()" class="item">
|
@click="routerClick()" class="item">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
<span>搜索</span>
|
<span>分析</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link v-if="path.includes('food')" to="/food/recipe"
|
<router-link v-if="path.includes('food')" to="/food/recipe"
|
||||||
|
|||||||
16
src/types/agent.ts
Normal file
16
src/types/agent.ts
Normal 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;
|
||||||
|
}
|
||||||
225
src/views/bill/agent.vue
Normal file
225
src/views/bill/agent.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<div class="common-mobile-view">
|
||||||
|
<main class="message-container" ref="msgContainer">
|
||||||
|
<div v-if="agentInfo.chatMessages.length === 0" class="empty-state">
|
||||||
|
<h2>您好!我是你的智能账单分析助手</h2>
|
||||||
|
<p>请问有什么可以帮你的?</p>
|
||||||
|
|
||||||
|
<div class="quick-actions">
|
||||||
|
<el-button v-for="q in quickQuestions" :key="q" type="primary" round
|
||||||
|
@click="askQuick(q)">
|
||||||
|
{{ q }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(item, index) in agentInfo.chatMessages" :key="index"
|
||||||
|
class="msg-row" :class="item.role">
|
||||||
|
<div class="bubble">
|
||||||
|
<MdPreview v-if="item.role === 'ai'" :modelValue="item.content" />
|
||||||
|
<span v-else>{{ item.content }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="agentInfo.loading" class="msg-row ai">
|
||||||
|
<div class="bubble typing">
|
||||||
|
<span /><span /><span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="input-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="agentInfo.inputMessage"
|
||||||
|
type="textarea" autosize
|
||||||
|
placeholder="给智能助手发送消息"
|
||||||
|
@keyup.enter="sendMessage"
|
||||||
|
:disabled="agentInfo.loading"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" circle :loading="agentInfo.loading"
|
||||||
|
:disabled="!agentInfo.inputMessage.trim()" @click="sendMessage">
|
||||||
|
<template #icon>
|
||||||
|
<el-icon><Promotion /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Promotion } from '@element-plus/icons-vue'
|
||||||
|
import { MdPreview } from 'md-editor-v3'
|
||||||
|
import 'md-editor-v3/lib/preview.css'
|
||||||
|
import { nextTick, reactive, ref } from 'vue'
|
||||||
|
import { IChatMessage } from '@/types/agent.ts'
|
||||||
|
import { isMobile } from 'vue3-common/utils/layoutUtil'
|
||||||
|
|
||||||
|
const msgContainer = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const agentInfo = reactive({
|
||||||
|
inputMessage: '',
|
||||||
|
loading: false,
|
||||||
|
chatMessages: [] as IChatMessage[]
|
||||||
|
})
|
||||||
|
|
||||||
|
const quickQuestions = [
|
||||||
|
'帮我分析下这个月的账单',
|
||||||
|
'帮我查询下最近花费最大的一笔是什么?',
|
||||||
|
'帮我查询下今年存了多少钱?'
|
||||||
|
]
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = msgContainer.value
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!agentInfo.inputMessage.trim() || agentInfo.loading) return
|
||||||
|
|
||||||
|
const userMsg = agentInfo.inputMessage
|
||||||
|
agentInfo.inputMessage = ''
|
||||||
|
agentInfo.chatMessages.push({ role: 'user', content: userMsg })
|
||||||
|
agentInfo.loading = true
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/agent-api/query/bill', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: userMsg, platform: isMobile() ? 'mobile' : 'pc' })
|
||||||
|
})
|
||||||
|
|
||||||
|
agentInfo.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 })
|
||||||
|
agentInfo.chatMessages[agentInfo.chatMessages.length - 1].content = aiText
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
agentInfo.loading = false
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const askQuick = (q: string) => {
|
||||||
|
agentInfo.inputMessage = q
|
||||||
|
sendMessage()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.message-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-row {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&.ai {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 78%;
|
||||||
|
line-height: 1.6;
|
||||||
|
border-radius: 16px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ai .bubble {
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.user .bubble {
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #bbb;
|
||||||
|
animation: blink 1.2s infinite both;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="common-mobile-view">
|
|
||||||
<bill-search />
|
|
||||||
|
|
||||||
<div v-if="billStore.billRecordList.length === 0">
|
|
||||||
<el-empty description="暂无数据"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="bill-search-list card-item">
|
|
||||||
<div class="search-result-title">
|
|
||||||
<span>收入:{{ formatAmount(getTotalAmount(billStore.billRecordList, 'income')) }}</span>
|
|
||||||
<span>支出:{{ formatAmount(getTotalAmount(billStore.billRecordList, 'expense')) }}</span>
|
|
||||||
</div>
|
|
||||||
<daily-card v-for="(item, index) in billStore.billRecordList"
|
|
||||||
:key="index" @click="selectBillRecordClick(item)"
|
|
||||||
:title="item.category" :content="item.content">
|
|
||||||
<template #icon>
|
|
||||||
<i :class="getCategoryByName(billStore.billCategoryList, item.category).icon"
|
|
||||||
:style="{color: getCategoryByName(billStore.billCategoryList, item.category).color}">
|
|
||||||
</i>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
|
||||||
<span v-if="item.location" class="location">
|
|
||||||
<i class="fa-solid fa-location-dot"></i>
|
|
||||||
{{ item.location }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #extra>
|
|
||||||
<span>{{ formatAmount(item.amount, true) }}</span>
|
|
||||||
<span>{{ item.payAccount }}</span>
|
|
||||||
</template>
|
|
||||||
</daily-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import BillSearch from '@/components/Bill/BillSearch.vue'
|
|
||||||
import DailyCard from '@/components/DailyCard.vue'
|
|
||||||
import { onMounted } from 'vue'
|
|
||||||
import { useBillStore } from '@/store/bill.ts'
|
|
||||||
import { getCategoryByName, getTotalAmount } from '@/utils/home/billUtil'
|
|
||||||
import { formatAmount } from 'vue3-common/utils/numberUtil'
|
|
||||||
import { IBillRecord } from '@/types/bill'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const billStore = useBillStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await billStore.searchInfo()
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectBillRecordClick = async (billRecord: IBillRecord) => {
|
|
||||||
await billStore.queryBillRecord(billRecord.id as number)
|
|
||||||
billStore.billApiType = 'UPDATE'
|
|
||||||
await router.push({
|
|
||||||
name: 'BillDetailId',
|
|
||||||
params: { id: billStore.currentBillRecord.id }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.bill-search-list {
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
.search-result-title {
|
|
||||||
align-self: flex-end;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}</style>
|
|
||||||
@@ -15,8 +15,8 @@ export const mobileHomeMeta: IRouteMetaConfig = {
|
|||||||
title: '账单图表',
|
title: '账单图表',
|
||||||
icon: ''
|
icon: ''
|
||||||
},
|
},
|
||||||
'/bill/search': {
|
'/bill/agent': {
|
||||||
title: '账单搜索',
|
title: '分析',
|
||||||
icon: ''
|
icon: ''
|
||||||
},
|
},
|
||||||
'/bill/detail/:id': {
|
'/bill/detail/:id': {
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ export default defineConfig({
|
|||||||
target: 'http://192.168.1.7:3000',
|
target: 'http://192.168.1.7:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (item) => item.replace(/^\/image-api/, '')
|
rewrite: (item) => item.replace(/^\/image-api/, '')
|
||||||
|
},
|
||||||
|
'/agent-api': {
|
||||||
|
target: `http://${API_HOST}:8000`,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (item) => item.replace(/^\/agent-api/, '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user