feat(mobile): rebuild practice session as WeChat-style chat (batch 2)

对练进行中(practiceView=active)改为微信式会话,复用问页同款形态;
预习(场景卡/最近练过/每日三题)与评分/满意度视图保持不变,
start/turn/finish/求助/风格重生成/幂等/红线/满意度脚本逻辑零改动。

ChatComposer:新增 panelItems 属性(自定义⊕面板项+panel事件),
sop 页默认面板不受影响。

practice/index.vue:
- 会话视图:固定铺满窗口区;头部=场景名+第N/3轮+情绪/信任细条+红线提示
- 业主台词=语音优先气泡(TTS预合成+时长+未读红点+转文字/收起,
  TTS不可用回退文本);我的回复右侧粉色气泡;老师傅提示/求助SOP=
  蓝色教练气泡(带播报/引用来源);提交中「业主回应中…」系统气泡
- 风格chips(再严肃/再温柔/更细致/更专业)横排贴输入条上方
- 输入条:按住说话松开即发送(转写→带audioOssId直接提交回合),
  键盘切换,⊕面板=选音频转文字/求助SOP/结束评分(结束评分随
  canFinishPractice置灰)
- 错题重说(红线)、退避回滚(提交失败恢复对话)原逻辑保留

验证:
- 本地全链路:开始练习→业主语音气泡6"+红点→转文字→键盘回复→
  老师傅提示+4风格chips→风格调整→⊕求助SOP气泡→第二轮→
  ⊕结束评分→评分74+老师傅建议+满意度,8张截图目检
