finai/frontend/src/views/ChatView.vue

427 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useChat } from '@/composables/useChat'
import { useAppStore } from '@/stores/app'
import { renderMarkdown } from '@/utils/markdown'
import assistantAvatar from '@/assets/sysA.jpeg'
const store = useAppStore()
const {
sessions, currentSessionId, messages, streaming,
loadSessions, selectSession, createSession, deleteSession, sendQuestion,
uploadImage,
} = useChat()
const question = ref('')
const messagesEl = ref(null)
const fileInput = ref(null)
const pendingImages = ref([])
const sessionFilter = ref('')
const deepThinking = ref(false)
const lightboxSrc = ref('')
function onBubbleClick(e) {
if (e.target?.tagName === 'IMG' && e.target.src) {
lightboxSrc.value = e.target.src
}
}
function scrollToEnd() {
nextTick(() => {
const el = messagesEl.value
if (el) el.scrollTop = el.scrollHeight
})
}
async function pickImages() {
fileInput.value?.click()
}
async function onFilesPicked(event) {
const files = Array.from(event.target.files || [])
event.target.value = ''
for (const f of files) {
if (!/^image\/(png|jpe?g)$/.test(f.type)) {
store.toast(`${f.name} 不是 PNG/JPG`, 'warning')
continue
}
if (f.size > 8 * 1024 * 1024) {
store.toast(`${f.name} 超过 8MB`, 'warning')
continue
}
try {
const att = await uploadImage(f)
const data_url = await readAsDataUrl(f)
pendingImages.value.push({ id: att.id, file_type: f.type, data_url })
} catch (err) {
store.toast(`上传失败:${err.message}`, 'error')
}
}
}
function readAsDataUrl(file) {
return new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result)
r.onerror = () => reject(r.error)
r.readAsDataURL(file)
})
}
function removePendingImage(idx) {
pendingImages.value.splice(idx, 1)
}
async function submit() {
const text = question.value
const attachments = pendingImages.value.slice()
if (!text.trim() && attachments.length === 0) return
question.value = ''
pendingImages.value = []
try {
await sendQuestion(text, attachments)
} catch (err) {
store.toast(err.message)
}
}
function onComposerKeydown(e) {
if (e.key !== 'Enter') return
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return
if (e.isComposing || e.keyCode === 229) return
e.preventDefault()
if (streaming.value) return
submit()
}
async function onCreateSession() {
try {
await createSession()
} catch (err) {
store.toast(err.message)
}
}
async function onDeleteSession(session) {
try {
await deleteSession(session)
store.toast('会话已删除')
} catch (err) {
store.toast(err.message)
}
}
async function onSelectSession(id) {
try {
await selectSession(id)
} catch (err) {
store.toast(err.message)
}
}
function copyText(text) {
if (!text) return
navigator.clipboard?.writeText(text).then(
() => store.toast('已复制', 'success'),
() => store.toast('复制失败', 'warning'),
)
}
function startOfDay(d) {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
return x.getTime()
}
const sessionGroups = computed(() => {
const todayStart = startOfDay(new Date())
const yesterdayStart = todayStart - 86400000
const kw = sessionFilter.value.trim().toLowerCase()
const groups = { today: [], yesterday: [], earlier: [] }
for (const s of sessions.value) {
if (kw && !(s.title || '').toLowerCase().includes(kw)) continue
const ts = new Date(s.last_message_at || s.created_at).getTime()
if (ts >= todayStart) groups.today.push(s)
else if (ts >= yesterdayStart) groups.yesterday.push(s)
else groups.earlier.push(s)
}
return groups
})
function formatSessionTime(s) {
const d = new Date(s.last_message_at || s.created_at)
if (Number.isNaN(d.getTime())) return ''
const todayStart = startOfDay(new Date())
if (d.getTime() >= todayStart) {
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }).replace(/\//g, '/')
}
function formatBubbleTime(msg) {
const ts = msg?.created_at
const d = ts ? new Date(ts) : new Date()
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
const userAvatarInitial = computed(() => (store.currentUser?.name || '我')[0].toUpperCase())
onMounted(async () => {
if (store.token) {
try { await loadSessions() } catch (err) { store.toast(err.message) }
}
})
watch(() => store.token, async (val) => {
if (val) await loadSessions()
else {
sessions.value = []
messages.value = []
currentSessionId.value = null
}
})
watch(messages, scrollToEnd, { deep: true })
</script>
<template>
<section class="view is-visible chat-layout">
<div class="welcome-banner">
<div class="welcome-text">
<div class="welcome-greeting">您好欢迎回来</div>
<h1 class="welcome-title">我是您的财税<em>AI</em>助手</h1>
<p class="welcome-subtitle">专注财税咨询 · 智能问答 · 专业可靠</p>
</div>
<div class="welcome-art">
<div class="welcome-art-bubble b1"></div>
<div class="welcome-art-bubble b2"></div>
<div class="welcome-art-bubble b3"></div>
<div class="welcome-art-bubble b4"></div>
<span class="welcome-art-text">AI</span>
</div>
</div>
<div class="chat-layout-body">
<section class="conversation-panel">
<div class="panel-head">
<h2>历史会话</h2>
</div>
<div class="session-toolbar">
<button class="primary-btn session-new" type="button" @click="onCreateSession">
<span>+</span>
<span>新建对话</span>
</button>
<div class="session-search">
<span class="session-search-icon">🔍</span>
<input v-model="sessionFilter" placeholder="筛选" />
</div>
</div>
<div class="session-list">
<div v-if="!sessions.length" class="empty-state small">
{{ store.token ? '暂无会话' : '请先登录' }}
</div>
<template v-else>
<template v-for="group in [
{ key: 'today', label: '今天', items: sessionGroups.today },
{ key: 'yesterday', label: '昨天', items: sessionGroups.yesterday },
{ key: 'earlier', label: '更早', items: sessionGroups.earlier },
]" :key="group.key">
<div v-if="group.items.length" class="session-group">
<div class="session-group-title">{{ group.label }}</div>
<div
v-for="session in group.items"
:key="session.id"
class="session-item"
:class="{ active: session.id === currentSessionId }"
>
<button class="session-body" type="button" @click="onSelectSession(session.id)">
<div class="session-title">{{ session.title || '未命名会话' }}</div>
</button>
<span class="session-meta">{{ formatSessionTime(session) }}</span>
<button
class="session-delete"
type="button"
title="删除会话"
aria-label="删除会话"
@click.stop="onDeleteSession(session)"
>×</button>
</div>
</div>
</template>
</template>
</div>
<div class="session-footer">
<button class="ghost-btn session-view-all" type="button">⌖ 查看全部记录</button>
</div>
</section>
<section class="chat-panel">
<div ref="messagesEl" class="messages">
<div v-if="!messages.length" class="empty-state">
输入一个财务制度或流程问题开始问答
</div>
<div
v-for="(msg, idx) in messages"
:key="idx"
class="message"
:class="msg.role"
>
<div v-if="msg.role === 'assistant'" class="msg-avatar assistant">
<img :src="assistantAvatar" alt="AI助手" />
</div>
<div class="bubble-col">
<div class="bubble">
<template v-if="msg.role === 'user'">
<div v-if="msg.attachments && msg.attachments.length" class="bubble-images">
<a
v-for="(att, aIdx) in msg.attachments"
:key="aIdx"
:href="att.data_url"
target="_blank"
rel="noopener"
class="bubble-image-link"
>
<img :src="att.data_url" :alt="`附件${aIdx + 1}`" />
</a>
</div>
<div v-if="msg.text" class="bubble-body">{{ msg.text }}</div>
</template>
<template v-else>
<div
v-if="!msg.text && streaming && idx === messages.length - 1"
class="typing-indicator"
aria-label="AI 正在生成回答"
>
<span></span><span></span><span></span>
</div>
<div
v-else
class="bubble-body markdown-body"
v-html="renderMarkdown(msg.text)"
@click="onBubbleClick"
/>
<div v-if="msg.references && msg.references.length" class="bubble-references">
<div class="bubble-references-title">参考来源</div>
<div class="bubble-references-body">
<details
v-for="(ref, rIdx) in msg.references"
:key="rIdx"
class="bubble-reference-item"
>
<summary>
<span class="ref-icon">📄</span>
<span class="ref-title">{{ rIdx + 1 }}.{{ ref.source_title || '未知来源' }}</span>
<small v-if="ref.page_no" class="ref-page">第 {{ ref.page_no }} 页</small>
<span class="ref-arrow"></span>
</summary>
<a
v-if="ref.source_url"
:href="ref.source_url"
target="_blank"
rel="noopener noreferrer"
class="ref-url-line"
@click.stop
>🔗 {{ ref.source_url }}</a>
<div v-if="ref.snippet" class="ref-snippet">{{ ref.snippet.trim() }}</div>
</details>
</div>
</div>
</template>
</div>
<div class="bubble-foot">
<template v-if="msg.role === 'assistant'">
<div class="bubble-actions">
<button type="button" title="复制" @click="copyText(msg.text)">⎘</button>
<button type="button" title="赞">👍</button>
<button type="button" title="踩">👎</button>
<button type="button" title="分享">↗</button>
</div>
<span class="bubble-time">{{ formatBubbleTime(msg) }}</span>
</template>
<template v-else>
<span class="bubble-time">{{ formatBubbleTime(msg) }}</span>
</template>
</div>
</div>
<div v-if="msg.role === 'user'" class="msg-avatar user">{{ userAvatarInitial }}</div>
</div>
</div>
<form class="composer" @submit.prevent="submit">
<div v-if="pendingImages.length" class="composer-images">
<div
v-for="(img, idx) in pendingImages"
:key="img.id"
class="composer-image"
>
<img :src="img.data_url" :alt="`图片${idx + 1}`" />
<button
class="composer-image-remove"
type="button"
title="移除"
@click="removePendingImage(idx)"
>×</button>
</div>
</div>
<textarea
v-model="question"
rows="2"
placeholder="输入问题,或上传图片/文件AI 将为您解答..."
:disabled="streaming"
@keydown="onComposerKeydown"
/>
<div class="composer-row">
<div class="composer-chips">
<button type="button" class="composer-chip" :disabled="streaming" @click="pickImages">
<span>📎</span><span>上传文件</span>
</button>
<button type="button" class="composer-chip" :disabled="streaming" @click="pickImages">
<span>🖼️</span><span>图片识别</span>
</button>
<button
type="button"
class="composer-chip"
:class="{ active: deepThinking }"
@click="deepThinking = !deepThinking"
>
<span>🧠</span><span>深度思考</span>
</button>
</div>
<button class="composer-send" type="submit" :disabled="streaming" title="发送">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 2 11 13" />
<path d="M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
</button>
</div>
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg"
multiple
hidden
@change="onFilesPicked"
/>
</form>
</section>
</div>
<div
v-if="lightboxSrc"
class="image-lightbox"
role="dialog"
aria-label="预览图片"
@click="lightboxSrc = ''"
>
<img :src="lightboxSrc" alt="预览" @click.stop />
<button
class="image-lightbox-close"
type="button"
aria-label="关闭预览"
@click="lightboxSrc = ''"
>×</button>
</div>
</section>
</template>