466 lines
24 KiB
Vue
466 lines
24 KiB
Vue
<template>
|
||
<div class="chat-page" :class="{ 'has-pending-images': pendingImages.length }">
|
||
<header class="chat-header">
|
||
<button v-if="!isPublic" class="back-btn" @click="router.back()">‹</button>
|
||
<div class="avatar-heading">
|
||
<div class="avatar-mark">
|
||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||
</div>
|
||
<div>
|
||
<h1>{{ avatar?.displayName || avatar?.name || '数字分身' }}</h1>
|
||
<span class="online-state" :class="avatarStatus.tone"><i></i>{{ avatarStatus.label }}</span>
|
||
</div>
|
||
</div>
|
||
<button v-if="!isPublic" class="settings-btn" title="编辑分身" @click="router.push(`/avatar/edit/${avatarId}`)">⚙</button>
|
||
</header>
|
||
|
||
<main ref="messageList" class="message-list">
|
||
<div v-if="!messages.length" class="welcome-card">
|
||
<div class="welcome-avatar">
|
||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||
</div>
|
||
<h2>有什么想聊的?</h2>
|
||
<p class="welcome-description">{{ avatar?.description || '很高兴和你聊聊。' }}</p>
|
||
</div>
|
||
|
||
<article v-for="(message, index) in messages" :key="`${message.role}-${index}`" class="message-row" :class="message.role">
|
||
<div v-if="message.role === 'assistant'" class="message-avatar avatar-message-face">
|
||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||
</div>
|
||
<div class="message-column">
|
||
<div v-if="message.attachments?.length" class="message-images">
|
||
<figure v-for="attachment in message.attachments" :key="attachment.id" class="message-image-card">
|
||
<img :src="attachment.previewUrl" :alt="attachment.filename" />
|
||
<figcaption v-if="attachment.warning">{{ attachment.warning }}</figcaption>
|
||
</figure>
|
||
</div>
|
||
<div class="message-bubble" :class="{ streaming: sending && message.role === 'assistant' && index === messages.length - 1 }">
|
||
<template v-if="message.role === 'assistant'">
|
||
<span
|
||
v-for="character in renderChatMarkdownCharacters(message.characters?.length ? message.characters : message.content)"
|
||
:key="character.key"
|
||
class="typing-character"
|
||
:class="{
|
||
newline: character.newline,
|
||
'markdown-bold': character.bold,
|
||
'markdown-italic': character.italic,
|
||
'markdown-code': character.code,
|
||
'markdown-heading': character.heading
|
||
}"
|
||
>{{ character.text }}</span>
|
||
</template>
|
||
<template v-else>{{ message.content }}</template>
|
||
</div>
|
||
<div v-if="message.source || message.references?.length" class="message-source">
|
||
{{ sourceLabel(message.source) }}
|
||
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="message.role === 'user'" class="message-avatar user-message-face">
|
||
<img v-if="userAvatarUrl" :src="userAvatarUrl" alt="" referrerpolicy="no-referrer" />
|
||
<span v-else>{{ userAvatarInitial }}</span>
|
||
</div>
|
||
</article>
|
||
|
||
<div v-if="thinking" class="message-row assistant">
|
||
<div class="message-avatar avatar-message-face">
|
||
<img v-if="avatar?.photoUrl" :src="avatar.photoUrl" alt="" referrerpolicy="no-referrer" />
|
||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||
</div>
|
||
<div class="message-bubble typing"><i></i><i></i><i></i></div>
|
||
</div>
|
||
<p v-if="errorMessage" class="chat-error">{{ errorMessage }} <button @click="retryLast">重试</button></p>
|
||
</main>
|
||
|
||
<form class="composer" @submit.prevent="sendMessage(inputText)">
|
||
<div v-if="pendingImages.length" class="pending-images">
|
||
<div v-for="image in pendingImages" :key="image.localId" class="pending-image" :class="image.status">
|
||
<img :src="image.previewUrl" :alt="image.filename" />
|
||
<div class="pending-image-copy">
|
||
<strong>{{ image.status === 'uploading' ? '正在识别图片…' : image.summary || image.filename }}</strong>
|
||
<span>{{ image.status === 'uploading' ? '正在提取图片中的可见内容' : categoryLabel(image.category) }}</span>
|
||
</div>
|
||
<button type="button" aria-label="移除图片" :disabled="sending" @click="removePendingImage(image.localId)">×</button>
|
||
</div>
|
||
<p v-if="hasPendingMedicalImage" class="medical-note">病例与医学影像识别仅供辅助,请以原始资料和医生意见为准。</p>
|
||
</div>
|
||
<div class="composer-row">
|
||
<button class="image-btn" type="button" :disabled="sending || uploadingImage || pendingImages.length >= 3" aria-label="选择图片" @click="imageInput?.click()">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 18.5v-13Zm2 12.7 3.8-4.2 2.7 2.8 1.7-1.8 3.8 3.2V5.5a.5.5 0 0 0-.5-.5h-11a.5.5 0 0 0-.5.5v12.7Zm8.3-7.8a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4Z"/></svg>
|
||
</button>
|
||
<input ref="imageInput" class="image-input" type="file" accept="image/jpeg,image/png,image/webp" multiple @change="selectImages" />
|
||
<textarea v-model="inputText" rows="1" :disabled="sending" placeholder="输入问题,或选择一张图片…" @keydown.enter.exact.prevent="sendMessage(inputText)"></textarea>
|
||
<button class="send-btn" type="submit" :disabled="sending || uploadingImage || (!inputText.trim() && !readyPendingImages.length)">发送</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import {
|
||
getAvatarDetail,
|
||
getPublicAvatar,
|
||
streamAvatarChat,
|
||
streamPublicAvatarChat,
|
||
uploadAvatarChatImage,
|
||
uploadPublicAvatarChatImage,
|
||
type ChatAttachment,
|
||
type ChatMessage
|
||
} from '@/api'
|
||
import { useAvatarStore } from '@/store/avatar'
|
||
import { useUserStore } from '@/store/user'
|
||
import { renderChatMarkdownCharacters } from '@/utils/chat-markdown.js'
|
||
|
||
type DisplayMessage = ChatMessage & {
|
||
source?: 'qa' | 'knowledge' | 'vision' | 'qwen' | 'public'
|
||
references?: Array<{ filename?: string }>
|
||
characters?: string[]
|
||
attachments?: MessageAttachment[]
|
||
}
|
||
|
||
type MessageAttachment = {
|
||
id: string
|
||
filename: string
|
||
previewUrl: string
|
||
category?: ChatAttachment['category']
|
||
summary?: string
|
||
warning?: string
|
||
}
|
||
|
||
type PendingImage = MessageAttachment & {
|
||
localId: string
|
||
attachmentId?: string
|
||
status: 'uploading' | 'ready'
|
||
}
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const store = useAvatarStore()
|
||
const userStore = useUserStore()
|
||
const shareToken = String(route.params.shareToken || '')
|
||
const isPublic = Boolean(shareToken)
|
||
const avatarId = ref(String(route.params.id || ''))
|
||
const avatar = ref<any>(null)
|
||
const messages = ref<DisplayMessage[]>([])
|
||
const inputText = ref('')
|
||
const sending = ref(false)
|
||
const thinking = ref(false)
|
||
const errorMessage = ref('')
|
||
const lastRequest = ref<{ question: string; attachments: MessageAttachment[] } | null>(null)
|
||
const messageList = ref<HTMLElement | null>(null)
|
||
const imageInput = ref<HTMLInputElement | null>(null)
|
||
const pendingImages = ref<PendingImage[]>([])
|
||
const previewUrls = new Set<string>()
|
||
let scrollFrame: number | null = null
|
||
|
||
const userAvatarUrl = computed(() => userStore.user?.avatarUrl || store.userProfile?.avatarUrl || '')
|
||
const userAvatarInitial = computed(() => (userStore.user?.nickname || store.userProfile?.nickname || '我').trim().slice(0, 1))
|
||
const avatarStatus = computed(() => {
|
||
const status = avatar.value?.status || 'active'
|
||
if (status === 'inactive') return { tone: 'inactive', label: '当前已停用' }
|
||
if (status === 'training') return { tone: 'training', label: '知识训练中' }
|
||
return { tone: 'active', label: '在线,随时可以和我聊聊' }
|
||
})
|
||
const readyPendingImages = computed(() => pendingImages.value.filter((image) => image.status === 'ready' && image.attachmentId))
|
||
const uploadingImage = computed(() => pendingImages.value.some((image) => image.status === 'uploading'))
|
||
const hasPendingMedicalImage = computed(() => readyPendingImages.value.some((image) => ['medical_document', 'medical_image'].includes(image.category || '')))
|
||
|
||
const sourceLabels: Record<NonNullable<DisplayMessage['source']>, string> = {
|
||
qa: '标准问答对',
|
||
knowledge: '参考文件知识库',
|
||
vision: '图片理解',
|
||
qwen: '智能回答',
|
||
public: ''
|
||
}
|
||
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
|
||
const categoryLabels: Record<ChatAttachment['category'], string> = {
|
||
general_image: '图片内容已识别',
|
||
document: '文档图片已识别',
|
||
medical_document: '病例文字已提取,请核对原文',
|
||
medical_image: '医学影像已作客观描述'
|
||
}
|
||
const categoryLabel = (category?: ChatAttachment['category']) => category ? categoryLabels[category] : '图片内容已识别'
|
||
|
||
const scrollToBottom = async () => {
|
||
await nextTick()
|
||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||
}
|
||
|
||
const scrollDuringStream = () => {
|
||
if (scrollFrame !== null) return
|
||
scrollFrame = window.requestAnimationFrame(() => {
|
||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||
scrollFrame = null
|
||
})
|
||
}
|
||
|
||
const sleep = (delay: number) => new Promise<void>((resolve) => window.setTimeout(resolve, delay))
|
||
|
||
const createStreamReply = () => {
|
||
const reply = reactive<DisplayMessage>({
|
||
role: 'assistant',
|
||
content: '',
|
||
characters: []
|
||
})
|
||
let attached = false
|
||
const attach = () => {
|
||
if (attached) return
|
||
messages.value.push(reply)
|
||
attached = true
|
||
}
|
||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||
const queue: string[] = []
|
||
let draining: Promise<void> | null = null
|
||
|
||
const drain = async () => {
|
||
while (queue.length) {
|
||
let character = queue.shift() || ''
|
||
if (character === '\r') continue
|
||
if (/\s/.test(character) && character !== '\n') character = ' '
|
||
const previous = reply.characters?.[reply.characters.length - 1] || ''
|
||
if (character === ' ' && (!previous || previous === ' ' || previous === '\n')) continue
|
||
if (character === '\n') {
|
||
while (reply.characters?.[reply.characters.length - 1] === ' ') {
|
||
reply.characters.pop()
|
||
reply.content = reply.content.slice(0, -1)
|
||
}
|
||
if (!reply.characters?.length || reply.characters[reply.characters.length - 1] === '\n') continue
|
||
}
|
||
reply.content += character
|
||
reply.characters?.push(character)
|
||
scrollDuringStream()
|
||
await sleep(/[,。!?;:\n]/.test(character) ? 140 : 28)
|
||
}
|
||
draining = null
|
||
}
|
||
|
||
return {
|
||
reply,
|
||
append: (content: string) => {
|
||
if (!content) return
|
||
attach()
|
||
if (reduceMotion) {
|
||
reply.content += content
|
||
void scrollToBottom()
|
||
return
|
||
}
|
||
queue.push(...Array.from(content))
|
||
if (!draining) draining = drain()
|
||
},
|
||
finish: async () => {
|
||
if (draining) await draining
|
||
while (reply.characters?.length && /[\s\n]/.test(reply.characters[reply.characters.length - 1])) {
|
||
reply.characters.pop()
|
||
reply.content = reply.content.slice(0, -1)
|
||
}
|
||
if (reduceMotion) reply.characters = []
|
||
if (attached) await scrollToBottom()
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadAvatar = async () => {
|
||
if (isPublic) {
|
||
avatar.value = await getPublicAvatar(shareToken)
|
||
avatarId.value = String(avatar.value?.id || '')
|
||
document.title = avatar.value?.displayName || avatar.value?.name || '会会数字分身'
|
||
return
|
||
}
|
||
avatar.value = store.avatars.find((item) => String(item.id) === avatarId.value)
|
||
if (!avatar.value) avatar.value = await getAvatarDetail(avatarId.value)
|
||
document.title = avatar.value?.displayName || avatar.value?.name || '会会数字分身'
|
||
}
|
||
|
||
const removePendingImage = (localId: string) => {
|
||
const target = pendingImages.value.find((image) => image.localId === localId)
|
||
if (target) {
|
||
URL.revokeObjectURL(target.previewUrl)
|
||
previewUrls.delete(target.previewUrl)
|
||
}
|
||
pendingImages.value = pendingImages.value.filter((image) => image.localId !== localId)
|
||
}
|
||
|
||
const selectImages = async (event: Event) => {
|
||
const input = event.target as HTMLInputElement
|
||
const slots = Math.max(0, 3 - pendingImages.value.length)
|
||
const files = Array.from(input.files || []).slice(0, slots)
|
||
input.value = ''
|
||
for (const file of files) {
|
||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||
errorMessage.value = '仅支持 JPG、PNG、WebP 图片'
|
||
continue
|
||
}
|
||
if (file.size > 8 * 1024 * 1024) {
|
||
errorMessage.value = '单张图片不能超过 8MB'
|
||
continue
|
||
}
|
||
const previewUrl = URL.createObjectURL(file)
|
||
previewUrls.add(previewUrl)
|
||
const localId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||
pendingImages.value.push({
|
||
id: localId,
|
||
localId,
|
||
filename: file.name,
|
||
previewUrl,
|
||
status: 'uploading'
|
||
})
|
||
errorMessage.value = ''
|
||
try {
|
||
const result = isPublic
|
||
? await uploadPublicAvatarChatImage(shareToken, file)
|
||
: await uploadAvatarChatImage(avatarId.value, file)
|
||
const pending = pendingImages.value.find((image) => image.localId === localId)
|
||
if (!pending) continue
|
||
Object.assign(pending, {
|
||
id: result.id,
|
||
attachmentId: result.id,
|
||
status: 'ready',
|
||
category: result.category,
|
||
summary: result.summary,
|
||
warning: result.warning
|
||
})
|
||
} catch (error: any) {
|
||
removePendingImage(localId)
|
||
errorMessage.value = error?.response?.data?.detail || error?.message || '图片识别失败,请重新选择图片'
|
||
}
|
||
}
|
||
}
|
||
|
||
const sendMessage = async (value: string, retryAttachments?: MessageAttachment[]) => {
|
||
const selectedAttachments = retryAttachments || readyPendingImages.value.map((image) => ({
|
||
id: image.attachmentId || image.id,
|
||
filename: image.filename,
|
||
previewUrl: image.previewUrl,
|
||
category: image.category,
|
||
summary: image.summary,
|
||
warning: image.warning
|
||
}))
|
||
const question = value.trim() || (selectedAttachments.length ? '请帮我看看这张图片。' : '')
|
||
if (!question || sending.value || (!retryAttachments && uploadingImage.value)) return
|
||
const history = messages.value.slice(-10).map(({ role, content, attachments }) => ({
|
||
role,
|
||
content,
|
||
attachmentIds: attachments?.map((attachment) => attachment.id) || []
|
||
}))
|
||
lastRequest.value = { question, attachments: selectedAttachments }
|
||
inputText.value = ''
|
||
errorMessage.value = ''
|
||
if (!retryAttachments) pendingImages.value = []
|
||
messages.value.push({ role: 'user', content: question, attachments: selectedAttachments })
|
||
sending.value = true
|
||
thinking.value = true
|
||
await scrollToBottom()
|
||
try {
|
||
const payload = {
|
||
message: question,
|
||
attachmentIds: selectedAttachments.map((attachment) => attachment.id),
|
||
history
|
||
}
|
||
const streamed = createStreamReply()
|
||
const handlers = {
|
||
onMeta: (meta: Pick<DisplayMessage, 'source' | 'references'>) => {
|
||
streamed.reply.source = meta.source
|
||
streamed.reply.references = meta.references
|
||
},
|
||
onDelta: (content: string) => {
|
||
thinking.value = false
|
||
streamed.append(content)
|
||
}
|
||
}
|
||
if (isPublic) {
|
||
await streamPublicAvatarChat(shareToken, payload, handlers)
|
||
} else {
|
||
await streamAvatarChat(avatarId.value, payload, handlers)
|
||
}
|
||
thinking.value = false
|
||
await streamed.finish()
|
||
} catch (error: any) {
|
||
errorMessage.value = error?.message || '暂时无法回答,请稍后重试'
|
||
} finally {
|
||
thinking.value = false
|
||
sending.value = false
|
||
}
|
||
}
|
||
|
||
const retryLast = () => {
|
||
if (!lastRequest.value || sending.value) return
|
||
while (messages.value[messages.value.length - 1]?.role === 'assistant') messages.value.pop()
|
||
if (messages.value[messages.value.length - 1]?.role === 'user') messages.value.pop()
|
||
void sendMessage(lastRequest.value.question, lastRequest.value.attachments)
|
||
}
|
||
|
||
onMounted(loadAvatar)
|
||
onBeforeUnmount(() => {
|
||
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
||
previewUrls.clear()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.chat-page { height: 100dvh; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #FFF8F1; color: #3B2417; }
|
||
.chat-header { position: sticky; top: 0; z-index: 10; flex: 0 0 auto; display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: white; background: linear-gradient(135deg, #F97316, #FB923C); box-shadow: 0 5px 18px rgba(249, 115, 22, .2); }
|
||
.back-btn, .settings-btn { border: 0; background: transparent; color: white; cursor: pointer; font-size: 25px; padding: 2px 6px; }
|
||
.settings-btn { font-size: 20px; margin-left: auto; }
|
||
.avatar-heading { display: flex; align-items: center; gap: 10px; }
|
||
.avatar-mark { width: 38px; height: 38px; display: grid; place-items: center; overflow: hidden; border-radius: 13px; background: rgba(255,255,255,.24); font-size: 23px; }.avatar-mark img { width: 100%; height: 100%; object-fit: cover; }
|
||
.avatar-heading h1 { margin: 0; font-size: 17px; }
|
||
.online-state { display: flex; align-items: center; gap: 4px; margin-top: 3px; font-size: 11px; opacity: .9; }.online-state i { width: 7px; height: 7px; border-radius: 50%; background: #86EFAC; box-shadow: 0 0 0 2px rgba(255,255,255,.22); }.online-state.training i { background: #FDE68A; }.online-state.inactive i { background: #FDA4AF; }
|
||
.message-list { min-height: 0; flex: 1 1 auto; width: min(760px, 100%); box-sizing: border-box; margin: 0 auto; padding: 24px 18px 120px; overflow-y: auto; overscroll-behavior: contain; }
|
||
.chat-page.has-pending-images .message-list { padding-bottom: min(330px, 42vh); }
|
||
.welcome-card { padding: 28px 20px; text-align: center; background: rgba(255,255,255,.72); border: 1px solid #FFE1C2; border-radius: 22px; box-shadow: 0 10px 28px rgba(181, 99, 35, .08); }
|
||
.welcome-avatar { width: 64px; height: 64px; display: grid; place-items: center; margin: 0 auto 14px; overflow: hidden; border: 3px solid #fff; border-radius: 50%; background: #FFE4C7; box-shadow: 0 7px 16px rgba(181, 99, 35, .18); font-size: 32px; }.welcome-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||
.welcome-card h2 { margin: 0 0 8px; font-size: 20px; }.welcome-description { max-width: 340px; margin: 0 auto; color: #8B6B58; font-size: 14px; line-height: 1.65; }
|
||
.message-row { display: flex; gap: 10px; margin: 18px 0; align-items: flex-start; }
|
||
.message-row.user { justify-content: flex-end; }
|
||
.message-avatar { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; overflow: hidden; border: 2px solid rgba(255,255,255,.9); border-radius: 14px; background: #FFE4C7; box-shadow: 0 3px 10px rgba(96, 52, 21, .12); font-size: 16px; }.message-avatar img { width: 100%; height: 100%; object-fit: cover; }.user-message-face { color: #fff; background: #D97706; }
|
||
.message-column { max-width: min(78%, 560px); }
|
||
.message-images { display: grid; grid-template-columns: repeat(2, minmax(0, 150px)); gap: 8px; margin-bottom: 8px; }
|
||
.message-image-card { margin: 0; overflow: hidden; border: 1px solid #F4D4B8; border-radius: 14px; background: #fff; box-shadow: 0 4px 14px rgba(96, 52, 21, .08); }
|
||
.message-image-card img { display: block; width: 100%; max-height: 210px; object-fit: cover; }
|
||
.message-image-card figcaption { padding: 7px 9px; color: #8A5A3B; background: #FFF6ED; font-size: 10px; line-height: 1.45; }
|
||
.message-bubble { padding: 12px 14px; white-space: pre-wrap; line-height: 1.6; font-size: 15px; border-radius: 4px 16px 16px 16px; background: white; box-shadow: 0 3px 12px rgba(96, 52, 21, .07); }
|
||
.message-bubble.streaming::after { content: ''; display: inline-block; width: 2px; height: 1.05em; margin-left: 3px; vertical-align: -0.16em; background: currentColor; animation: type-cursor .75s step-end infinite; }
|
||
.typing-character { display: inline-block; animation: character-in .24s cubic-bezier(.2,.72,.25,1) both; }.typing-character.newline { display: block; height: 0; }
|
||
.typing-character.markdown-bold { font-weight: 750; color: #2F1A10; }
|
||
.typing-character.markdown-italic { font-style: italic; }
|
||
.typing-character.markdown-code { margin: 0 1px; padding: 0 3px; border-radius: 4px; color: #9A3412; background: #FFF0E3; font-family: "SFMono-Regular", Consolas, monospace; font-size: .92em; }
|
||
.typing-character.markdown-heading { font-weight: 750; font-size: 1.08em; }
|
||
.user .message-bubble { color: white; border-radius: 16px 4px 16px 16px; background: #F97316; }
|
||
.message-source { margin: 5px 4px 0; font-size: 11px; color: #A77A5B; }
|
||
.typing { display: flex; gap: 4px; padding: 14px 16px; }
|
||
.typing i { width: 5px; height: 5px; border-radius: 50%; background: #F97316; animation: blink 1s infinite alternate; }
|
||
.typing i:nth-child(2) { animation-delay: .2s; }.typing i:nth-child(3) { animation-delay: .4s; }
|
||
@keyframes blink { from { opacity: .25; } to { opacity: 1; } }
|
||
@keyframes type-cursor { 50% { opacity: 0; } }
|
||
@keyframes character-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||
.chat-error { margin: 4px auto; color: #B42318; font-size: 13px; }.chat-error button { border: 0; background: none; color: #C15F18; cursor: pointer; text-decoration: underline; }
|
||
.composer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; gap: 9px; padding: 10px max(18px, calc((100vw - 760px) / 2 + 18px)) 12px; background: rgba(255,255,255,.94); border-top: 1px solid #F4DCC7; backdrop-filter: blur(14px); }
|
||
.composer-row { display: flex; align-items: flex-end; gap: 9px; }
|
||
.composer textarea { flex: 1; resize: none; min-height: 22px; max-height: 100px; padding: 11px 13px; border: 1px solid #EED8C5; border-radius: 13px; font: inherit; color: #3B2417; outline: none; }.composer textarea:focus { border-color: #F97316; }
|
||
.image-input { display: none; }
|
||
.image-btn { flex: 0 0 auto; width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid #EED8C5; border-radius: 13px; color: #C65A11; background: #FFF8F1; cursor: pointer; }
|
||
.image-btn svg { width: 22px; height: 22px; fill: currentColor; }
|
||
.image-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||
.pending-images { display: grid; gap: 7px; }
|
||
.pending-image { display: grid; grid-template-columns: 48px minmax(0, 1fr) 30px; align-items: center; gap: 9px; min-height: 48px; padding: 6px 8px; border: 1px solid #F1D4BB; border-radius: 14px; background: #FFF9F3; }
|
||
.pending-image img { width: 48px; height: 48px; object-fit: cover; border-radius: 10px; }
|
||
.pending-image-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||
.pending-image-copy strong { overflow: hidden; color: #4B2B19; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||
.pending-image-copy span { color: #9A7159; font-size: 10px; }
|
||
.pending-image.uploading strong::after { content: ''; display: inline-block; width: 7px; height: 7px; margin-left: 7px; border: 2px solid #F6B889; border-top-color: #F97316; border-radius: 50%; animation: image-spin .7s linear infinite; }
|
||
.pending-image > button { width: 28px; height: 28px; border: 0; border-radius: 9px; color: #9A7159; background: #F8E8D9; font-size: 19px; cursor: pointer; }
|
||
.medical-note { margin: 0; padding: 0 2px; color: #9A5A2E; font-size: 10px; line-height: 1.45; }
|
||
.send-btn { align-self: flex-end; padding: 11px 18px; border: 0; border-radius: 12px; color: white; background: #F97316; cursor: pointer; }.send-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||
@keyframes image-spin { to { transform: rotate(360deg); } }
|
||
@media (max-width: 520px) {
|
||
.message-images { grid-template-columns: minmax(0, 220px); }
|
||
.message-column { max-width: 80%; }
|
||
.send-btn { padding-inline: 14px; }
|
||
}
|
||
</style>
|