Files
replypier/frontend/src/components/conversations/ConversationChat.vue
T

1957 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { onMounted, watch, ref, computed } from 'vue'
import type { ChatDetail, Message } from '@/types/chat'
import { useConversationChat } from '@/composables/useConversationChat'
import { useConversationFiles } from '@/composables/useConversationFiles'
import { useVisualViewport } from '@/composables/useVisualViewport'
import { useJiraTicket } from '@/composables/useJiraTicket'
import JiraTicketModal from '@/components/jira/JiraTicketModal.vue'
import TicketCreateModal from '@/components/tickets/TicketCreateModal.vue'
import { ticketService } from '@/services/tickets'
import { permissionChecks } from '@/utils/permissions'
import FileUpload from '@/components/common/FileUpload.vue'
import { userService } from '@/services/user'
import WhatsAppTemplatePicker from '@/components/conversations/WhatsAppTemplatePicker.vue'
import MessageStatusTicks from '@/components/conversations/MessageStatusTicks.vue'
import QuickReplyChips from '@/components/conversations/QuickReplyChips.vue'
import MessageTag from '@/components/conversations/MessageTag.vue'
import { navIconSvg } from '@/components/layout/navIcons'
import { getInitials } from '@/utils/text'
import { marked } from 'marked'
import { sanitizeHtml } from '@/utils/sanitize'
import type { Renderer } from 'marked'
const props = defineProps<{
chat: ChatDetail
}>()
const emit = defineEmits<{
(e: 'refresh'): void
(e: 'chatUpdated', data: ChatDetail): void
(e: 'clearUnread', sessionId: string): void
(e: 'view-product', productId: string): void
(e: 'back'): void
(e: 'info'): void
}>()
// Create a local ref to track the current chat state
const currentChat = ref(props.chat)
const {
newMessage,
messagesContainer,
formattedMessages,
isLoading,
showTakeoverButton,
showTakenOverStatus,
isChatClosed,
canSendMessage,
scrollToBottom,
handleTakeover,
handleRouteToHuman,
isWaitingForHuman,
handlerCaption,
handledByAI,
replaceChatFromProps,
templateCanReopen,
clearTemplateSuggestion
} = useConversationChat(props.chat, emit)
// Templates are a WhatsApp-only way back into a conversation whose 24h window
// closed, and they need the account the conversation arrived on.
const showTemplatePicker = ref(false)
const canUseTemplates = computed(
() => currentChat.value.channel === 'whatsapp' && !!currentChat.value.channel_account_id
)
const handleTemplateSent = () => {
showTemplatePicker.value = false
clearTemplateSuggestion()
emit('refresh')
}
// Add file handling functionality
const {
fileUploadRef,
uploadedFiles,
handleFilesUploaded,
handleFileUploadError,
handleChatPaste,
handleSendMessageWithAttachments,
formatFileSize,
isImageAttachment,
getDownloadUrl,
getImageUrl
} = useConversationFiles(currentChat, newMessage, canSendMessage, scrollToBottom)
// Add Jira ticket functionality
const {
jiraConnected,
checkJiraStatus
} = useJiraTicket()
// State for Jira ticket modal
const showJiraTicketModal = ref(false)
const ticketSummary = ref('')
// Native ticketing: preferred over Jira when the user can manage tickets.
// One native ticket per session — the button flips to "View ticket".
const showTicketModal = ref(false)
const canUseNativeTickets = permissionChecks.canManageTickets()
const linkedTicketId = ref<string | null>(null)
const showTicketMenu = ref(false)
async function refreshLinkedTicket() {
if (!canUseNativeTickets || !currentChat.value?.session_id) return
const ticket = await ticketService.getTicketBySession(currentChat.value.session_id)
linkedTicketId.value = ticket?.id || null
}
// Add marked configuration
marked.setOptions({
renderer: new marked.Renderer(),
gfm: true,
breaks: true
})
// Configure marked renderer to add target="_blank" to links
const renderer = new marked.Renderer() as Renderer
renderer.link = function({ href, title, text }) {
if (!href) return text || ''
const link = `<a href="${href}"${title ? ` title="${title}"` : ''}>${text}</a>`
return link.replace(/^<a /, '<a target="_blank" rel="nofollow" ')
}
marked.use({ renderer })
// Create helper function to render and sanitize markdown
const renderMarkdown = (text: string) => {
return sanitizeHtml(marked.parse(text, { async: false }) as string)
}
// Computed property to determine if the current user can create a ticket
const canCreateTicket = computed(() => {
// Can only create ticket if:
// 1. User can send messages (already handled by canSendMessage)
// 2. Chat is not closed
// 3. Current user is the one who took over the chat
return canSendMessage.value &&
!isChatClosed.value &&
currentChat.value.user_id === userService.getUserId();
})
// Function to handle create ticket
const handleCreateTicket = async () => {
// Get a summary from the last few messages
const lastMessages = formattedMessages.value.slice(-3)
const summary = lastMessages.map(m => m.message).join(' ').substring(0, 100) + '...'
ticketSummary.value = summary
showJiraTicketModal.value = true
}
// Function to handle ticket created
const handleTicketCreated = (ticketKey: string) => {
// Add ticket key to message input
newMessage.value = `Jira 工单已创建:${ticketKey}`
// Close the modal
showJiraTicketModal.value = false
}
// ── Thread presentation ─────────────────────────────────────────────────
const currentUserName = userService.getUserName() || '人工客服'
const customerName = computed(
() => props.chat.customer.full_name || props.chat.customer.email || '访客'
)
const customerSubline = computed(() => {
const email = props.chat.customer.email
if (props.chat.customer.full_name) return email || '未留下邮箱'
return email || '匿名访客 · 未留下姓名与邮箱'
})
const CHANNEL_LABELS: Record<string, string> = {
web: '网页组件',
shopify: 'Shopify',
whatsapp: 'WhatsApp',
messenger: 'Messenger',
instagram: 'Instagram',
telegram: 'Telegram',
slack: 'Slack',
email: '邮件',
sms: '短信',
line: 'LINE',
api: 'API'
}
const channelLabel = (channel?: string | null) =>
(channel && CHANNEL_LABELS[channel]) || channel || '网页组件'
const formatClock = (iso: string) => {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
const dayLabel = (iso: string) => {
const date = new Date(iso)
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const dayDiff = Math.round(
(startOfToday.getTime() - startOfMessageDay.getTime()) / 86400000
)
const formatted = new Intl.DateTimeFormat('zh-CN', {
month: 'long',
day: 'numeric'
}).format(date)
if (dayDiff === 0) return `今天 · ${formatted}`
if (dayDiff === 1) return `昨天 · ${formatted}`
return formatted
}
// Who authored a bubble. The backend keeps the visitor / AI / human split in
// message_type, which is what the three designs key off.
type MessageKind = 'visitor' | 'ai' | 'human'
const messageKind = (type?: string): MessageKind => {
if (type === 'user') return 'visitor'
if (type === 'agent') return 'human'
return 'ai'
}
const isOwnMessage = (message: Message) =>
!!message.user_name && message.user_name === currentUserName
const whoLabel = (message: Message) => {
if (message.message_type === 'agent') {
return isOwnMessage(message)
? '你 · 人工客服'
: `${message.user_name || '客服'} · 人工客服`
}
return `AI · ${message.agent_name || props.chat.agent.name || '客服'}`
}
const avatarText = (message: Message) => {
if (messageKind(message.message_type) === 'human') {
return isOwnMessage(message) ? '你' : getInitials(message.user_name, '客')
}
return getInitials(customerName.value, '访')
}
const avatarTone = (message: Message) => {
const kind = messageKind(message.message_type)
return kind === 'human' ? 'ink' : kind === 'ai' ? 'celadon' : ''
}
const isEndEvent = (message: Message) =>
Boolean(message.end_chat || message.attributes?.end_chat)
// Hand-off notices are stored as ordinary agent/bot rows with this flag, not as
// message_type 'system' — they belong on the rule, not in a bubble.
const isHandoverEvent = (message: Message) =>
Boolean(message.attributes?.handover_notice)
const eventIcon = (message: Message) =>
isEndEvent(message) ? 'lock' : isHandoverEvent(message) ? 'headset' : 'clock'
// One stream that interleaves day dividers, system events and bubbles. System
// messages are the backend's own record of a hand-off or a close, so they get
// the rule-and-pill treatment instead of a bubble.
interface FormattedMessage extends Message {
timeAgo: string
}
interface DayItem {
key: string
kind: 'day'
label: string
}
interface MessageItem {
key: string
kind: 'event' | MessageKind
message: FormattedMessage
}
type StreamItem = DayItem | MessageItem
const streamItems = computed<StreamItem[]>(() => {
const items: StreamItem[] = []
let lastDay = ''
formattedMessages.value.forEach((message, index) => {
const dayKey = new Date(message.created_at).toDateString()
if (dayKey !== lastDay) {
items.push({
key: `day-${dayKey}`,
kind: 'day',
label: dayLabel(message.created_at)
})
lastDay = dayKey
}
if (message.message_type === 'system' || isHandoverEvent(message)) {
items.push({ key: `message-${index}`, kind: 'event', message })
return
}
items.push({
key: `message-${index}`,
kind: messageKind(message.message_type),
message
})
})
return items
})
const composerPlaceholder = computed(
() => `回复 ${customerName.value}… 支持粘贴截图`
)
// Keep the composer above the on-screen keyboard on installed iOS PWAs, where
// the keyboard overlays the viewport instead of resizing it
useVisualViewport(() => scrollToBottom())
// Watch for chat changes and update the internal state
watch(() => props.chat, (newChat) => {
if (newChat) {
// Only sync local state to avoid emitting events back to parent
currentChat.value = newChat
replaceChatFromProps(newChat)
}
}, { immediate: true })
// Check Jira status on mount
onMounted(async () => {
scrollToBottom()
await checkJiraStatus()
await refreshLinkedTicket()
})
watch(() => currentChat.value?.session_id, () => refreshLinkedTicket())
</script>
<template>
<div class="chat-layout">
<header class="thread-head">
<button
class="icon-btn sm back-btn"
aria-label="返回会话列表"
@click="emit('back')"
>
<span class="back-glyph" v-html="navIconSvg('chevronRight', 18)"></span>
</button>
<div class="thread-who">
<div class="thread-name">
<h2>{{ customerName }}</h2>
<span v-if="handledByAI" class="chip chip-ai"><i class="d"></i>AI 接待中</span>
<span v-else-if="isWaitingForHuman" class="chip chip-wait"><i class="d"></i>等待人工</span>
<span v-else-if="showTakenOverStatus" class="chip">
<span class="chip-glyph" v-html="navIconSvg('headset', 12)"></span>
由 {{ chat.user_name || '其他客服' }} 接管
</span>
<span v-else-if="isChatClosed" class="chip chip-quiet">已结束</span>
<span v-else class="chip chip-human">人工接待中 · 你</span>
</div>
<div class="thread-meta">
<span>{{ customerSubline }}</span>
<i></i>
<span>{{ channelLabel(chat.channel) }}</span>
<i></i>
<span>{{ chat.agent.name || 'AI 客服' }}</span>
</div>
</div>
<div class="thread-actions">
<!-- The thread only streams once a human takes the chat over; while the
AI is answering this is how you pull the latest turns. -->
<button
class="icon-btn sm"
title="刷新会话"
aria-label="刷新会话"
@click="emit('refresh')"
>
<span v-html="navIconSvg('refresh', 16)"></span>
</button>
<button
v-if="canUseTemplates && !isChatClosed"
class="btn btn-ghost btn-sm"
title="发送模板"
aria-label="发送模板"
@click="showTemplatePicker = true"
>
<span class="btn-glyph" v-html="navIconSvg('message', 15)"></span>
<span class="btn-label">发送模板</span>
</button>
<template v-if="canCreateTicket">
<!-- A native ticket already exists for this conversation -->
<button
v-if="linkedTicketId"
class="btn btn-ghost btn-sm"
@click="$router.push(`/tickets/${linkedTicketId}`)"
>
<span class="btn-glyph" v-html="navIconSvg('ticket', 15)"></span>
<span class="btn-label">查看工单</span>
</button>
<!-- Jira connected: keep the familiar Jira flow primary, native in
the dropdown (orgs that configured Jira keep their board). -->
<div v-else-if="jiraConnected" class="ticket-btn-group">
<button class="btn btn-ghost btn-sm" @click="handleCreateTicket">
<span class="btn-glyph" v-html="navIconSvg('ticket', 15)"></span>
<span class="btn-label">创建工单</span>
</button>
<button
v-if="canUseNativeTickets"
class="ticket-menu-toggle"
title="更多工单选项"
aria-label="更多工单选项"
@click="showTicketMenu = !showTicketMenu"
>
<span v-html="navIconSvg('chevronDown', 14)"></span>
</button>
<div v-if="showTicketMenu" class="ticket-menu">
<button
class="ticket-menu-item"
@click="showTicketMenu = false; showTicketModal = true"
>
创建 ReplyPier 工单
</button>
</div>
</div>
<!-- No Jira: native ticketing is the flow -->
<button
v-else-if="canUseNativeTickets"
class="btn btn-ghost btn-sm"
@click="showTicketModal = true"
>
<span class="btn-glyph" v-html="navIconSvg('ticket', 15)"></span>
<span class="btn-label">创建工单</span>
</button>
</template>
<button
class="icon-btn sm"
aria-label="会话详情"
title="会话详情"
@click="emit('info')"
>
<span v-html="navIconSvg('panel', 16)"></span>
</button>
</div>
</header>
<main class="chat-content">
<div class="messages" ref="messagesContainer" aria-label="对话消息">
<div class="stream-inner">
<template v-for="item in streamItems" :key="item.key">
<div v-if="item.kind === 'day'" class="day">{{ item.label }}</div>
<div
v-else-if="item.kind === 'event'"
class="event"
:class="{
warn: isEndEvent(item.message),
take: isHandoverEvent(item.message),
}"
>
<span class="event-pill">
<span
class="event-glyph"
v-html="navIconSvg(eventIcon(item.message), 13)"
></span>
<span class="event-text">{{ item.message.message || '会话事件' }}</span>
<time class="num">{{ formatClock(item.message.created_at) }}</time>
</span>
</div>
<div
v-else
class="msg"
:class="[item.kind, { out: item.kind !== 'visitor' }]"
>
<span class="avatar" :class="avatarTone(item.message)">
<span
v-if="item.kind === 'ai'"
class="ai-glyph"
v-html="navIconSvg('sparkles', 14)"
></span>
<template v-else>{{ avatarText(item.message) }}</template>
</span>
<div class="msg-body">
<div class="bubble">
<!-- Product message -->
<template v-if="item.message.message_type === 'product' && item.message.attributes?.shopify_output?.products?.length">
<div class="products-carousel">
<div v-html="renderMarkdown(item.message.message || '')" class="product-message-text"></div>
<div class="carousel-items">
<div
v-for="product in item.message.attributes.shopify_output.products"
:key="product.id"
class="product-card-compact"
>
<div class="product-image-compact" v-if="product.image?.src">
<img :src="product.image.src" :alt="product.title || ''" class="product-thumbnail">
</div>
<div class="product-info-compact">
<div class="product-text-area">
<div class="product-title-compact">{{ product.title }}</div>
<div class="product-variant-compact" v-if="product.variant_title">{{ product.variant_title }}</div>
<div class="product-price-compact">{{ product.price }}</div>
</div>
<div class="product-actions-compact">
<button
class="view-details-button-compact"
@click="$emit('view-product', String(product?.id || 'unknown'))"
>
查看商品 <span class="external-link-icon" v-html="navIconSvg('arrowUpRight', 12)"></span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Regular message with markdown -->
<template v-else>
<MessageTag
v-if="item.message.attributes?.interactive_reply"
icon="fa-solid fa-hand-pointer"
label="按钮回复"
/>
<MessageTag
v-if="item.message.attributes?.source === 'app'"
icon="fa-solid fa-plug"
:label="`由 ${item.message.attributes?.app_name || '应用'} 发送`"
/>
<div class="bubble-text" v-html="renderMarkdown(item.message.message || '')"></div>
<QuickReplyChips :sent="item.message.attributes?.interactive_sent" />
<!-- Display attachments if present -->
<div v-if="item.message.attachments && item.message.attachments.length > 0" class="message-attachments">
<div
v-for="attachment in item.message.attachments"
:key="attachment.id"
class="attachment-item"
>
<!-- Image attachment - render as image -->
<template v-if="isImageAttachment(attachment.content_type)">
<div class="attachment-image-container">
<img
:src="getImageUrl(attachment.file_url)"
:alt="attachment.filename"
class="attachment-image"
/>
<div class="attachment-image-info">
<a
:href="getDownloadUrl(attachment.file_url)"
target="_blank"
class="attachment-link"
>
<font-awesome-icon icon="fa-solid fa-download" />
{{ attachment.filename }}
<span class="attachment-size">({{ formatFileSize(attachment.file_size) }})</span>
</a>
</div>
</div>
</template>
<!-- Other file types - render as download link -->
<template v-else>
<a
:href="getDownloadUrl(attachment.file_url)"
target="_blank"
class="attachment-link"
>
<font-awesome-icon icon="fa-solid fa-paperclip" />
{{ attachment.filename }}
<span class="attachment-size">({{ formatFileSize(attachment.file_size) }})</span>
</a>
</template>
</div>
</div>
</template>
</div>
<div class="msg-meta">
<span
v-for="(source, sourceIndex) in (item.message.sources || [])"
:key="`${item.key}-source-${sourceIndex}`"
class="cite"
:title="source.type"
>
<span class="cite-glyph" v-html="navIconSvg('book', 12)"></span>
依据 · {{ source.name }}
</span>
<span
v-if="item.kind !== 'visitor'"
class="who"
:class="{ 'who-ai': item.kind === 'ai' }"
>
{{ whoLabel(item.message) }}
</span>
<time class="num" :title="item.message.timeAgo">
{{ formatClock(item.message.created_at) }}
</time>
<MessageStatusTicks
:status="item.message.channel_status"
:on-accent="item.kind === 'human'"
/>
<span v-if="item.message.attributes?.delivery_status === 'failed'" class="delivery-failed">
<span class="fail-glyph" v-html="navIconSvg('x', 12)"></span> 未送达
</span>
</div>
</div>
</div>
</template>
<div v-if="formattedMessages.length === 0" class="thread-empty">
<span class="thread-empty-glyph" v-html="navIconSvg('message', 20)"></span>
<p>还没有消息</p>
<span>这段会话开始后,消息会出现在这里。</span>
</div>
</div>
</div>
</main>
<!-- Jira Ticket Modal -->
<JiraTicketModal
v-if="showJiraTicketModal"
:chat-id="currentChat.session_id"
:initial-summary="ticketSummary"
@close="showJiraTicketModal = false"
@ticket-created="handleTicketCreated"
/>
<!-- Native ticket modal (AI-drafted from this conversation) -->
<TicketCreateModal
:open="showTicketModal"
:session-id="currentChat.session_id"
:session-label="chat.customer.full_name || chat.customer.email || '此会话'"
@close="showTicketModal = false"
@created="refreshLinkedTicket"
/>
<!-- Unclaimed: the composer is replaced by the primary "Take over chat"
action. Covers both an AI-handled chat and one the AI has queued for a
human, so claiming never requires opening the details panel. -->
<footer v-if="showTakeoverButton" class="chat-input takeover-footer">
<p class="takeover-caption">{{ handlerCaption }}</p>
<div class="takeover-actions">
<button class="btn btn-primary takeover-button" :disabled="isLoading" @click="handleTakeover">
<span class="btn-glyph" v-html="navIconSvg('headset', 16)"></span>
接管会话
</button>
<!-- Claiming it yourself was the only way to silence the AI. This queues
it for whoever is free instead. -->
<button
v-if="handledByAI"
class="btn btn-ghost hand-over-button"
:disabled="isLoading"
@click="handleRouteToHuman"
>
<span class="btn-glyph" v-html="navIconSvg('transfer', 15)"></span>
转给团队
</button>
</div>
</footer>
<footer class="chat-input" v-else-if="!isChatClosed && !handledByAI">
<!-- The agent has just been told a reply couldn't be delivered; offer the
one action that gets the conversation back rather than leaving them
to retype into a composer that will fail again. -->
<div v-if="templateCanReopen && canUseTemplates" class="window-closed-notice">
<div class="window-closed-text">
<span class="btn-glyph" v-html="navIconSvg('clock', 14)"></span>
<span>该客户的 24 小时窗口已关闭,发送已审核的模板可重新开启。</span>
</div>
<button class="btn btn-primary btn-sm" @click="showTemplatePicker = true">
发送模板
</button>
</div>
<div
class="composer"
:class="{ disabled: !canSendMessage }"
@paste="handleChatPaste"
>
<div class="composer-row">
<input
v-model="newMessage"
type="text"
:placeholder="composerPlaceholder"
class="message-input"
@keyup.enter="handleSendMessageWithAttachments"
:disabled="!canSendMessage"
>
</div>
<div class="composer-bar">
<FileUpload
ref="fileUploadRef"
:max-files="3"
@filesUploaded="handleFilesUploaded"
@error="handleFileUploadError"
/>
<span class="as-role">
以 <b>{{ currentUserName }}</b> 身份回复
</span>
<span class="hint"><kbd>Enter</kbd> 发送</span>
<button
class="btn btn-primary btn-sm send-button"
@click="handleSendMessageWithAttachments"
:disabled="(!newMessage.trim() && uploadedFiles.length === 0) || !canSendMessage"
aria-label="发送"
>
发送
<span class="btn-glyph" v-html="navIconSvg('send', 14)"></span>
</button>
</div>
</div>
<div v-if="!canSendMessage" class="input-message">
{{ handlerCaption }}
</div>
</footer>
<footer v-else-if="handledByAI" class="chat-closed-footer">
<div class="chat-closed-message">
<span v-html="navIconSvg('lock', 15)"></span>
此会话正由 AI 客服处理
</div>
</footer>
<footer v-else class="chat-closed-footer">
<div class="chat-closed-message">
<span v-html="navIconSvg('lock', 15)"></span>
此会话已结束
</div>
</footer>
<WhatsAppTemplatePicker
v-if="showTemplatePicker && currentChat.channel_account_id"
:account-id="currentChat.channel_account_id"
:session-id="currentChat.session_id"
@close="showTemplatePicker = false"
@sent="handleTemplateSent"
/>
</div>
</template>
<style scoped>
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
width: 100%;
background: var(--paper);
position: relative;
overflow: hidden;
}
/* ── Thread header ─────────────────────────────────── */
.thread-head {
flex: none;
display: flex;
align-items: center;
gap: 14px;
padding: 16px 28px;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--surface) 70%, transparent);
backdrop-filter: blur(8px);
}
.back-btn {
display: none;
margin-left: -8px;
}
.back-glyph {
display: inline-flex;
transform: rotate(180deg);
}
.back-glyph :deep(svg) {
display: block;
}
.thread-who {
flex: 1;
min-width: 0;
}
.thread-name {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.thread-name h2 {
font-family: var(--font-serif);
font-size: 18px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--ink);
margin: 0;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.thread-name .chip {
flex: none;
height: 22px;
font-size: 11px;
}
.chip-glyph {
display: inline-flex;
}
.chip-glyph :deep(svg) {
display: block;
}
.thread-meta {
margin-top: 3px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
}
.thread-meta i {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--faint);
flex: none;
}
.thread-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
.btn-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-glyph :deep(svg) {
display: block;
}
.ticket-btn-group {
position: relative;
display: flex;
align-items: center;
}
.ticket-btn-group .btn {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.ticket-menu-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 32px;
padding: 0;
border: 1px solid var(--line-2);
border-left: 0;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: var(--surface);
color: var(--text2);
cursor: pointer;
transition:
border-color var(--t-fast),
color var(--t-fast);
}
.ticket-menu-toggle:hover {
border-color: var(--line-3);
color: var(--ink);
}
.ticket-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
min-width: 190px;
padding: 4px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.ticket-menu-item {
display: block;
width: 100%;
padding: 8px 12px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text2);
font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.ticket-menu-item:hover {
background: var(--surface-2);
color: var(--ink);
}
/* ── Stream ────────────────────────────────────────── */
.chat-content {
flex: 1 1 auto;
min-height: 0;
position: relative;
width: 100%;
overflow: hidden;
}
.messages {
position: absolute;
inset: 0;
overflow-y: auto;
padding: 28px 28px 12px;
scroll-behavior: smooth;
}
.stream-inner {
max-width: 780px;
min-height: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.day {
align-self: center;
font-family: var(--font-serif);
font-size: 12.5px;
letter-spacing: 0.3em;
color: var(--muted);
padding: 4px 0 6px;
}
.msg {
display: flex;
gap: 12px;
max-width: 80%;
align-items: flex-start;
}
.msg.out {
align-self: flex-end;
flex-direction: row-reverse;
}
.msg .avatar {
--a: 30px;
margin-top: 2px;
}
.msg-body {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
}
.msg.out .msg-body {
align-items: flex-end;
}
.bubble {
padding: 12px 16px;
border-radius: 16px;
font-size: 14.5px;
line-height: 1.8;
word-break: break-word;
max-width: 100%;
}
.msg.visitor .bubble {
background: var(--surface);
border: 1px solid var(--line);
border-top-left-radius: 5px;
box-shadow: var(--shadow-sm);
color: var(--ink-2);
}
.msg.ai .bubble {
background: var(--bubble-ai);
border: 1px solid var(--celadon-line);
border-top-right-radius: 5px;
color: var(--ink-2);
}
.msg.human .bubble {
background: var(--bubble-human);
color: var(--bubble-human-fg);
border-top-right-radius: 5px;
box-shadow: var(--shadow-md);
}
.msg-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 11.5px;
color: var(--muted);
padding: 0 2px;
}
.msg.out .msg-meta {
justify-content: flex-end;
}
.msg-meta .who {
color: var(--ink-2);
font-weight: 500;
}
.msg.ai .msg-meta .who {
color: var(--celadon);
}
.msg-meta time {
font-variant-numeric: tabular-nums;
}
/* Knowledge-base citation — 青瓷 dashed capsule */
.cite {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 10px 0 8px;
border-radius: var(--radius-pill);
border: 1px dashed var(--celadon-line);
background: var(--surface);
color: var(--celadon);
font-size: 11.5px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: border-style var(--t-fast);
}
.cite:hover {
border-style: solid;
}
.cite-glyph {
display: inline-flex;
flex: none;
}
.cite-glyph :deep(svg) {
display: block;
}
/* System events — rule, pill, timestamp */
.event {
display: flex;
align-items: center;
gap: 14px;
font-size: 12.5px;
color: var(--muted);
margin: 6px 0;
}
.event::before,
.event::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--line-2));
}
.event::after {
background: linear-gradient(270deg, transparent, var(--line-2));
}
.event-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface);
max-width: 78%;
line-height: 1.5;
}
.event-glyph {
display: inline-flex;
flex: none;
color: var(--muted);
}
.event-glyph :deep(svg) {
display: block;
}
.event-text {
min-width: 0;
}
.event time {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
flex: none;
}
.event.warn .event-pill {
color: var(--vermilion);
border-color: var(--vermilion-line);
background: color-mix(in srgb, var(--vermilion) 5%, var(--surface));
}
.event.warn .event-glyph {
color: var(--vermilion);
}
/* A hand-off is notable, not alarming: ink rather than 朱砂. */
.event.take .event-pill {
color: var(--ink);
font-weight: 500;
}
.event.take .event-glyph {
color: var(--ink);
}
.thread-empty {
margin: auto;
padding: 48px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
text-align: center;
}
.thread-empty-glyph {
width: 46px;
height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
border-radius: 50%;
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
box-shadow: var(--shadow-sm);
}
.thread-empty p {
font-family: var(--font-serif);
font-size: 17px;
font-weight: 600;
color: var(--ink);
}
.thread-empty span:last-child {
font-size: 12.5px;
color: var(--muted);
}
/* Payload inside a bubble */
.bubble-text :deep(p) {
margin: 0;
}
.bubble-text :deep(p + p),
.bubble-text :deep(ul + p),
.bubble-text :deep(p + ul) {
margin-top: 8px;
}
.bubble-text :deep(ul),
.bubble-text :deep(ol) {
margin: 4px 0 0;
padding-left: 1.2em;
}
.bubble-text :deep(a) {
color: var(--indigo);
text-decoration: underline;
text-underline-offset: 2px;
}
.msg.human .bubble-text :deep(a) {
color: var(--bubble-human-fg);
}
.msg.human .bubble-text :deep(a:hover) {
opacity: 0.75;
}
.bubble-text :deep(img) {
max-width: 100%;
border-radius: var(--radius-sm);
margin: 8px 0;
}
.bubble-text :deep(code) {
font-family: var(--font-mono);
font-size: 0.92em;
padding: 1px 5px;
border-radius: 5px;
background: var(--surface-sunk);
}
.msg.human .message-tag {
background: color-mix(in srgb, var(--bubble-human-fg) 10%, transparent);
border-color: color-mix(in srgb, var(--bubble-human-fg) 22%, transparent);
color: color-mix(in srgb, var(--bubble-human-fg) 78%, transparent);
}
/* ── Attachments ───────────────────────────────────── */
.message-attachments {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.attachment-item {
display: flex;
align-items: center;
}
.attachment-link {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--ink-2);
font-size: 12.5px;
max-width: 100%;
overflow: hidden;
text-decoration: none;
transition: border-color var(--t-fast);
}
.attachment-link:hover {
border-color: var(--line-3);
}
.attachment-link :deep(svg) {
width: 13px;
height: 13px;
color: var(--muted);
}
.attachment-size {
color: var(--muted);
font-size: 11px;
margin-left: 2px;
}
.msg.human .attachment-link {
background: color-mix(in srgb, var(--bubble-human-fg) 12%, transparent);
border-color: color-mix(in srgb, var(--bubble-human-fg) 22%, transparent);
color: var(--bubble-human-fg);
}
.msg.human .attachment-link :deep(svg),
.msg.human .attachment-size {
color: color-mix(in srgb, var(--bubble-human-fg) 72%, transparent);
}
.attachment-image-container {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 350px;
}
.attachment-image {
width: 100%;
max-height: 400px;
border-radius: var(--radius-sm);
object-fit: contain;
border: 1px solid var(--line);
}
.attachment-image-info {
display: flex;
align-items: center;
font-size: 12px;
}
.attachment-image-info .attachment-link {
margin: 0;
padding: 4px 8px;
font-size: 12px;
}
/* ── Product carousel ──────────────────────────────── */
.products-carousel {
margin: 2px 0;
width: 100%;
padding: 6px;
background: var(--surface-2);
border-radius: var(--radius-card);
}
.product-message-text {
margin-bottom: 6px;
}
.carousel-items {
display: flex;
flex-direction: row;
gap: 8px;
margin-top: 2px;
overflow-x: auto;
padding: 2px 2px 10px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.product-card-compact {
width: 180px;
flex: none;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-card);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--t-fast), transform var(--t-fast);
}
.product-card-compact:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.product-image-compact {
width: 100%;
aspect-ratio: 1;
overflow: hidden;
}
.product-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--t-med);
}
.product-thumbnail:hover {
transform: scale(1.04);
}
.product-info-compact {
padding: 9px;
display: flex;
flex-direction: column;
gap: 5px;
}
.product-title-compact {
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
min-height: 2.8em;
color: var(--ink);
}
.product-variant-compact {
font-size: 11px;
color: var(--muted);
}
.product-price-compact {
font-size: 13px;
font-weight: 600;
color: var(--ink);
}
.view-details-button-compact {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--line-2);
border-radius: var(--radius-pill);
background: var(--surface-2);
color: var(--ink-2);
font-size: 11.5px;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
transition: border-color var(--t-fast), color var(--t-fast);
}
.view-details-button-compact:hover {
border-color: var(--line-3);
color: var(--ink);
}
.external-link-icon {
display: inline-flex;
line-height: 1;
}
.external-link-icon :deep(svg) {
display: block;
}
/* ── Composer ──────────────────────────────────────── */
.chat-input {
flex: 0 0 auto;
width: 100%;
padding: 10px 28px 22px;
}
.composer {
max-width: 780px;
margin: 0 auto;
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
transition:
border-color var(--t-fast),
box-shadow var(--t-fast);
}
.composer:focus-within {
border-color: var(--line-3);
box-shadow: var(--shadow-sm), var(--ring);
}
.composer.disabled {
opacity: 0.72;
background: var(--surface-2);
}
.composer-row {
padding: 14px 18px 2px;
}
.message-input {
width: 100%;
padding: 2px 0;
border: 0;
outline: none;
background: none;
color: var(--ink);
font-family: var(--font-sans);
font-size: 14.5px;
line-height: 1.7;
}
.message-input::placeholder {
color: var(--faint);
}
.message-input:disabled {
cursor: not-allowed;
}
.composer-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 4px 10px 10px 12px;
}
.as-role {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
.as-role b {
color: var(--ink-2);
font-weight: 600;
}
.composer-bar .hint {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
color: var(--muted);
white-space: nowrap;
}
.composer-bar .send-button {
margin-left: 6px;
}
/* The attachment control is shared with other surfaces; compact it to a
toolbar glyph and let previews ride inline instead of stacking a panel. */
.composer-bar :deep(.file-upload-container) {
display: contents;
}
.composer-bar :deep(.upload-button) {
width: 32px;
height: 32px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text2);
}
.composer-bar :deep(.upload-button:hover:not(:disabled)) {
background: var(--surface-2);
border-color: var(--line-2);
color: var(--ink);
}
.composer-bar :deep(.upload-button svg) {
width: 16px;
height: 16px;
}
.composer-bar :deep(.file-previews) {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 6px;
max-width: 44%;
overflow-x: auto;
margin: 0;
padding: 3px 6px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.composer-bar :deep(.file-preview) {
gap: 6px;
padding: 2px 4px;
border: 0;
border-radius: 5px;
background: transparent;
box-shadow: none;
transform: none;
}
.composer-bar :deep(.file-preview:hover) {
transform: none;
box-shadow: none;
border-color: transparent;
}
.composer-bar :deep(.file-preview::before) {
display: none;
}
.composer-bar :deep(.file-preview-content) {
width: 22px;
height: 22px;
border-radius: 5px;
background: var(--surface-sunk);
box-shadow: none;
}
.composer-bar :deep(.file-preview-content::after) {
display: none;
}
.composer-bar :deep(.file-preview-image) {
width: 22px;
height: 22px;
border-radius: 4px;
}
.composer-bar :deep(.file-preview-icon svg) {
width: 13px;
height: 13px;
}
.composer-bar :deep(.file-preview-icon) {
color: var(--muted);
}
.composer-bar :deep(.file-preview-name) {
max-width: 120px;
color: var(--ink-2);
font-size: 11.5px;
font-weight: 500;
}
.composer-bar :deep(.file-preview-size) {
color: var(--muted);
font-size: 10.5px;
}
.composer-bar :deep(.file-preview-remove) {
width: 18px;
height: 18px;
min-width: 18px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted);
font-size: 13px;
box-shadow: none;
}
.composer-bar :deep(.file-preview-remove:hover) {
background: var(--vermilion-soft);
color: var(--vermilion);
}
.composer-bar :deep(.upload-progress) {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: var(--muted);
}
.window-closed-notice {
max-width: 780px;
margin: 0 auto 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 10px 14px;
border: 1px solid var(--brass-line);
border-radius: var(--radius-md);
background: var(--brass-soft);
}
.window-closed-text {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
line-height: 1.55;
color: var(--ink-2);
}
.window-closed-text .btn-glyph {
color: var(--brass);
}
.input-message {
max-width: 780px;
margin: 8px auto 0;
text-align: center;
font-size: 12px;
color: var(--muted);
}
/* ── Takeover / closed footers ─────────────────────── */
.takeover-footer {
display: flex;
flex-direction: column;
gap: 10px;
}
.takeover-caption {
max-width: 780px;
margin: 0 auto;
text-align: center;
font-size: 12.5px;
color: var(--muted);
}
.takeover-actions {
display: flex;
gap: 8px;
max-width: 780px;
margin: 0 auto;
width: 100%;
}
.takeover-button {
flex: 1;
height: 44px;
}
.hand-over-button {
flex: none;
height: 44px;
}
.chat-closed-footer {
flex: 0 0 auto;
padding: 12px 28px 22px;
width: 100%;
}
.chat-closed-message {
max-width: 780px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 13px 18px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
color: var(--muted);
font-size: 13px;
}
/* ── Post-bubble status ────────────────────────────── */
.delivery-failed {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--vermilion);
}
.fail-glyph {
display: inline-flex;
}
.fail-glyph :deep(svg) {
display: block;
}
.ai-glyph {
display: inline-flex;
}
.ai-glyph :deep(svg) {
display: block;
}
/* ── Responsive ────────────────────────────────────── */
@media (max-width: 1440px) {
.thread-head {
padding: 12px 18px;
gap: 10px;
}
.thread-meta {
display: none;
}
.thread-name h2 {
font-size: 16px;
}
.messages {
padding: 22px 18px 10px;
}
.chat-input {
padding: 8px 18px 16px;
}
.chat-closed-footer {
padding: 10px 18px 16px;
}
/* The three-column desktop gives the thread less width than the viewport,
so the action labels yield to the customer name before the thread does. */
.thread-actions .btn-label {
display: none;
}
.thread-actions .btn {
width: 32px;
padding: 0;
}
}
/* Mobile: full-screen chat pane */
@media (max-width: 768px) {
.back-btn {
display: inline-flex;
}
.thread-head {
padding: 8px 8px 8px 6px;
gap: 2px;
}
.thread-name h2 {
font-size: 16px;
max-width: 46vw;
}
.thread-name {
gap: 6px;
flex-wrap: wrap;
row-gap: 3px;
}
.thread-actions {
gap: 0;
}
.thread-actions .btn,
.thread-actions .icon-btn.sm,
.back-btn {
width: 38px;
height: 38px;
}
.thread-actions .btn {
border-color: transparent;
background: transparent;
}
.messages {
padding: 18px 12px 8px;
}
.stream-inner {
gap: 14px;
}
.msg {
max-width: 88%;
gap: 8px;
}
.msg .avatar {
--a: 26px;
}
.bubble {
padding: 10px 13px;
font-size: 14px;
line-height: 1.7;
}
.day {
font-size: 11.5px;
letter-spacing: 0.24em;
}
.event {
font-size: 11.5px;
gap: 8px;
}
.event-pill {
max-width: 88%;
padding: 4px 10px;
gap: 6px;
}
.composer-bar .hint {
display: none;
}
.composer-bar {
padding: 2px 8px 8px 10px;
}
.composer-row {
padding: 12px 14px 0;
}
.as-role {
font-size: 11.5px;
}
.chat-input {
padding: 8px 12px calc(12px + var(--safe-bottom) + var(--kb-offset, 0px));
}
.chat-closed-footer {
padding: 8px 12px calc(12px + var(--safe-bottom));
}
.window-closed-notice {
border-radius: var(--radius-sm);
}
.takeover-button,
.hand-over-button {
height: 46px;
}
.composer-bar :deep(.file-previews) {
order: -1;
max-width: 100%;
}
}
@media (max-width: 480px) {
.thread-name .chip {
display: none;
}
.thread-name h2 {
max-width: 52vw;
}
}
</style>