708 lines
16 KiB
Vue
708 lines
16 KiB
Vue
<template>
|
||
<div ref="chatListRef" class="chat-list" @scroll.passive="handleScroll">
|
||
<!-- 顶部:有更多历史时上滑加载更早记录 -->
|
||
<div v-if="hasMore || loadingMore" class="load-more-bar">
|
||
<span v-if="loadingMore" class="load-more-spinner"></span>
|
||
{{ loadingMore ? '加载中…' : '继续上滑,加载更早的记录' }}
|
||
</div>
|
||
<div
|
||
v-for="msg in messages"
|
||
:key="msg.id"
|
||
class="message-row"
|
||
:class="{ 'is-user': msg.isUser, 'is-error': !msg.isUser && isErrorMsg(msg) }"
|
||
>
|
||
<div class="bubble-wrap">
|
||
<div class="bubble">
|
||
<template v-if="msg.isUser">{{ msg.content }}</template>
|
||
<template v-else>
|
||
<!-- 初始加载动画 -->
|
||
<template v-if="msg.loading && !msg.thinking && !msg.content">
|
||
<span class="loading-text">思考中</span><span class="loading-dots"><i></i><i></i><i></i></span>
|
||
</template>
|
||
<!-- 思考区:DeepSeek 风格折叠块 -->
|
||
<div v-if="msg.thinking" class="thinking-block">
|
||
<button class="thinking-toggle" type="button" @click="toggleThinking(msg)">
|
||
<span class="thinking-icon">🧠</span>
|
||
<span class="thinking-title">{{ thinkingTitle(msg) }}</span>
|
||
<span class="thinking-arrow" :class="{ collapsed: !isThinkingExpanded(msg) }">▾</span>
|
||
</button>
|
||
<div v-if="isThinkingExpanded(msg)" class="thinking-content">
|
||
<div class="thinking-text">{{ msg.thinking }}</div>
|
||
</div>
|
||
</div>
|
||
<!-- 回答区:Markdown 渲染 -->
|
||
<div
|
||
v-if="msg.content"
|
||
class="answer-block"
|
||
v-html="renderAnswer(msg.content)"
|
||
@click="handleAnswerClick"
|
||
></div>
|
||
<!-- 失败重试 -->
|
||
<button v-if="isErrorMsg(msg)" type="button" class="retry-btn" @click="emit('retry', msg)">
|
||
<span class="retry-icon">↻</span> 重新生成
|
||
</button>
|
||
</template>
|
||
</div>
|
||
<!-- AI 消息操作栏(hover 显示) -->
|
||
<div v-if="!msg.isUser && msg.content && !isErrorMsg(msg)" class="msg-actions">
|
||
<button class="msg-action" title="重新生成" @click="emit('regenerate', msg)">
|
||
<el-icon><RefreshRight /></el-icon>
|
||
</button>
|
||
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
|
||
<el-icon><DocumentCopy /></el-icon>
|
||
</button>
|
||
<button v-if="msg.recordId" class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
|
||
<el-icon><Delete /></el-icon>
|
||
</button>
|
||
</div>
|
||
<!-- 用户消息操作栏(hover 显示,仅删除) -->
|
||
<div v-if="msg.isUser && msg.recordId" class="msg-actions">
|
||
<button class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
|
||
<el-icon><Delete /></el-icon>
|
||
</button>
|
||
</div>
|
||
<div class="time">{{ msg.time }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||
import { ElMessage } from 'element-plus';
|
||
import { Delete, DocumentCopy, RefreshRight } from '@element-plus/icons-vue';
|
||
import 'highlight.js/styles/github-dark.css';
|
||
import { renderMarkdown } from '../utils/markdown';
|
||
|
||
interface ChatMessage {
|
||
id: string;
|
||
content: string;
|
||
time: string;
|
||
isUser: boolean;
|
||
loading?: boolean;
|
||
thinking?: string;
|
||
thinkingSeconds?: number;
|
||
retryQuestion?: string;
|
||
// 后端记录 id / 类型(来自 session/get 的每条结果),存在时才显示删除按钮
|
||
recordId?: string;
|
||
recordType?: string;
|
||
}
|
||
|
||
interface Emits {
|
||
(e: 'retry', msg: ChatMessage): void;
|
||
(e: 'regenerate', msg: ChatMessage): void;
|
||
(e: 'delete', msg: ChatMessage): void;
|
||
(e: 'load-more'): void;
|
||
}
|
||
|
||
interface Props {
|
||
messages: ChatMessage[];
|
||
hasMore?: boolean;
|
||
loadingMore?: boolean;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
hasMore: false,
|
||
loadingMore: false,
|
||
});
|
||
const emit = defineEmits<Emits>();
|
||
const chatListRef = ref<HTMLElement | null>(null);
|
||
|
||
const isErrorMsg = (msg: ChatMessage): boolean => !msg.isUser && String(msg.content || '').startsWith('❌');
|
||
|
||
// 思考区折叠:未手动操作时,流式中展开、完成(出现答案)后自动收起(DeepSeek 形态)
|
||
const thinkingExpanded = reactive<Record<string, boolean>>({});
|
||
|
||
const isThinkingExpanded = (msg: ChatMessage): boolean =>
|
||
msg.id in thinkingExpanded ? thinkingExpanded[msg.id] : !msg.content;
|
||
|
||
const toggleThinking = (msg: ChatMessage) => {
|
||
thinkingExpanded[msg.id] = !isThinkingExpanded(msg);
|
||
};
|
||
|
||
// Markdown 渲染缓存(content 在作答完成后固定,避免重复解析)
|
||
const mdCache = new Map<string, string>();
|
||
const renderAnswer = (content: string): string => {
|
||
if (!mdCache.has(content)) mdCache.set(content, renderMarkdown(content));
|
||
return mdCache.get(content)!;
|
||
};
|
||
|
||
// 复制文本(剪贴板 API + 兼容回退)
|
||
const copyText = async (text: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
}
|
||
ElMessage.success('已复制');
|
||
};
|
||
|
||
// 代码块复制(事件委托:点击渲染出的 .code-copy-btn)
|
||
const handleAnswerClick = (e: MouseEvent) => {
|
||
const el = e.target as HTMLElement;
|
||
const btn = el.closest('.code-copy-btn');
|
||
if (!btn) return;
|
||
const code = btn.closest('.code-wrapper')?.querySelector('code');
|
||
if (code) copyText(code.textContent || '');
|
||
};
|
||
|
||
// 思考区折叠标题:完成(有答案)显示"已深度思考(用时 X 秒)",流式中显示"思考中…"
|
||
const thinkingTitle = (msg: ChatMessage): string => {
|
||
if (!msg.content) return '思考中…';
|
||
return msg.thinkingSeconds != null ? `已深度思考(用时 ${msg.thinkingSeconds} 秒)` : '已深度思考';
|
||
};
|
||
|
||
// autoLoading 标记本次 load-more 是否由"自动续载"触发:自动续载不保持位置(落在最新),用户上滑保持视口
|
||
let autoLoading = false;
|
||
|
||
// 消息新增或内容变化时自动滚动到底部;顶部加载更多期间不滚底。
|
||
// immediate:切换会话时 ChatList 会重建,挂载即定位到最新消息,避免停留在顶部旧消息。
|
||
// 首屏不满视口时自动续载更早记录,直到填满视口或没有更多(保证"上滑加载"入口始终可见)
|
||
watch(
|
||
() => props.messages.map((m) => `${m.id}|${m.content}|${m.thinking || ''}|${m.loading ? 1 : 0}`).join('\n'),
|
||
() => {
|
||
if (props.loadingMore) return;
|
||
nextTick(() => {
|
||
const el = chatListRef.value;
|
||
if (!el) return;
|
||
el.scrollTop = el.scrollHeight;
|
||
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
|
||
autoLoading = true;
|
||
emit('load-more');
|
||
}
|
||
});
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
// 顶部加载更多:开始加载时记录容器高度,完成后把滚动位置补回新增高度,保持视口内消息不跳动。
|
||
// 该 watch 注册在 messages watch 之后,其 nextTick 回调后执行,可覆盖同批的"滚到底"。
|
||
const prevScrollHeight = ref(0);
|
||
watch(
|
||
() => props.loadingMore,
|
||
(loading, prev) => {
|
||
if (loading && !prev) {
|
||
const el = chatListRef.value;
|
||
prevScrollHeight.value = autoLoading ? 0 : el && el.scrollHeight > el.clientHeight ? el.scrollHeight : 0;
|
||
} else if (!loading && prev) {
|
||
nextTick(() => {
|
||
const el = chatListRef.value;
|
||
if (!el) return;
|
||
if (prevScrollHeight.value) {
|
||
// 用户主动上滑加载更早:滚动位置补回新增高度,保持视口内消息不跳动
|
||
el.scrollTop = el.scrollHeight - prevScrollHeight.value;
|
||
prevScrollHeight.value = 0;
|
||
} else if (autoLoading) {
|
||
// 自动续载:加载期间滚底 watch 被 loadingMore 拦截(同批 loadingMore 仍为 true 先 flush),
|
||
// 恢复时须把 scrollTop 设回底部定位到最新消息;若仍不满屏则继续自动续载
|
||
el.scrollTop = el.scrollHeight;
|
||
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
|
||
emit('load-more');
|
||
} else {
|
||
autoLoading = false;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
);
|
||
|
||
// 滚动到顶部时触发加载更早记录(防重入由父组件 sessionLoadingMore 保证)
|
||
const handleScroll = (e: Event) => {
|
||
// 程序设置 scrollTop 触发的 scroll 事件(isTrusted=false)不参与交互判断:
|
||
// 自动续载/滚底时 scrollTop 不满屏被钳制为 0,会被误判为"用户上滑"而清空 autoLoading、破坏自动定位到最新
|
||
if (!e.isTrusted) return;
|
||
const el = chatListRef.value;
|
||
if (!el || !props.hasMore || props.loadingMore) return;
|
||
if (el.scrollTop <= 4) {
|
||
autoLoading = false;
|
||
emit('load-more');
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
const el = chatListRef.value;
|
||
// 挂载即定位到最新消息。immediate watch 的 nextTick 在 Transition 期间 DOM 未插入时执行(el 为 null)会失效,
|
||
// 这里在 DOM 已插入、内容已渲染(sh 已确定)后滚底;requestAnimationFrame 确保过渡与布局完成
|
||
requestAnimationFrame(() => {
|
||
if (el) el.scrollTop = el.scrollHeight;
|
||
});
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.chat-list {
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: flex-start;
|
||
padding: 8px 0 16px;
|
||
scrollbar-width: none;
|
||
&::-webkit-scrollbar { display: none; }
|
||
}
|
||
|
||
/* 顶部:加载更早记录提示条 */
|
||
.load-more-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
font-size: 11px;
|
||
color: #cbd5e1;
|
||
padding: 4px 0 10px;
|
||
}
|
||
|
||
.load-more-spinner {
|
||
width: 12px;
|
||
height: 12px;
|
||
border: 2px solid #e2e8f0;
|
||
border-top-color: #94a3b8;
|
||
border-radius: 50%;
|
||
animation: load-more-rotate 0.7s linear infinite;
|
||
}
|
||
|
||
@keyframes load-more-rotate {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.message-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
max-width: 100%;
|
||
|
||
&.is-user {
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
|
||
/* 节奏化消息间距:AI↔AI 24,用户↔AI 16 */
|
||
.message-row + .message-row {
|
||
margin-top: 16px;
|
||
}
|
||
.message-row:not(.is-user) + .message-row:not(.is-user) {
|
||
margin-top: 24px;
|
||
}
|
||
|
||
.bubble-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
max-width: 100%;
|
||
align-items: flex-start;
|
||
|
||
.message-row.is-user & {
|
||
align-items: flex-end;
|
||
/* 明确宽度基准:打断 fit-content 与百分比 max-width 的循环,避免气泡被压得过窄导致文字竖排 */
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
.bubble {
|
||
font-size: 15px;
|
||
line-height: 1.7;
|
||
padding: 0;
|
||
border-radius: 0;
|
||
word-break: break-word;
|
||
color: #1f2328;
|
||
background: transparent;
|
||
border: none;
|
||
box-shadow: none;
|
||
|
||
/* AI 消息:无背景卡片,纯文本左对齐(DeepSeek 式) */
|
||
.message-row:not(.is-user) & {
|
||
width: 100%;
|
||
max-width: 880px;
|
||
}
|
||
|
||
.message-row.is-user & {
|
||
width: fit-content;
|
||
max-width: 72%;
|
||
padding: 10px 16px;
|
||
border-radius: 18px 18px 6px 18px;
|
||
background: #e8ecff;
|
||
color: #1f2328;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.message-row.is-error & {
|
||
color: #dc2626;
|
||
}
|
||
}
|
||
|
||
/* AI 消息操作栏(hover 显示) */
|
||
.msg-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
|
||
.message-row:hover & {
|
||
opacity: 1;
|
||
}
|
||
/* 用户消息在右侧,操作栏右对齐 */
|
||
.message-row.is-user & {
|
||
align-self: flex-end;
|
||
}
|
||
}
|
||
|
||
.msg-action {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: #9ca3af;
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s;
|
||
|
||
&:hover {
|
||
background: #eef0f3;
|
||
color: #4b5563;
|
||
}
|
||
|
||
&.is-danger:hover {
|
||
background: #fef2f2;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.el-icon {
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
/* 失败重试 */
|
||
.retry-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
margin-top: 8px;
|
||
padding: 7px 14px;
|
||
border: 1px solid #fecaca;
|
||
border-radius: 10px;
|
||
background: #fef2f2;
|
||
color: #dc2626;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
|
||
&:hover {
|
||
background: #fee2e2;
|
||
border-color: #fca5a5;
|
||
}
|
||
|
||
.retry-icon {
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
}
|
||
}
|
||
|
||
/* 思考区:DeepSeek 风格折叠块 */
|
||
.thinking-block {
|
||
margin: 0 0 12px;
|
||
background: #f5f6f7;
|
||
border: 1px solid #eef0f3;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
max-width: 880px;
|
||
}
|
||
|
||
.thinking-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
width: 100%;
|
||
padding: 9px 14px;
|
||
border: none;
|
||
background: transparent;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #8b8b98;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
user-select: none;
|
||
transition: color 0.15s;
|
||
|
||
&:hover {
|
||
color: #6b7280;
|
||
}
|
||
}
|
||
|
||
.thinking-icon {
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.thinking-title {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.thinking-arrow {
|
||
margin-left: auto;
|
||
display: inline-flex;
|
||
font-size: 11px;
|
||
line-height: 1;
|
||
opacity: 0.7;
|
||
transition: transform 0.2s;
|
||
|
||
&.collapsed {
|
||
transform: rotate(-90deg);
|
||
}
|
||
}
|
||
|
||
.thinking-content {
|
||
padding: 0 14px 14px;
|
||
animation: thinking-fade-in 0.2s ease;
|
||
}
|
||
|
||
.thinking-text {
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
color: #9a9aab;
|
||
font-style: italic;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
border-top: 1px solid #eef0f3;
|
||
padding-top: 10px;
|
||
}
|
||
|
||
@keyframes thinking-fade-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-2px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
/* 回答区:Markdown 渲染(DeepSeek 风格排版) */
|
||
.answer-block {
|
||
white-space: normal;
|
||
line-height: 1.7;
|
||
font-size: 15px;
|
||
word-break: break-word;
|
||
max-width: 880px;
|
||
|
||
& > :first-child {
|
||
margin-top: 0;
|
||
}
|
||
& > :last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
:deep(p) {
|
||
margin: 0 0 12px;
|
||
}
|
||
|
||
:deep(h1),
|
||
:deep(h2),
|
||
:deep(h3),
|
||
:deep(h4),
|
||
:deep(h5),
|
||
:deep(h6) {
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
margin: 20px 0 10px;
|
||
color: #1f2328;
|
||
}
|
||
|
||
:deep(h1) {
|
||
font-size: 20px;
|
||
border-bottom: 1px solid #eaecef;
|
||
padding-bottom: 8px;
|
||
}
|
||
|
||
:deep(h2) {
|
||
font-size: 18px;
|
||
}
|
||
|
||
:deep(h3) {
|
||
font-size: 16px;
|
||
}
|
||
|
||
:deep(h4),
|
||
:deep(h5),
|
||
:deep(h6) {
|
||
font-size: 15px;
|
||
}
|
||
|
||
:deep(ul),
|
||
:deep(ol) {
|
||
margin: 0 0 12px;
|
||
padding-left: 22px;
|
||
}
|
||
|
||
:deep(li) {
|
||
margin: 3px 0;
|
||
}
|
||
|
||
:deep(li > ul),
|
||
:deep(li > ol) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
:deep(blockquote) {
|
||
margin: 0 0 12px;
|
||
padding: 6px 14px;
|
||
border-left: 4px solid #e0e0e0;
|
||
color: #6a737d;
|
||
background: #fafafa;
|
||
border-radius: 0 8px 8px 0;
|
||
}
|
||
|
||
:deep(code) {
|
||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||
font-size: 13px;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
background: #f2f3f5;
|
||
color: #c7254e;
|
||
}
|
||
|
||
:deep(.code-wrapper) {
|
||
position: relative;
|
||
margin: 0 0 14px;
|
||
|
||
&:hover .code-copy-btn {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
:deep(.code-copy-btn) {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
z-index: 2;
|
||
padding: 2px 8px;
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
border-radius: 6px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: #8b949e;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
transition: opacity 0.15s, background 0.15s;
|
||
|
||
&:hover {
|
||
background: rgba(255, 255, 255, 0.16);
|
||
color: #c9d1d9;
|
||
}
|
||
}
|
||
|
||
:deep(pre) {
|
||
margin: 0;
|
||
padding: 12px 14px;
|
||
border-radius: 10px;
|
||
background: #0d1117;
|
||
overflow-x: auto;
|
||
line-height: 1.6;
|
||
|
||
code {
|
||
display: block;
|
||
padding: 0;
|
||
background: transparent;
|
||
color: inherit;
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
:deep(table) {
|
||
width: 100%;
|
||
margin: 0 0 14px;
|
||
border-collapse: collapse;
|
||
font-size: 14px;
|
||
|
||
th,
|
||
td {
|
||
border: 1px solid #e2e8f0;
|
||
padding: 6px 12px;
|
||
text-align: left;
|
||
}
|
||
|
||
th {
|
||
background: #f5f7fa;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
:deep(a) {
|
||
color: #2563eb;
|
||
text-decoration: none;
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
|
||
:deep(hr) {
|
||
border: none;
|
||
border-top: 1px solid #eaecef;
|
||
margin: 16px 0;
|
||
}
|
||
|
||
:deep(img) {
|
||
max-width: 100%;
|
||
border-radius: 6px;
|
||
}
|
||
}
|
||
|
||
/* 执行中占位 */
|
||
.loading-text {
|
||
color: #94a3b8;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.loading-dots {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
margin-left: 6px;
|
||
|
||
i {
|
||
width: 4px;
|
||
height: 4px;
|
||
border-radius: 50%;
|
||
background: #94a3b8;
|
||
animation: dot-blink 1.2s infinite ease-in-out both;
|
||
|
||
&:nth-child(1) { animation-delay: 0s; }
|
||
&:nth-child(2) { animation-delay: 0.15s; }
|
||
&:nth-child(3) { animation-delay: 0.3s; }
|
||
}
|
||
}
|
||
|
||
@keyframes dot-blink {
|
||
0%, 80%, 100% { opacity: 0.2; }
|
||
40% { opacity: 1; }
|
||
}
|
||
|
||
.time {
|
||
font-size: 11px;
|
||
color: #8f9aae;
|
||
padding: 4px 2px;
|
||
|
||
.message-row.is-user & {
|
||
text-align: right;
|
||
color: rgba(148, 163, 184, 0.9);
|
||
}
|
||
.message-row:not(.is-user) & {
|
||
padding-left: 2px;
|
||
}
|
||
}
|
||
</style>
|