- 生产冒烟:语音气泡+红点+按住说话+老师傅提示+风格chips全就位
- H5已发布(备份 h5-before-20260718052458);冲突表已更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 05:26:28 +08:00
co-authored by Claude Fable 5
parent 95dfd653ba
commit 9d7d621300
3 changed files with 575 additions and 81 deletions
@@ -91,4 +91,4 @@ Figma 已完成全文件结构和视觉复核,当前实测为 81 个顶层页
| 功能 | 客户要求 | Figma | 当前代码 | 线上状态 | 处理决定 |
| --- | --- | --- | --- | --- | --- |
| 语音文字交互形态 | 2026-07-18 客户原话:凡语音文字交互处应做成微信聊天式(消息流+按住说话),现表单式操作太复杂 | 表单式布局(输入框+按钮组) | 问页已改会话流:ChatComposer(⊕面板/按住说话松开即发/键盘切换)+气泡消息流,引用/总结卡/反馈/播报收入答案气泡操作行 | 已发布并生产冒烟通过(2026-07-18) | 按客户要求执行,客户原话 > Figma;练页为第二批,web-ai 页随批次二统一 |
| 语音文字交互形态 | 2026-07-18 客户原话:凡语音文字交互处应做成微信聊天式(消息流+按住说话),现表单式操作太复杂 | 表单式布局(输入框+按钮组) | 问页已改会话流:ChatComposer(⊕面板/按住说话松开即发/键盘切换)+气泡消息流,引用/总结卡/反馈/播报收入答案气泡操作行 | 已发布并生产冒烟通过(2026-07-18) | 按客户要求执行,客户原话 > Figma;2026-07-18 批次二完成:练页对练进行中改为同款会话(业主台词=语音优先气泡+红点+转文字,老师傅提示气泡,风格chips,⊕面板收纳选音频/求助/结束评分,松开即发送),预习/每日三题/评分视图保持;web-ai 页待批次三 |
+36 -14
View File
@@ -69,20 +69,34 @@
</view>
<view v-if="panelOpen" class="composer-panel">
<button class="panel-item" :disabled="disabled" @click="panelEmit('pick-media')">
<text class="panel-item-icon">🖼</text>
<text class="panel-item-label">图片/视频</text>
</button>
<button class="panel-item" :disabled="disabled || transcribing" @click="panelEmit('pick-audio')">
<text class="panel-item-icon">🎵</text>
<text class="panel-item-label">选音频转文字</text>
</button>
<button v-if="toolLabel" class="panel-item" :disabled="disabled" @click="panelEmit('tool')">
<text class="panel-item-icon">📊</text>
<text class="panel-item-label">{{ toolLabel }}</text>
</button>
<template v-if="panelItems.length">
<button
v-for="item in panelItems"
:key="item.key"
class="panel-item"
:disabled="disabled || item.disabled"
@click="panelAction(item.key)"
>
<text class="panel-item-icon">{{ item.icon }}</text>
<text class="panel-item-label">{{ item.label }}</text>
</button>
</template>
<template v-else>
<button class="panel-item" :disabled="disabled" @click="panelEmit('pick-media')">
<text class="panel-item-icon">🖼</text>
<text class="panel-item-label">图片/视频</text>
</button>
<button class="panel-item" :disabled="disabled || transcribing" @click="panelEmit('pick-audio')">
<text class="panel-item-icon">🎵</text>
<text class="panel-item-label">选音频转文字</text>
</button>
<button v-if="toolLabel" class="panel-item" :disabled="disabled" @click="panelEmit('tool')">
<text class="panel-item-icon">📊</text>
<text class="panel-item-label">{{ toolLabel }}</text>
</button>
</template>
</view>
<text v-if="panelOpen" class="panel-hint">图片≤10MB,视频≤100MB;仅用于本次现场分析</text>
<text v-if="panelOpen && !panelItems.length" class="panel-hint">图片≤10MB,视频≤100MB;仅用于本次现场分析</text>
</view>
</template>
@@ -98,6 +112,7 @@ const props = withDefaults(defineProps<{
mediaName?: string;
mediaKind?: string;
toolLabel?: string;
panelItems?: Array<{ key: string; icon: string; label: string; disabled?: boolean }>;
}>(), {
disabled: false,
transcribing: false,
@@ -106,7 +121,8 @@ const props = withDefaults(defineProps<{
quickQuestions: () => [],
mediaName: '',
mediaKind: '',
toolLabel: ''
toolLabel: '',
panelItems: () => []
});
const emit = defineEmits<{
@@ -117,6 +133,7 @@ const emit = defineEmits<{
(e: 'clear-media'): void;
(e: 'quick', question: string): void;
(e: 'tool'): void;
(e: 'panel', key: string): void;
}>();
const draft = ref('');
@@ -150,6 +167,11 @@ const panelEmit = (event: 'pick-media' | 'pick-audio' | 'tool') => {
emit(event);
};
const panelAction = (key: string) => {
panelOpen.value = false;
emit('panel', key);
};
const stopTracks = () => {
stream?.getTracks().forEach((track) => track.stop());
stream = null;
+538 -66
View File
@@ -155,46 +155,98 @@
<view><text>红线纠偏</text><text>识别与规避</text></view>
</view>
<view v-if="practiceView === 'active' && turns.length" class="panel dialogue-card employee-card">
<view class="dialogue-head">
<view class="dialogue-heading-block">
<text class="panel-title">对话</text>
<text class="dialogue-caption">保持承接情绪、说明动作、给出反馈节点。</text>
<view v-if="practiceView === 'active'" class="practice-chat">
<view class="chat-head">
<view class="chat-head-row">
<text class="chat-title">{{ activeScenarioName }}</text>
<text class="chat-context">第 {{ Math.min(roundIndex + 1, 3) }}/3 轮</text>
</view>
<text class="round-label">第 {{ Math.min(roundIndex + 1, 3) }} 轮</text>
</view>
<view v-if="hasCoachState" class="coach-state">
<view class="state-row">
<text>情绪 {{ emotionScore }}</text>
<view class="state-bar">
<view class="state-fill emotion-fill" :style="{ width: `${emotionScore}%` }" />
<view v-if="hasCoachState" class="coach-strip">
<view class="coach-metric">
<text class="coach-metric-label">情绪 {{ emotionScore }}</text>
<view class="state-bar"><view class="state-fill emotion-fill" :style="{ width: `${emotionScore}%` }" /></view>
</view>
<view class="coach-metric">
<text class="coach-metric-label">信任 {{ trustScore }}</text>
<view class="state-bar"><view class="state-fill trust-fill" :style="{ width: `${trustScore}%` }" /></view>
</view>
</view>
<view class="state-row">
<text>信任 {{ trustScore }}</text>
<view class="state-bar">
<view class="state-fill trust-fill" :style="{ width: `${trustScore}%` }" />
</view>
</view>
<text v-if="redFlagActive" class="red-flag">红线纠偏:先承接诉求,避免推责、绝对承诺或情绪对抗。</text>
<text v-if="redFlagActive" class="chat-red-flag">红线纠偏:先承接诉求,避免推责、绝对承诺或情绪对抗。</text>
</view>
<view class="turn-list">
<view v-for="(turn, index) in turns" :key="index" class="turn-row" :class="turn.role">
<view class="turn-heading">
<text class="turn-role">{{ roleLabel(turn.role) }}</text>
<button
v-if="turn.role !== 'trainee'"
class="turn-voice-button"
:disabled="speechState.status === 'loading' && speechState.key !== `turn-${index}`"
@click="speakGenerated(`turn-${index}`, turn.text)"
>
{{ speechButtonLabel(`turn-${index}`) }}
</button>
<scroll-view scroll-y class="chat-thread" :scroll-top="threadScrollTop" :scroll-with-animation="true">
<view class="thread-inner">
<template v-for="(turn, index) in turns" :key="index">
<view v-if="turn.role === 'customer'" class="bubble-row left">
<view class="avatar customer-avatar">业</view>
<view class="master-col">
<view v-if="turnVoice(index).status !== 'unavailable'" class="voice-line">
<view
class="bubble master voice-bubble"
:class="{ playing: isTurnPlaying(index) }"
@click="playTurnVoice(index, turn.text)"
>
<text class="voice-icon">{{ isTurnPlaying(index) ? '◼' : '🔊' }}</text>
<text class="voice-duration">{{ turnVoiceLabel(index) }}</text>
</view>
<view v-if="turnVoice(index).unread" class="voice-dot" />
<button class="transcript-toggle" @click="toggleTurnTranscript(index)">
{{ turnVoice(index).open ? '收起' : '转文字' }}
</button>
</view>
<view v-if="turnVoice(index).status === 'unavailable' || turnVoice(index).open" class="bubble master">
<text class="bubble-text">{{ turn.text }}</text>
</view>
</view>
</view>
<view v-else-if="turn.role === 'coach'" class="bubble-row left">
<view class="avatar coach-avatar">傅</view>
<view class="bubble coach">
<text class="coach-label">老师傅提示</text>
<text class="bubble-text">{{ turn.text }}</text>
<view class="bubble-actions">
<button
class="bubble-action"
:disabled="speechState.status === 'loading' && speechState.key !== `turn-${index}`"
@click="speakGenerated(`turn-${index}`, turn.text)"
>
{{ speechButtonLabel(`turn-${index}`) }}
</button>
</view>
</view>
</view>
<view v-else class="bubble-row right">
<view class="bubble user">
<text class="bubble-text">{{ turn.text }}</text>
</view>
</view>
</template>
<view v-if="helpAnswer" class="bubble-row left">
<view class="avatar coach-avatar">傅</view>
<view class="bubble coach">
<text class="coach-label">SOP 提示</text>
<text class="bubble-text">{{ helpAnswer }}</text>
<view v-if="helpRefs.length" class="help-ref-list">
<text v-for="item in helpRefs" :key="item" class="citation-meta">📄 {{ item }}</text>
</view>
</view>
</view>
<text class="turn-text">{{ turn.text }}</text>
<view v-if="status === 'submitting'" class="system-row">
<text class="system-text">业主回应中…</text>
</view>
<view v-if="message" class="system-row">
<text class="system-text">{{ message }}</text>
</view>
<view :style="{ height: '12px' }" />
</view>
</view>
<view v-if="canAdjustCoach" class="style-actions">
</scroll-view>
<view v-if="canAdjustCoach" class="style-chip-row">
<button
v-for="item in styleOptions"
:key="item.value"
@@ -205,41 +257,21 @@
{{ adjustingStyle === item.value ? '调整中' : item.label }}
</button>
</view>
</view>
<view v-if="status === 'active' || status === 'submitting'" class="panel reply-card employee-card">
<text class="panel-title">你的回应</text>
<textarea
v-model="draft"
class="textarea practice-textarea"
maxlength="500"
<ChatComposer
ref="composerRef"
:disabled="busy"
:transcribing="transcribing"
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
:maxlength="500"
:panel-items="practicePanelItems"
@send="handleComposerSend"
@voice-blob="handleSessionVoiceBlob"
@pick-audio="composerPickAudio"
@panel="handlePanelAction"
/>
<view class="reply-actions">
<button class="secondary-button" :class="{ 'recording-active': recording }" :disabled="busy || transcribing" @click="() => toggleRecording()">
{{ recording ? '停止录音' : '录音转写' }}
</button>
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="() => pickAudio()">
{{ transcribing ? '转写中' : '选音频' }}
</button>
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
{{ helpLoading ? '求助中' : '求助' }}
</button>
<button class="secondary-button" :disabled="busy || !canFinishPractice" @click="finish()">结束评分</button>
<button class="primary-button" :disabled="busy" @click="submit">
{{ status === 'submitting' ? '提交中' : '提交回应' }}
</button>
</view>
<text v-if="draftAudioUrl" class="voice-state">已附带语音回放</text>
</view>
<view v-if="practiceView === 'active' && (helpAnswer || helpRefs.length)" class="panel help-card employee-card">
<text class="panel-title">SOP 提示</text>
<text v-if="helpAnswer" class="help-answer">{{ helpAnswer }}</text>
<view v-if="helpRefs.length" class="help-refs">
<text v-for="item in helpRefs" :key="item" class="help-ref">{{ item }}</text>
</view>
</view>
<view v-if="result" class="panel result-card employee-card">
<view class="result-title-row">
@@ -307,8 +339,9 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import ChatComposer from '@/components/chat/ChatComposer.vue';
import type {
PracticeFinishResponse,
PracticePrepCard,
@@ -323,7 +356,7 @@ import { getSelectedPosition, isLoggedIn, rememberLoginRedirect } from '@/servic
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
@@ -465,6 +498,156 @@ const speakGenerated = (key: string, text?: string) => {
void speechPlayback.toggle(key, text || '');
};
/* ===== chat-session presentation (WeChat-style, batch 2) ===== */
const composerRef = ref<InstanceType<typeof ChatComposer> | null>(null);
const threadScrollTop = ref(0);
let threadScrollSeq = 0;
const scrollThreadToBottom = () => {
void nextTick(() => {
threadScrollSeq += 200;
threadScrollTop.value = 100000 + threadScrollSeq;
});
};
interface TurnVoice {
status: 'preparing' | 'ready' | 'unavailable';
duration: number | null;
unread: boolean;
open: boolean;
}
const turnVoices = ref<Record<number, TurnVoice>>({});
const turnVoice = (index: number): TurnVoice =>
turnVoices.value[index] || { status: 'unavailable', duration: null, unread: false, open: false };
const patchTurnVoice = (index: number, patch: Partial<TurnVoice>) => {
turnVoices.value = { ...turnVoices.value, [index]: { ...turnVoice(index), ...patch } };
};
/** Customer lines arrive as WeChat-style voice messages: pre-synthesize so the
* bubble shows a duration and an unread dot; fall back to text when TTS is off. */
const prepareTurnVoice = async (index: number, text: string) => {
const spoken = (text || '').trim();
if (!spoken) return;
patchTurnVoice(index, { status: 'preparing', duration: null, unread: true, open: false });
const source = await speechPlayback.preload(spoken);
if (!turnVoices.value[index]) return;
if (!source) {
patchTurnVoice(index, { status: 'unavailable', unread: false });
return;
}
const duration = await measureAudioDuration(source);
patchTurnVoice(index, { status: 'ready', duration });
};
const isTurnPlaying = (index: number) =>
speechState.value.key === `turn-${index}` && speechState.value.status === 'playing';
const turnVoiceLabel = (index: number) => {
if (speechState.value.key === `turn-${index}` && speechState.value.status === 'loading') return '…';
const voice = turnVoice(index);
if (voice.status === 'preparing') return '…';
return voice.duration ? `${Math.min(voice.duration, 60)}"` : '语音';
};
const playTurnVoice = (index: number, text: string) => {
if (turnVoice(index).status === 'preparing') {
uni.showToast({ title: '语音生成中,请稍候', icon: 'none' });
return;
}
patchTurnVoice(index, { unread: false });
void speechPlayback.toggle(`turn-${index}`, (text || '').trim());
};
const toggleTurnTranscript = (index: number) => {
patchTurnVoice(index, { open: !turnVoice(index).open });
};
const practicePanelItems = computed(() => [
{ key: 'audio', icon: '🎵', label: '选音频转文字', disabled: transcribing.value },
{ key: 'help', icon: '🧭', label: helpLoading.value ? '求助中…' : '求助 SOP', disabled: helpLoading.value },
{ key: 'finish', icon: '🏁', label: '结束评分', disabled: !canFinishPractice.value }
]);
const handlePanelAction = (key: string) => {
if (key === 'audio') {
void composerPickAudio();
} else if (key === 'help') {
void loadHelp();
} else if (key === 'finish') {
void finish();
}
};
const handleComposerSend = (text: string) => {
draft.value = text;
void submit();
};
const submitTranscribed = async (text: string, audioUrl: string, ossId?: string) => {
draft.value = text;
draftAudioUrl.value = audioUrl;
draftAudioOssId.value = ossId;
await submit();
};
const handleSessionVoiceBlob = async (blob: Blob) => {
if (transcribing.value || busy.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
try {
const asr = await transcribeSpeechBlob(blob, 'practice-turn.webm', (abort) => {
if (generation === transcriptionGeneration) abortTranscription = abort;
});
if (generation !== transcriptionGeneration) return;
const text = (asr.text || '').trim();
if (!text) {
uni.showToast({ title: '语音没有识别到文字,请再说一遍', icon: 'none' });
return;
}
await submitTranscribed(text, asr.audioUrl || '', normalizeOssId(asr.ossId));
} catch (error) {
if (generation !== transcriptionGeneration) return;
uni.showToast({ title: error instanceof Error ? error.message : '语音转写失败,请改用文字输入', icon: 'none' });
} finally {
if (generation === transcriptionGeneration) {
abortTranscription = null;
transcribing.value = false;
}
}
};
const composerPickAudio = async () => {
if (busy.value || transcribing.value) return;
const generation = ++transcriptionGeneration;
transcribing.value = true;
try {
const file = await chooseSpeechAudio();
if (generation !== transcriptionGeneration) return;
const asr = await transcribeSpeechFile(file, (abort) => {
if (generation === transcriptionGeneration) abortTranscription = abort;
});
if (generation !== transcriptionGeneration) return;
const text = (asr.text || '').trim();
if (!text) {
uni.showToast({ title: '语音没有识别到文字,请再试一次', icon: 'none' });
return;
}
await submitTranscribed(text, asr.audioUrl || '', normalizeOssId(asr.ossId));
} catch (error) {
if (generation !== transcriptionGeneration) return;
const text = error instanceof Error ? error.message : '语音转写失败,请改用文字输入';
if (text !== '未选择音频') uni.showToast({ title: text, icon: 'none' });
} finally {
if (generation === transcriptionGeneration) {
abortTranscription = null;
transcribing.value = false;
}
}
};
const dailyFeedbackSpeech = (item: PracticeAssignment) => [
typeof item.score === 'number' ? `本题得分${item.score}分` : '',
item.feedback ? `点评,${item.feedback}` : '',
@@ -558,6 +741,10 @@ const requireLogin = () => {
const appendTurn = (role: PracticeRole, text?: string) => {
if (!text) return;
turns.value.push({ role, text });
if (role === 'customer') {
void prepareTurnVoice(turns.value.length - 1, text);
}
scrollThreadToBottom();
};
const clearDraftAudio = () => {
@@ -928,6 +1115,8 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
satisfactionComment.value = '';
satisfactionSubmitted.value = false;
turns.value = [];
turnVoices.value = {};
threadScrollTop.value = 0;
draft.value = '';
clearDraftAudio();
helpAnswer.value = '';
@@ -2346,4 +2535,287 @@ onUnload(() => {
.result-voice-button {
flex: none;
}
/* ===== chat session (WeChat-style, batch 2) ===== */
.practice-chat {
position: fixed;
top: var(--window-top, 0px);
right: 0;
bottom: var(--window-bottom, 0px);
left: 0;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0;
background: #f2f3f5;
overflow: hidden;
z-index: 10;
}
.practice-chat .chat-head {
flex-shrink: 0;
padding: 8px 12px 8px;
background: #ffffff;
border-bottom: 1px solid #ebedf0;
}
.chat-head-row {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.chat-title {
color: #18202b;
font-size: 16px;
font-weight: 800;
}
.chat-context {
color: #8a93a0;
font-size: 12px;
}
.coach-strip {
display: flex;
gap: 14px;
margin-top: 8px;
}
.coach-metric {
flex: 1;
}
.coach-metric-label {
display: block;
margin-bottom: 3px;
color: #5a6270;
font-size: 11px;
}
.chat-red-flag {
display: block;
margin-top: 7px;
color: #bd0010;
font-size: 12px;
}
.chat-thread {
flex: 1;
min-height: 0;
}
.thread-inner {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 12px 6px;
}
.bubble-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.bubble-row.right {
justify-content: flex-end;
}
.avatar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
font-size: 15px;
font-weight: 700;
}
.customer-avatar {
background: #e70014;
color: #ffffff;
}
.coach-avatar {
background: #1f5c9d;
color: #ffffff;
}
.bubble {
max-width: 82%;
padding: 10px 12px;
border-radius: 12px;
word-break: break-word;
}
.bubble.master {
background: #ffffff;
border-top-left-radius: 4px;
box-shadow: 0 1px 3px rgba(20, 24, 31, 0.06);
}
.bubble.coach {
background: #eef4fb;
border-top-left-radius: 4px;
}
.bubble.user {
background: #ffd8dc;
border-top-right-radius: 4px;
}
.coach-label {
display: block;
margin-bottom: 4px;
color: #1f5c9d;
font-size: 11px;
font-weight: 700;
}
.bubble-text {
display: block;
color: #232a33;
font-size: 15px;
line-height: 1.55;
white-space: pre-wrap;
}
.master-col {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
max-width: 82%;
}
.master-col .bubble {
max-width: 100%;
}
.voice-line {
display: flex;
align-items: center;
gap: 8px;
}
.voice-bubble {
display: flex;
align-items: center;
gap: 8px;
min-width: 96px;
padding: 11px 14px;
cursor: pointer;
}
.voice-bubble.playing {
background: #ffe9eb;
}
.voice-icon {
font-size: 15px;
line-height: 1;
}
.voice-duration {
color: #232a33;
font-size: 15px;
font-weight: 600;
}
.voice-dot {
flex-shrink: 0;
width: 9px;
height: 9px;
border-radius: 50%;
background: #f43b30;
}
.transcript-toggle {
flex-shrink: 0;
margin: 0;
padding: 4px 12px;
border: none;
border-radius: 8px;
background: rgba(20, 24, 31, 0.06);
color: #6b7480;
font-size: 12px;
line-height: 1.5;
}
.bubble-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.bubble-action {
margin: 0;
padding: 4px 12px;
border: 1px solid #d9e4f0;
border-radius: 999px;
background: #ffffff;
color: #38424e;
font-size: 12px;
line-height: 1.5;
}
.help-ref-list {
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 6px;
}
.citation-meta {
color: #6b7480;
font-size: 11px;
}
.system-row {
display: flex;
justify-content: center;
padding: 2px 0;
}
.system-text {
max-width: 86%;
padding: 4px 12px;
border-radius: 999px;
background: rgba(20, 24, 31, 0.06);
color: #6b7480;
font-size: 12px;
text-align: center;
line-height: 1.5;
}
.style-chip-row {
display: flex;
gap: 8px;
padding: 8px 10px 0;
background: #f7f8fa;
border-top: 1px solid #ebedf0;
overflow-x: auto;
}
.style-chip-row .chip {
flex-shrink: 0;
margin: 0;
padding: 5px 12px;
border: 1px solid #cddcec;
border-radius: 999px;
background: #ffffff;
color: #1f5c9d;
font-size: 13px;
line-height: 1.4;
}
.style-chip-row + .chat-composer-wrap,
.practice-chat .chat-composer-wrap {
border-top: none;
}
</style>