Ukweli / web /src /components /ChatMessage.vue
Benard John
Initial commit
42a0d15
Raw
History Blame Contribute Delete
6.47 kB
<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">
<!-- User Message -->
<div v-if="message.role === 'user'" class="user-text">
{{ message.content }}
</div>
<!-- Assistant Message -->
<div v-else class="assistant-message">
<!-- Confidence Badge -->
<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>
<!-- Message Text (Markdown) -->
<div class="message-text markdown-body" v-html="renderedContent" />
<!-- Citations -->
<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>
<!-- Feedback -->
<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>
<!-- Timestamp -->
<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>