Files
JARVIS/frontend/src/views/ChatView.vue

925 lines
22 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 lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { conversationApi } from '@/api/conversation'
import { documentApi } from '@/api/document'
import { MessageCircle, Trash2, Send, Sparkles, CornerDownLeft, Paperclip, Smile } from 'lucide-vue-next'
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
import FileMessage from '@/components/chat/FileMessage.vue'
const store = useConversationStore()
const inputMessage = ref('')
const isSending = ref(false)
const chatContainer = ref<HTMLElement>()
const inputRef = ref<HTMLTextAreaElement>()
const isTyping = ref(false)
const fileInputRef = ref<HTMLInputElement>()
const showEmojiPicker = ref(false)
const selectedFiles = ref<{ id: string; name: string; type: string; size: number }[]>([])
async function sendMessage() {
if (!inputMessage.value.trim() || isSending.value) return
isSending.value = true
isTyping.value = true
const text = inputMessage.value.trim()
inputMessage.value = ''
store.addMessage({
id: `temp-${Date.now()}`,
role: 'user',
content: text,
created_at: new Date().toISOString(),
})
await nextTick()
scrollToBottom()
try {
const response = await conversationApi.chat(text, store.currentConversationId || undefined, selectedFiles.value.map(f => f.id))
selectedFiles.value = []
isTyping.value = false
store.addMessage({
id: response.data.message_id,
role: 'assistant',
content: response.data.content,
model: response.data.agent_name,
created_at: new Date().toISOString(),
})
if (!store.currentConversationId) {
store.setCurrentConversation(response.data.conversation_id)
await loadConversations()
}
} catch (e) {
isTyping.value = false
console.error('发送失败:', e)
store.addMessage({
id: `err-${Date.now()}`,
role: 'assistant',
content: '抱歉,连接失败。请检查服务状态。',
created_at: new Date().toISOString(),
})
}
isSending.value = false
await nextTick()
scrollToBottom()
}
async function loadConversations() {
try {
const response = await conversationApi.list()
store.setConversations(response.data)
} catch (e) {
console.error('加载对话列表失败:', e)
}
}
async function selectConversation(id: string) {
store.setCurrentConversation(id)
store.setMessages([])
try {
const response = await conversationApi.getMessages(id)
store.setMessages(response.data)
await nextTick()
scrollToBottom()
} catch (e) {
console.error('加载消息失败:', e)
}
}
async function newConversation() {
store.setCurrentConversation('')
store.setMessages([])
inputRef.value?.focus()
}
async function deleteConversation(id: string, e: Event) {
e.stopPropagation()
try {
await conversationApi.delete(id)
store.removeConversation(id)
if (store.currentConversationId === id) {
store.setCurrentConversation('')
store.setMessages([])
}
} catch (e) {
console.error('删除失败:', e)
}
}
function scrollToBottom() {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
function formatTime(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function formatConvDate(dateStr: string) {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return '今天'
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
function autoResize(e: Event) {
const el = e.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files?.length) return
for (const file of input.files) {
if (file.size > 10 * 1024 * 1024) {
alert(`文件 ${file.name} 超过10MB限制`)
continue
}
try {
const response = await documentApi.upload(file)
selectedFiles.value.push({
id: response.data.id,
name: file.name,
type: file.type,
size: file.size,
})
} catch (e) {
console.error('上传失败:', e)
alert(`文件 ${file.name} 上传失败`)
}
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function insertEmoji(emoji: string) {
inputMessage.value += emoji
showEmojiPicker.value = false
}
function openFilePicker() {
fileInputRef.value?.click()
}
onMounted(() => {
loadConversations()
inputRef.value?.focus()
})
</script>
<template>
<div class="chat-view">
<!-- Conversation list sidebar -->
<aside class="conv-sidebar">
<div class="conv-sidebar-header">
<div class="section-label">// SESSIONS</div>
<button class="new-chat-btn" @click="newConversation">
<span class="btn-line"></span>
<MessageCircle :size="14" />
<span>NEW SESSION</span>
</button>
</div>
<div class="conv-list">
<div
v-for="conv in store.conversations"
:key="conv.id"
class="conv-item"
:class="{ active: conv.id === store.currentConversationId }"
@click="selectConversation(conv.id)"
>
<div class="conv-item-icon">
<MessageCircle :size="12" />
</div>
<div class="conv-item-body">
<div class="conv-title">{{ conv.title || 'New Conversation' }}</div>
<div class="conv-date">{{ formatConvDate(conv.created_at) }}</div>
</div>
<button class="conv-delete" @click="deleteConversation(conv.id, $event)">
<Trash2 :size="12" />
</button>
<div class="conv-active-line"></div>
</div>
<div v-if="store.conversations.length === 0" class="conv-empty">
<div class="empty-icon"><MessageCircle :size="24" /></div>
<div class="empty-text">No sessions yet</div>
<div class="empty-hint">Start a new conversation</div>
</div>
</div>
</aside>
<!-- Chat area -->
<section class="chat-area">
<!-- Top bar -->
<div class="chat-topbar">
<div class="chat-status">
<div class="status-indicator" :class="{ active: !isSending }"></div>
<span class="status-text">{{ isTyping ? 'PROCESSING...' : 'READY' }}</span>
</div>
<div class="chat-model" v-if="store.messages.length > 0">
<Sparkles :size="12" />
<span>JARVIS v2.0</span>
</div>
</div>
<!-- Messages -->
<div ref="chatContainer" class="messages-area">
<!-- Welcome screen -->
<div v-if="store.messages.length === 0" class="welcome-screen">
<div class="welcome-icon">
<div class="welcome-ring r1"></div>
<div class="welcome-ring r2"></div>
<div class="welcome-ring r3"></div>
<div class="welcome-core">
<Sparkles :size="28" />
</div>
</div>
<div class="welcome-title">JARVIS</div>
<div class="welcome-sub">Personal AI Assistant</div>
<div class="welcome-hint">有什么我可以帮你的</div>
</div>
<!-- Message bubbles -->
<div
v-for="(msg, i) in store.messages"
:key="msg.id"
class="message-row"
:class="msg.role"
:style="{ animationDelay: `${i * 30}ms` }"
>
<div class="msg-avatar">
<span v-if="msg.role === 'user'">{{ '>' }}</span>
<span v-else class="ai-icon">J</span>
</div>
<div class="msg-content">
<div class="msg-meta">
<span class="msg-role">{{ msg.role === 'user' ? 'YOU' : 'JARVIS' }}</span>
<span class="msg-time">{{ formatTime(msg.created_at) }}</span>
</div>
<div class="msg-bubble">{{ msg.content }}</div>
<div v-if="msg.role === 'user' && msg.attachments?.length" class="msg-attachments">
<FileMessage
v-for="att in msg.attachments"
:key="att.id"
:file="att"
/>
</div>
</div>
</div>
<!-- Typing indicator -->
<div v-if="isTyping" class="message-row assistant typing-row">
<div class="msg-avatar"><span class="ai-icon">J</span></div>
<div class="msg-content">
<div class="msg-meta">
<span class="msg-role">JARVIS</span>
</div>
<div class="msg-bubble typing">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</div>
</div>
<!-- Input area -->
<div class="input-area">
<div class="input-frame">
<div class="input-corners tl"></div>
<div class="input-corners tr"></div>
<div class="input-corners bl"></div>
<div class="input-corners br"></div>
<textarea
ref="inputRef"
v-model="inputMessage"
placeholder="输入指令,按 Enter 发送..."
:disabled="isSending"
rows="1"
@keydown.enter.exact.prevent="sendMessage"
@input="autoResize"
></textarea>
<input
ref="fileInputRef"
type="file"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
style="display: none"
@change="handleFileSelect"
/>
<button class="attach-btn" @click="openFilePicker" title="上传文件">
<Paperclip :size="15" />
</button>
<div class="emoji-wrapper">
<button
class="emoji-btn"
:class="{ active: showEmojiPicker }"
@click="showEmojiPicker = !showEmojiPicker"
title="表情包"
>
<Smile :size="15" />
</button>
<EmojiPicker
:visible="showEmojiPicker"
@select="insertEmoji"
@close="showEmojiPicker = false"
/>
</div>
<button
class="send-btn"
:class="{ active: inputMessage.trim() }"
:disabled="!inputMessage.trim() || isSending"
@click="sendMessage"
>
<Send :size="15" />
<CornerDownLeft :size="12" class="enter-hint" />
</button>
</div>
<div class="input-hints">
<span class="hint-item">ENTER 发送</span>
<span class="hint-sep">|</span>
<span class="hint-item">SHIFT+ENTER 换行</span>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.chat-view {
display: flex;
height: 100%;
overflow: hidden;
}
/* ── Conversation Sidebar ── */
.conv-sidebar {
width: 200px;
min-width: 200px;
background: var(--bg-panel);
border-right: 1px solid var(--border-dim);
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-sidebar-header {
padding: 16px 14px 12px;
border-bottom: 1px solid var(--border-dim);
}
.section-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 10px;
}
.new-chat-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
font-size: 10px;
letter-spacing: 0.1em;
font-weight: 600;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.new-chat-btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(0,245,212,0.1), transparent);
transform: translateX(-100%);
transition: transform 0.4s;
}
.new-chat-btn:hover::before { transform: translateX(100%); }
.new-chat-btn:hover {
background: rgba(0, 245, 212, 0.18);
box-shadow: var(--glow-cyan);
}
.btn-line {
width: 1px;
height: 12px;
background: var(--accent-cyan);
opacity: 0.6;
}
.conv-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conv-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-md);
cursor: pointer;
margin-bottom: 2px;
border: 1px solid transparent;
transition: all var(--transition-fast);
overflow: hidden;
}
.conv-item:hover {
background: rgba(0, 245, 212, 0.04);
border-color: var(--border-dim);
}
.conv-item.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
}
.conv-active-line {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent-cyan);
opacity: 0;
transition: opacity var(--transition-fast);
box-shadow: 0 0 6px var(--accent-cyan);
}
.conv-item.active .conv-active-line { opacity: 1; }
.conv-item-icon {
color: var(--text-dim);
flex-shrink: 0;
}
.conv-item.active .conv-item-icon { color: var(--accent-cyan); }
.conv-item-body { flex: 1; min-width: 0; }
.conv-title {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.conv-item.active .conv-title { color: var(--accent-cyan); }
.conv-date {
font-size: 9px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.conv-delete {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 3px;
opacity: 0;
transition: all var(--transition-fast);
border-radius: 3px;
}
.conv-item:hover .conv-delete { opacity: 1; }
.conv-delete:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
.conv-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 16px;
gap: 8px;
}
.empty-icon { color: var(--text-dim); }
.empty-text { font-size: 12px; color: var(--text-dim); }
.empty-hint { font-size: 10px; color: var(--text-muted); }
/* ── Chat Area ── */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.chat-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5, 8, 16, 0.6);
backdrop-filter: blur(8px);
}
.chat-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim);
}
.status-indicator.active {
background: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
animation: pulse-glow 1.5s ease-in-out infinite;
}
.status-text {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.chat-model {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--accent-amber);
padding: 3px 10px;
border: 1px solid rgba(249, 168, 37, 0.2);
border-radius: 20px;
background: var(--accent-amber-dim);
}
/* ── Messages ── */
.messages-area {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* Welcome screen */
.welcome-screen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding-bottom: 80px;
}
.welcome-icon {
position: relative;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-cyan);
margin-bottom: 8px;
}
.welcome-ring {
position: absolute;
border-radius: 50%;
border: 1px solid var(--accent-cyan);
animation: spin linear infinite;
}
.r1 { width: 80px; height: 80px; opacity: 0.3; animation-duration: 8s; border-style: dashed; }
.r2 { width: 60px; height: 60px; opacity: 0.5; animation-duration: 5s; animation-direction: reverse; }
.r3 { width: 40px; height: 40px; opacity: 0.7; animation-duration: 3s; }
.welcome-core {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 12px var(--accent-cyan));
animation: pulse-glow 2s ease-in-out infinite;
}
.welcome-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 800;
letter-spacing: 0.2em;
color: var(--accent-cyan);
text-shadow: var(--glow-cyan);
}
.welcome-sub {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.3em;
color: var(--text-dim);
text-transform: uppercase;
}
.welcome-hint {
font-size: 13px;
color: var(--text-dim);
margin-top: 20px;
}
/* Message rows */
.message-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 0;
animation: fade-in-up 0.3s ease both;
}
.msg-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
margin-top: 4px;
}
.user .msg-avatar {
background: rgba(0, 245, 212, 0.1);
border: 1px solid var(--border-mid);
color: var(--accent-cyan);
}
.assistant .msg-avatar {
background: linear-gradient(135deg, var(--accent-cyan-dim), var(--accent-purple-dim));
border: 1px solid var(--border-bright);
color: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan-glow);
}
.msg-content {
flex: 1;
min-width: 0;
max-width: 75%;
}
.msg-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.msg-role {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.msg-time {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
}
.msg-bubble {
display: inline-block;
padding: 10px 16px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.65;
word-break: break-word;
white-space: pre-wrap;
}
.user .msg-bubble {
background: var(--accent-cyan-dim);
border: 1px solid rgba(0, 245, 212, 0.2);
border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md);
color: var(--text-primary);
}
.assistant .msg-bubble {
background: rgba(13, 21, 37, 0.8);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4px;
color: var(--text-secondary);
backdrop-filter: blur(4px);
}
/* Typing indicator */
.typing .dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-cyan);
margin: 0 2px;
animation: typing-bounce 1.2s ease-in-out infinite;
}
.typing .dot:nth-child(2) { animation-delay: 0.2s; }
.typing .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
/* ── Input Area ── */
.input-area {
padding: 16px 24px 20px;
border-top: 1px solid var(--border-dim);
background: rgba(5, 8, 16, 0.8);
backdrop-filter: blur(12px);
}
.input-frame {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
padding: 12px 16px;
display: flex;
align-items: flex-end;
gap: 12px;
transition: all var(--transition-mid);
}
.input-frame:focus-within {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.1), var(--glow-cyan);
}
/* Corner accents */
.input-corners {
position: absolute;
width: 8px;
height: 8px;
pointer-events: none;
}
.input-corners::before,
.input-corners::after {
content: '';
position: absolute;
background: var(--accent-cyan);
}
.input-corners::before { width: 100%; height: 1px; }
.input-corners::after { width: 1px; height: 100%; }
.tl { top: -1px; left: -1px; }
.tr { top: -1px; right: -1px; transform: scaleX(-1); }
.bl { bottom: -1px; left: -1px; transform: scaleY(-1); }
.br { bottom: -1px; right: -1px; transform: scale(-1); }
.input-frame textarea {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
resize: none;
max-height: 120px;
padding: 0;
}
.input-frame textarea::placeholder { color: var(--text-dim); }
.send-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--text-muted);
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
transition: all var(--transition-fast);
}
.send-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
.send-btn.active:hover {
background: rgba(0, 245, 212, 0.2);
box-shadow: var(--glow-cyan);
transform: scale(1.05);
}
.enter-hint { display: none; }
.input-hints {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-left: 4px;
}
.hint-item {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.hint-sep {
color: var(--text-muted);
font-size: 9px;
}
/* File attachment button */
.attach-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.attach-btn:hover {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
/* Emoji button */
.emoji-wrapper {
position: relative;
}
.emoji-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.emoji-btn:hover,
.emoji-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
/* Message attachments */
.msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
</style>