Initial commit: AI chat assistant with workflow chat, workspace, and profile tabs
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
288
components/MessageBubble.uvue
Normal file
288
components/MessageBubble.uvue
Normal file
@@ -0,0 +1,288 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user