feat:增加美食分析助手
This commit is contained in:
@@ -31,6 +31,10 @@ server {
|
||||
proxy_pass http://host.docker.internal:3000/;
|
||||
}
|
||||
|
||||
location /agent-api/ {
|
||||
proxy_pass http://host.docker.internal:8000/;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<i class="fa-solid fa-chart-pie"></i>
|
||||
<span>统计</span>
|
||||
</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">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
<span>搜索</span>
|
||||
<span>分析</span>
|
||||
</router-link>
|
||||
|
||||
<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: '账单图表',
|
||||
icon: ''
|
||||
},
|
||||
'/bill/search': {
|
||||
title: '账单搜索',
|
||||
'/bill/agent': {
|
||||
title: '分析',
|
||||
icon: ''
|
||||
},
|
||||
'/bill/detail/:id': {
|
||||
|
||||
@@ -59,6 +59,11 @@ export default defineConfig({
|
||||
target: 'http://192.168.1.7:3000',
|
||||
changeOrigin: true,
|
||||
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