| <template> |
| <div class="chat-message" :class="[`role-${message.role}`, { streaming: message.isStreaming }]"> |
| <div class="message-avatar"> |
| <n-avatar |
| v-if="message.role === 'assistant'" |
| :size="36" |
| :style="{ background: 'var(--primary)' }" |
| > |
| <n-icon><shield-checkmark-outline /></n-icon> |
| </n-avatar> |
| <n-avatar |
| v-else |
| :size="36" |
| :style="{ background: 'var(--accent)' }" |
| > |
| <n-icon><person-outline /></n-icon> |
| </n-avatar> |
| </div> |
| |
| <div class="message-content"> |
| |
| <div v-if="message.role === 'user'" class="user-text"> |
| {{ message.content }} |
| </div> |
| |
| |
| <div v-else class="assistant-message"> |
| |
| <div v-if="message.confidence" class="confidence-badge"> |
| <n-tag |
| :type="confidenceType" |
| size="small" |
| class="confidence-tag" |
| > |
| <template #icon> |
| <n-icon size="14"> |
| <checkmark-circle-outline v-if="message.confidence === 'high'" /> |
| <help-circle-outline v-else-if="message.confidence === 'medium'" /> |
| <warning-outline v-else /> |
| </n-icon> |
| </template> |
| {{ message.confidence }} confidence |
| </n-tag> |
| </div> |
| |
| |
| <div class="message-text markdown-body" v-html="renderedContent" /> |
| |
| |
| <div v-if="message.citations && message.citations.length > 0" class="citations"> |
| <p class="citations-label">Sources:</p> |
| <div class="citation-list"> |
| <n-button |
| v-for="(citation, index) in message.citations" |
| :key="index" |
| quaternary |
| size="tiny" |
| class="citation-btn" |
| @click="$emit('citation-click', citation)" |
| > |
| <template #icon> |
| <n-icon size="14"><document-text-outline /></n-icon> |
| </template> |
| [{{ index + 1 }}] {{ citation.title }}, p.{{ citation.page }} |
| </n-button> |
| </div> |
| </div> |
| |
| |
| <div v-if="!message.isStreaming && message.role === 'assistant'" class="message-feedback"> |
| <n-divider style="margin: 12px 0" /> |
| <div class="feedback-row"> |
| <span class="feedback-label">Was this helpful?</span> |
| <n-button-group size="tiny"> |
| <n-button quaternary @click="submitFeedback(1)"> |
| <template #icon><n-icon><thumbs-up-outline /></n-icon></template> |
| </n-button> |
| <n-button quaternary @click="submitFeedback(-1)"> |
| <template #icon><n-icon><thumbs-down-outline /></n-icon></template> |
| </n-button> |
| </n-button-group> |
| </div> |
| </div> |
| </div> |
| |
| |
| <span class="message-time">{{ formatTime(message.timestamp) }}</span> |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { computed } from 'vue' |
| import { marked } from 'marked' |
| import DOMPurify from 'dompurify' |
| import type { ChatMessage } from '@/types' |
| import { format } from 'date-fns' |
| import { |
| ShieldCheckmarkOutline, |
| PersonOutline, |
| DocumentTextOutline, |
| ThumbsUpOutline, |
| ThumbsDownOutline, |
| CheckmarkCircleOutline, |
| HelpCircleOutline, |
| WarningOutline, |
| } from '@vicons/ionicons5' |
| |
| const props = defineProps<{ |
| message: ChatMessage |
| }>() |
| |
| const emit = defineEmits<{ |
| feedback: [messageId: string, rating: number, correction?: string] |
| citationClick: [citation: any] |
| }>() |
| |
| const renderedContent = computed(() => { |
| const rawHtml = marked.parse(props.message.content, { async: false }) as string |
| return DOMPurify.sanitize(rawHtml) |
| }) |
| |
| const confidenceType = computed(() => { |
| switch (props.message.confidence) { |
| case 'high': return 'success' |
| case 'medium': return 'warning' |
| case 'low': return 'error' |
| default: return 'default' |
| } |
| }) |
| |
| const formatTime = (timestamp: string) => { |
| return format(new Date(timestamp), 'h:mm a') |
| } |
| |
| const submitFeedback = (rating: number) => { |
| emit('feedback', props.message.id, rating) |
| } |
| </script> |
| |
| <style scoped lang="scss"> |
| .chat-message { |
| display: flex; |
| gap: 12px; |
| padding: 8px 0; |
| animation: slideIn 0.3s ease; |
| } |
| |
| @keyframes slideIn { |
| from { |
| opacity: 0; |
| transform: translateY(10px); |
| } |
| to { |
| opacity: 1; |
| transform: translateY(0); |
| } |
| } |
| |
| .role-user { |
| flex-direction: row-reverse; |
| |
| .message-content { |
| align-items: flex-end; |
| } |
| |
| .user-text { |
| background: var(--primary); |
| color: white; |
| border-radius: 16px 16px 4px 16px; |
| padding: 12px 16px; |
| max-width: 80%; |
| font-size: 15px; |
| line-height: 1.5; |
| } |
| } |
| |
| .role-assistant { |
| .message-content { |
| align-items: flex-start; |
| max-width: 85%; |
| } |
| } |
| |
| .message-avatar { |
| flex-shrink: 0; |
| } |
| |
| .message-content { |
| display: flex; |
| flex-direction: column; |
| gap: 4px; |
| } |
| |
| .assistant-message { |
| background: var(--bg-primary); |
| border: 1px solid var(--border); |
| border-radius: 16px 16px 16px 4px; |
| padding: 16px; |
| width: 100%; |
| } |
| |
| .confidence-badge { |
| margin-bottom: 8px; |
| } |
| |
| .confidence-tag { |
| font-size: 11px; |
| } |
| |
| .message-text { |
| font-size: 15px; |
| line-height: 1.6; |
| color: var(--text-primary); |
| |
| :deep(p) { |
| margin-bottom: 0.8em; |
| |
| &:last-child { |
| margin-bottom: 0; |
| } |
| } |
| } |
| |
| .citations { |
| margin-top: 12px; |
| padding-top: 12px; |
| border-top: 1px dashed var(--border); |
| } |
| |
| .citations-label { |
| font-size: 12px; |
| font-weight: 600; |
| color: var(--text-muted); |
| margin-bottom: 8px; |
| } |
| |
| .citation-list { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 6px; |
| } |
| |
| .citation-btn { |
| font-size: 12px; |
| color: var(--primary); |
| background: rgba(26, 95, 180, 0.08); |
| border-radius: 6px; |
| |
| &:hover { |
| background: rgba(26, 95, 180, 0.15); |
| } |
| } |
| |
| .message-feedback { |
| margin-top: 8px; |
| } |
| |
| .feedback-row { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| } |
| |
| .feedback-label { |
| font-size: 12px; |
| color: var(--text-muted); |
| } |
| |
| .message-time { |
| font-size: 11px; |
| color: var(--text-muted); |
| margin-top: 4px; |
| } |
| |
| .streaming { |
| .assistant-message { |
| border-style: dashed; |
| } |
| } |
| |
| @media (max-width: 768px) { |
| .role-user .user-text { |
| max-width: 90%; |
| } |
| |
| .role-assistant .message-content { |
| max-width: 90%; |
| } |
| } |
| </style> |
| |