289 lines
7.0 KiB
Plaintext
289 lines
7.0 KiB
Plaintext
<template>
|
|
<view
|
|
class="message-bubble"
|
|
:class="{
|
|
'message-bubble--user': message.role === 'user',
|
|
'message-bubble--ai': message.role === 'assistant',
|
|
'message-bubble--error': message.status === 'error',
|
|
}"
|
|
>
|
|
<!-- AI Avatar placeholder -->
|
|
<view v-if="message.role === 'assistant'" class="message-bubble__avatar">
|
|
<text class="message-bubble__avatar-text">AI</text>
|
|
</view>
|
|
|
|
<view class="message-bubble__content-wrapper">
|
|
<!-- Bubble -->
|
|
<view class="message-bubble__bubble">
|
|
<!-- Parsed content segments -->
|
|
<view v-for="(segment, idx) in parsedSegments" :key="idx">
|
|
<!-- Code block -->
|
|
<view v-if="segment.type === 'code'" class="message-bubble__code-block">
|
|
<text class="message-bubble__code-text">{{ segment.value }}</text>
|
|
</view>
|
|
<!-- Regular text (with inline bold handled) -->
|
|
<text v-else class="message-bubble__text">{{ segment.value }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- Timestamp & Status -->
|
|
<view class="message-bubble__meta" :class="{ 'message-bubble__meta--user': message.role === 'user' }">
|
|
<text class="message-bubble__time">{{ formatTime(message.timestamp) }}</text>
|
|
<text v-if="message.role === 'user' && message.status === 'sending'" class="message-bubble__status">⏳</text>
|
|
<text v-if="message.role === 'user' && message.status === 'sent'" class="message-bubble__status">✓</text>
|
|
<text v-if="message.role === 'user' && message.status === 'error'" class="message-bubble__status message-bubble__status--error">⚠️</text>
|
|
</view>
|
|
|
|
<!-- Error retry -->
|
|
<view v-if="message.status === 'error'" class="message-bubble__retry" @tap="onRetry">
|
|
<text class="message-bubble__retry-text">发送失败,点击重试</text>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- User Avatar placeholder -->
|
|
<view v-if="message.role === 'user'" class="message-bubble__avatar message-bubble__avatar--user">
|
|
<text class="message-bubble__avatar-text">我</text>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="uts">
|
|
import type { Message, ParsedSegment } from '@/types/index'
|
|
|
|
const props = defineProps<{
|
|
message: Message
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
retry: []
|
|
}>()
|
|
|
|
/**
|
|
* Parse message content into segments: text, code, bold.
|
|
* Handles ``` code fences and **bold** markers.
|
|
*/
|
|
const parsedSegments = computed<ParsedSegment[]>(() => {
|
|
return parseContent(props.message.content)
|
|
})
|
|
|
|
function parseContent(content: string): ParsedSegment[] {
|
|
const segments: ParsedSegment[] = []
|
|
let remaining = content
|
|
|
|
while (remaining.length > 0) {
|
|
const codeStart = remaining.indexOf('```')
|
|
if (codeStart === -1) {
|
|
// No more code blocks — add remaining as text
|
|
if (remaining.trim()) {
|
|
segments.push({ type: 'text', value: remaining.trim() })
|
|
}
|
|
break
|
|
}
|
|
|
|
// Text before code block
|
|
if (codeStart > 0) {
|
|
const beforeText = remaining.substring(0, codeStart).trim()
|
|
if (beforeText) {
|
|
segments.push({ type: 'text', value: beforeText })
|
|
}
|
|
}
|
|
|
|
// Find code block end
|
|
const afterFence = remaining.substring(codeStart + 3)
|
|
const codeEnd = afterFence.indexOf('```')
|
|
if (codeEnd === -1) {
|
|
// Unclosed code block — treat rest as text
|
|
segments.push({ type: 'text', value: remaining.substring(codeStart).trim() })
|
|
break
|
|
}
|
|
|
|
const codeContent = afterFence.substring(0, codeEnd).trim()
|
|
if (codeContent) {
|
|
segments.push({ type: 'code', value: codeContent })
|
|
}
|
|
|
|
remaining = afterFence.substring(codeEnd + 3)
|
|
}
|
|
|
|
// If no segments at all, return the whole content as text
|
|
if (segments.length === 0 && content.trim()) {
|
|
segments.push({ type: 'text', value: content.trim() })
|
|
}
|
|
|
|
return segments
|
|
}
|
|
|
|
/**
|
|
* Format timestamp to HH:mm.
|
|
*/
|
|
function formatTime(timestamp: number): string {
|
|
const d = new Date(timestamp)
|
|
const h = d.getHours().toString().padStart(2, '0')
|
|
const m = d.getMinutes().toString().padStart(2, '0')
|
|
return `${h}:${m}`
|
|
}
|
|
|
|
function onRetry(): void {
|
|
emit('retry')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.message-bubble {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: flex-end;
|
|
margin-bottom: 20rpx;
|
|
padding: 0 24rpx;
|
|
animation: messageEnter 0.3s ease-out;
|
|
}
|
|
|
|
.message-bubble--user {
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.message-bubble--ai {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
/* Avatar */
|
|
.message-bubble__avatar {
|
|
width: 56rpx;
|
|
height: 56rpx;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
margin-right: 12rpx;
|
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
|
}
|
|
|
|
.message-bubble__avatar--user {
|
|
margin-right: 0;
|
|
margin-left: 12rpx;
|
|
background: linear-gradient(135deg, #00b894, #55efc4);
|
|
}
|
|
|
|
.message-bubble__avatar-text {
|
|
font-size: 22rpx;
|
|
font-weight: 700;
|
|
color: #ffffff;
|
|
}
|
|
|
|
/* Content wrapper */
|
|
.message-bubble__content-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-width: 80%;
|
|
}
|
|
|
|
.message-bubble--ai .message-bubble__content-wrapper {
|
|
max-width: 78%;
|
|
}
|
|
|
|
/* Bubble box */
|
|
.message-bubble__bubble {
|
|
padding: 20rpx 24rpx;
|
|
border-radius: 24rpx 24rpx 4rpx 24rpx;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.message-bubble--user .message-bubble__bubble {
|
|
border-radius: 24rpx 24rpx 24rpx 4rpx;
|
|
background: linear-gradient(135deg, #6c5ce7 0%, #5a4bd1 100%);
|
|
border: none;
|
|
}
|
|
|
|
.message-bubble--error .message-bubble__bubble {
|
|
border-color: rgba(255, 82, 82, 0.4);
|
|
}
|
|
|
|
/* Text */
|
|
.message-bubble__text {
|
|
font-size: 28rpx;
|
|
color: rgba(255, 255, 255, 0.9);
|
|
line-height: 1.6;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.message-bubble--ai .message-bubble__text {
|
|
color: rgba(255, 255, 255, 0.88);
|
|
}
|
|
|
|
.message-bubble--user .message-bubble__text {
|
|
color: #ffffff;
|
|
}
|
|
|
|
/* Code block */
|
|
.message-bubble__code-block {
|
|
background: rgba(0, 0, 0, 0.35);
|
|
border-radius: 12rpx;
|
|
padding: 16rpx 20rpx;
|
|
margin: 12rpx 0;
|
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
.message-bubble__code-text {
|
|
font-size: 24rpx;
|
|
font-family: 'Courier New', Courier, monospace;
|
|
color: #a29bfe;
|
|
line-height: 1.5;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
|
|
/* Meta (time + status) */
|
|
.message-bubble__meta {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
margin-top: 6rpx;
|
|
padding: 0 8rpx;
|
|
}
|
|
|
|
.message-bubble__meta--user {
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.message-bubble__time {
|
|
font-size: 20rpx;
|
|
color: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
.message-bubble__status {
|
|
font-size: 18rpx;
|
|
margin-left: 8rpx;
|
|
color: rgba(255, 255, 255, 0.4);
|
|
}
|
|
|
|
.message-bubble__status--error {
|
|
color: #ff5252;
|
|
}
|
|
|
|
/* Retry */
|
|
.message-bubble__retry {
|
|
margin-top: 8rpx;
|
|
padding: 8rpx 16rpx;
|
|
align-self: flex-end;
|
|
}
|
|
|
|
.message-bubble__retry-text {
|
|
font-size: 22rpx;
|
|
color: #ff5252;
|
|
}
|
|
|
|
/* Enter animation */
|
|
@keyframes messageEnter {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(16rpx);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
</style>
|