feat(aihr): on-demand summary card with infographic bottom sheet

P0-2 按甲方钦定交互落地(反馈#2:显式按钮+小窗+预期等待):新端点
POST /api/knowledge/summary-card 复用检索命中片段调 chat 模型
(temperature=0,30s超时)生成结构化卡{title,steps,objections,scripts,
reminders},无命中/无模型/失败均明确报错可重试,不兜假数据;检索
底层拆出 dbHits() 避免总结卡重复触发轻概要 LLM。移动端:概要卡下
「✨生成总结卡·约10秒生成」按钮→底部弹层(拖条+X+深遮罩),四区块
信息图排版(蓝序号四步法/橙块异议/绿引用话术/✓提醒)对照设计基准,
430px 两列 375px 单列;听一遍(独立 TTS 状态)+开始跟读(问练焊接);
SOP 面板配色统一蓝色系。「保存图片」需 html2canvas 依赖本轮不做。
实测:端点4s返回结构化卡;浏览器弹层与 summary-modal 设计图逐区块
一致。
This commit is contained in:
2026-07-05 03:21:37 +08:00
parent 9af439dc2a
commit 617847f7e0
4 changed files with 773 additions and 6 deletions
@@ -7,6 +7,8 @@ import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse;
import org.dromara.aihr.domain.AihrSopDto.ProcessingOverviewResponse;
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
import org.dromara.aihr.domain.AihrSopDto.UploadEnqueueResponse;
import org.dromara.aihr.domain.AihrSopDto.UploadItemResponse;
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
@@ -61,6 +63,11 @@ public class AihrSopController {
return R.ok(sopSeedService.search(request));
}
@PostMapping("/summary-card")
public R<SummaryCardResponse> summaryCard(@RequestBody SummaryCardRequest request) {
return R.ok(sopSeedService.summaryCard(request));
}
@PostMapping("/doc/upload")
public R<UploadResponse> uploadDoc(@RequestPart("file") MultipartFile file, @RequestParam("category") String category) {
return R.ok(sopSeedService.uploadDoc(file, category));
@@ -10,6 +10,18 @@ public final class AihrSopDto {
public record SearchRequest(String queryText, String category, Integer limit) {
}
public record SummaryCardRequest(String queryText, String category) {
}
public record SummaryCardResponse(String title, List<CardStep> steps, List<CardObjection> objections, List<String> scripts, List<String> reminders) {
}
public record CardStep(String label, String desc) {
}
public record CardObjection(String concern, String response) {
}
public record UploadResponse(String docId, Long ossId, String fileName, String category, Integer fragments, String summary, List<String> tags, List<SnippetResponse> snippets) {
}
@@ -6,6 +6,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrSopDto.CategoryStatResponse;
import org.dromara.aihr.domain.AihrSopDto.CardObjection;
import org.dromara.aihr.domain.AihrSopDto.CardStep;
import org.dromara.aihr.domain.AihrSopDto.DocResponse;
import org.dromara.aihr.domain.AihrSopDto.LocalImportItemResponse;
import org.dromara.aihr.domain.AihrSopDto.LocalImportRequest;
@@ -21,6 +23,8 @@ import org.dromara.aihr.domain.AihrSopDto.RecordResponse;
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
@@ -129,6 +133,55 @@ public class AihrSopSeedService {
);
}
public SummaryCardResponse summaryCard(SummaryCardRequest request) {
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
if (isBlank(queryText)) {
throw new ServiceException("请先输入问题");
}
String category = request == null ? "sop" : firstNonBlank(request.category(), "sop");
List<KnowledgeHit> hits;
try {
hits = dbHits(dbCategory(category), queryText, 5);
} catch (DataAccessException e) {
hits = List.of();
}
if (hits.isEmpty()) {
throw new ServiceException("知识库中未检索到相关内容");
}
Optional<ChatRuntime> runtime = chatRuntime();
if (runtime.isEmpty()) {
throw new ServiceException("未配置对话模型,无法生成总结卡");
}
StringBuilder context = new StringBuilder();
int index = 1;
for (KnowledgeHit hit : hits.stream().limit(5).toList()) {
context.append("片段").append(index++).append("(来源:").append(hit.title()).append("):\n")
.append(truncate(hit.content(), 1500)).append("\n\n");
}
try {
String content = callSummaryCardModel(runtime.get(), queryText, context.toString());
JsonNode root = objectMapper.readTree(jsonObjectText(content));
String title = truncate(root.path("title").asText("").strip(), 14);
List<CardStep> steps = jsonCardSteps(root.path("steps"));
if (isBlank(title) || steps.isEmpty()) {
throw new IllegalStateException("summary card missing title or steps");
}
return new SummaryCardResponse(
title,
steps,
jsonCardObjections(root.path("objections")),
jsonStrings(root.path("scripts"), 3, 90),
jsonStrings(root.path("reminders"), 5, 20)
);
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.warn("summary card failed: {}", e.getMessage());
throw new ServiceException("总结卡生成失败,请稍后重试");
}
}
public UploadResponse uploadDoc(MultipartFile file, String category) {
if (file == null || file.isEmpty()) {
throw new ServiceException("上传文件不能为空");
@@ -1567,10 +1620,7 @@ public class AihrSopSeedService {
private SearchResponse dbSearch(String category, String queryText, Integer limit) {
try {
int limitValue = limitValue(limit);
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue);
List<KnowledgeHit> hits = rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue));
List<KnowledgeHit> hits = dbHits(category, queryText, limit);
if (hits.isEmpty()) {
return null;
}
@@ -1580,6 +1630,13 @@ public class AihrSopSeedService {
}
}
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit) {
int limitValue = limitValue(limit);
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue);
return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue));
}
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit) {
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
try {
@@ -1831,6 +1888,54 @@ public class AihrSopSeedService {
return choices.get(0).path("message").path("content").asText();
}
private String callSummaryCardModel(ChatRuntime runtime, String queryText, String context) throws Exception {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName());
body.put("temperature", 0);
body.put("stream", false);
ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject();
system.put("role", "system");
system.put("content", """
你是物业一线员工的SOP信息图总结助手。只返回 JSON,不要 markdown、代码块或解释。
输出格式固定为:
{"title":"XX总结卡","steps":[{"label":"步骤名","desc":"一句话"}],"objections":[{"concern":"异议","response":"应对"}],"scripts":["可直接开口的完整话术"],"reminders":["注意事项"]}
字段约束:title ≤14字;steps 3-5条,label ≤6字,desc ≤20字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。
硬约束:只能基于给定片段内容归纳,不得编造片段中没有的流程、时限、承诺、金额、政策或话术。
""");
ObjectNode user = messages.addObject();
user.put("role", "user");
user.put("content", """
员工的问题:%s
检索命中的知识片段:
%s
""".formatted(queryText, context));
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
.timeout(Duration.ofSeconds(30))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
if (!isBlank(runtime.apiKey())) {
builder.header("Authorization", "Bearer " + runtime.apiKey());
}
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build()
.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (!ok(response.statusCode())) {
throw new IllegalStateException("summary card HTTP " + response.statusCode());
}
JsonNode choices = objectMapper.readTree(response.body()).path("choices");
if (!choices.isArray() || choices.isEmpty()) {
throw new IllegalStateException("summary card response missing choices");
}
return choices.get(0).path("message").path("content").asText();
}
private static List<String> jsonStrings(JsonNode node, int maxItems, int maxChars) {
if (!node.isArray()) {
return List.of();
@@ -1848,6 +1953,42 @@ public class AihrSopSeedService {
return List.copyOf(values);
}
private static List<CardStep> jsonCardSteps(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
List<CardStep> values = new ArrayList<>();
for (JsonNode item : node) {
String label = item.path("label").asText("").strip();
String desc = item.path("desc").asText("").strip();
if (!label.isEmpty() && !desc.isEmpty()) {
values.add(new CardStep(truncate(label, 6), truncate(desc, 20)));
}
if (values.size() >= 5) {
break;
}
}
return List.copyOf(values);
}
private static List<CardObjection> jsonCardObjections(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
List<CardObjection> values = new ArrayList<>();
for (JsonNode item : node) {
String concern = item.path("concern").asText("").strip();
String response = item.path("response").asText("").strip();
if (!concern.isEmpty() && !response.isEmpty()) {
values.add(new CardObjection(truncate(concern, 8), truncate(response, 20)));
}
if (values.size() >= 4) {
break;
}
}
return List.copyOf(values);
}
private record SearchDigest(String answer, List<String> keyPoints, List<String> cautions) {
}
+609 -2
View File
@@ -827,6 +827,13 @@
<p v-for="item in sopResult.cautions" :key="item">⚠ {{ item }}</p>
</div>
<p v-if="sopResult.reference" class="digest-reference">{{ sopResult.reference }}</p>
<div class="summary-card-entry">
<button class="summary-card-trigger" type="button" :disabled="summaryCardLoading" @click="generateSummaryCard">
<span>{{ summaryCardLoading ? '生成中…' : '✨ 生成总结卡' }}</span>
<small>约10秒生成</small>
</button>
<em v-if="summaryCardMessage">{{ summaryCardMessage }}</em>
</div>
</div>
<div v-if="sopResult.snippets?.length" class="snippet-list">
@@ -850,6 +857,66 @@
</div>
</section>
<section v-if="summaryCardOpen && summaryCard" class="summary-card-mask" aria-label="总结卡弹层" @click.self="closeSummaryCard">
<article class="summary-card-sheet" role="dialog" aria-modal="true">
<span class="summary-card-handle" aria-hidden="true"></span>
<button class="summary-card-close" type="button" aria-label="关闭总结卡" @click="closeSummaryCard">×</button>
<header class="summary-card-head">
<h2>{{ summaryCard.title }}</h2>
<span>由AI生成</span>
</header>
<div class="summary-card-grid">
<section class="summary-card-block is-steps">
<h3>沟通四步法</h3>
<ol>
<li v-for="(step, index) in summaryCard.steps" :key="`${step.label}-${index}`">
<em>{{ index + 1 }}</em>
<span>
<b>{{ step.label }}</b>
<small>{{ step.desc }}</small>
</span>
</li>
</ol>
</section>
<section class="summary-card-block is-objections">
<h3>高频异议应对</h3>
<div class="objection-list">
<p v-for="(item, index) in summaryCard.objections" :key="`${item.concern}-${index}`">
<b>{{ item.concern }}</b>
<span>{{ item.response }}</span>
</p>
</div>
</section>
<section class="summary-card-block is-scripts">
<h3>开口话术(可直接使用)</h3>
<blockquote v-for="(script, index) in summaryCard.scripts" :key="`${script}-${index}`">{{ script }}</blockquote>
</section>
<section class="summary-card-block is-reminders">
<h3>关键提醒</h3>
<ul>
<li v-for="(item, index) in summaryCard.reminders" :key="`${item}-${index}`">{{ item }}</li>
</ul>
</section>
</div>
<p v-if="summaryCardMessage" class="summary-card-inline-message">{{ summaryCardMessage }}</p>
<footer class="summary-card-footer">
<div class="summary-card-footer-actions">
<button class="listen" type="button" :disabled="cardSpeakLoading" @click="toggleSummaryCardSpeak">
{{ cardSpeaking ? '停止播放' : cardSpeakLoading ? '合成中' : '🔊 听一遍' }}
</button>
<button class="practice" type="button" @click="startPracticeFromSummaryCard">🎤 开始跟读</button>
</div>
<p>总结卡由 AI 基于检索结果生成,请结合实际情况灵活使用</p>
</footer>
</article>
</section>
<div v-if="toastMessage" class="build-toast" role="status" aria-live="polite">{{ toastMessage }}</div>
</main>
</template>
@@ -1116,6 +1183,24 @@ interface KnowledgeSearchResponse {
cautions?: string[];
}
interface SummaryCardStep {
label: string;
desc: string;
}
interface SummaryCardObjection {
concern: string;
response: string;
}
interface SummaryCardResponse {
title: string;
steps: SummaryCardStep[];
objections: SummaryCardObjection[];
scripts: string[];
reminders: string[];
}
interface CaseTag {
text: string;
type: string;
@@ -1301,7 +1386,14 @@ const sopSnippetsOpen = ref(false);
const expandedSnippets = ref(new Set<number>());
const sopSpeaking = ref(false);
const sopSpeakLoading = ref(false);
const summaryCard = ref<SummaryCardResponse | null>(null);
const summaryCardOpen = ref(false);
const summaryCardLoading = ref(false);
const summaryCardMessage = ref('');
const cardSpeaking = ref(false);
const cardSpeakLoading = ref(false);
let sopAudio: HTMLAudioElement | null = null;
let cardAudio: HTMLAudioElement | null = null;
const materialFile = ref<File | null>(null);
const materialUploading = ref(false);
const materialMessage = ref('');
@@ -1593,15 +1685,30 @@ const openSopPanel = () => {
sopQuery.value = '';
sopResult.value = null;
sopMessage.value = '';
summaryCard.value = null;
summaryCardMessage.value = '';
summaryCardOpen.value = false;
navigatePath(`${rolePaths[roleKey.value]}/sop`);
};
const stopSummaryCardSpeak = () => {
cardAudio?.pause();
cardAudio = null;
cardSpeaking.value = false;
};
const closeSummaryCard = () => {
stopSummaryCardSpeak();
summaryCardOpen.value = false;
};
const closeSopPanel = () => {
if (sopRecording.value) {
sopAutoSearchAfterTranscribe = false;
sopMediaRecorder?.stop();
}
stopSopSpeak();
closeSummaryCard();
sopPanelOpen.value = false;
activeTabLabel.value = sopReturnTabLabel.value;
navigatePath(sopReturnPath.value || rolePaths[roleKey.value]);
@@ -1740,6 +1847,76 @@ const toggleSopSpeak = async () => {
}
};
const generateSummaryCard = async () => {
const queryText = (sopQuery.value || sopResult.value?.queryText || '').trim();
if (!sopResult.value || !queryText) return;
summaryCardLoading.value = true;
summaryCardMessage.value = '';
closeSummaryCard();
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 35000);
try {
summaryCard.value = await readApi<SummaryCardResponse>(
await fetch('/dev-api/api/knowledge/summary-card', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ queryText, category: 'sop' }),
signal: controller.signal
})
);
summaryCardOpen.value = true;
} catch (error) {
summaryCardMessage.value =
error instanceof DOMException && error.name === 'AbortError'
? '总结卡生成超时,请稍后重试'
: error instanceof Error
? error.message
: '总结卡生成失败,请稍后重试';
} finally {
window.clearTimeout(timeout);
summaryCardLoading.value = false;
}
};
const toggleSummaryCardSpeak = async () => {
if (cardSpeaking.value) {
stopSummaryCardSpeak();
return;
}
const card = summaryCard.value;
if (!card) return;
const speech = [
card.title,
...card.steps.map((step, index) => `${index + 1}、${step.label},${step.desc}`),
...card.reminders.map((item) => `提醒,${item}`)
].join('。').slice(0, 280);
cardSpeakLoading.value = true;
try {
const data = await readApi<{ audioUrl: string }>(
await fetch('/dev-api/api/ai/tts', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ text: speech, voice: '' })
})
);
if (!summaryCardOpen.value) return;
cardAudio = new Audio(data.audioUrl);
cardAudio.onended = () => stopSummaryCardSpeak();
cardSpeaking.value = true;
void cardAudio.play().catch(() => stopSummaryCardSpeak());
} catch {
summaryCardMessage.value = '语音播报暂不可用(需启用 tts 模型)';
} finally {
cardSpeakLoading.value = false;
}
};
const startPracticeFromSummaryCard = () => {
closeSummaryCard();
closeSopPanel();
openUserPracticePanel();
};
const searchSopKnowledge = async () => {
const cleaned = sopQuery.value.trim();
if (!cleaned) {
@@ -1752,7 +1929,11 @@ const searchSopKnowledge = async () => {
sopResult.value = null;
sopSnippetsOpen.value = false;
expandedSnippets.value = new Set();
summaryCard.value = null;
summaryCardMessage.value = '';
summaryCardOpen.value = false;
stopSopSpeak();
stopSummaryCardSpeak();
const controller = new AbortController();
// 概要由 chat 模型生成,比纯检索多几秒,超时放宽到 25s
const timeout = window.setTimeout(() => controller.abort(), 25000);
@@ -2801,6 +2982,7 @@ const applyRouteFromLocation = () => {
reviewMessage.value = '';
userReviewMessage.value = '';
sopPanelOpen.value = false;
closeSummaryCard();
if (route.role === 'user') {
if (route.page === 'training') {
@@ -3053,6 +3235,8 @@ watch(
onBeforeUnmount(() => {
setMentorScrollListening(false);
cleanupMentorVoicePress();
stopSopSpeak();
stopSummaryCardSpeak();
window.removeEventListener('popstate', applyRouteFromLocation);
});
const ringStyle = (value: number) => ({ '--value': value } as Record<string, string | number>);
@@ -5185,7 +5369,7 @@ p {
justify-content: center;
overflow-y: auto;
background:
radial-gradient(circle at 10% 20%, rgba(15, 174, 166, 0.07) 0%, transparent 40%),
radial-gradient(circle at 10% 20%, rgba(46, 107, 230, 0.08) 0%, transparent 40%),
radial-gradient(circle at 90% 10%, rgba(56, 132, 255, 0.07) 0%, transparent 35%),
linear-gradient(180deg, #f5f8fa 0%, #fcfdfe 100%);
}
@@ -5293,6 +5477,428 @@ p {
box-shadow: var(--shadow-md);
}
.sop-panel .sop-back {
color: #1c56c6;
background: rgba(46, 107, 230, 0.1);
}
.sop-panel .sop-form input {
border-color: rgba(46, 107, 230, 0.2);
}
.sop-panel .sop-form input:focus {
border-color: #2e6be6;
box-shadow: 0 0 0 3px rgba(46, 107, 230, 0.14);
}
.sop-panel .sop-voice,
.sop-panel .digest-speak,
.sop-panel .snippet-toggle,
.sop-panel .snippet-list blockquote em,
.sop-panel .panel-message {
color: #1c56c6;
}
.sop-panel .sop-voice {
background: rgba(46, 107, 230, 0.1);
border-color: rgba(46, 107, 230, 0.16);
}
.sop-panel .sop-submit {
background: linear-gradient(135deg, #2e6be6, #0f58d6);
box-shadow: 0 4px 12px rgba(46, 107, 230, 0.18);
}
.sop-panel .digest-card {
border-color: rgba(46, 107, 230, 0.24);
background: linear-gradient(160deg, rgba(46, 107, 230, 0.09), rgba(46, 107, 230, 0.02));
}
.sop-panel .digest-speak {
border-color: rgba(46, 107, 230, 0.36);
}
.sop-panel .snippet-toggle {
border-color: rgba(46, 107, 230, 0.35);
}
.summary-card-entry {
margin-top: 12px;
display: grid;
gap: 7px;
}
.summary-card-trigger {
width: 100%;
min-height: 58px;
border-radius: 22px;
color: #ffffff;
background: linear-gradient(135deg, #2e6be6, #0b55d8);
box-shadow: 0 10px 24px rgba(46, 107, 230, 0.26);
display: grid;
place-items: center;
gap: 2px;
font-weight: 900;
}
.summary-card-trigger span {
font-size: 16px;
line-height: 1.1;
}
.summary-card-trigger small {
font-size: 12px;
line-height: 1;
opacity: 0.82;
}
.summary-card-trigger:disabled {
opacity: 0.62;
box-shadow: none;
}
.summary-card-entry em {
color: #c25712;
font-size: 12px;
font-style: normal;
font-weight: 750;
}
.summary-card-mask {
position: fixed;
inset: 0;
z-index: 180;
display: flex;
align-items: flex-end;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.summary-card-sheet {
position: relative;
width: min(100vw, 860px);
height: 88vh;
max-height: 88vh;
padding: 22px 16px 0;
border-radius: 24px 24px 0 0;
background: #f8fbff;
box-shadow: 0 -18px 44px rgba(13, 31, 68, 0.24);
overflow-y: auto;
animation: summary-sheet-in 220ms ease-out;
}
@keyframes summary-sheet-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.summary-card-handle {
position: absolute;
top: 9px;
left: 50%;
width: 66px;
height: 6px;
border-radius: 999px;
background: rgba(25, 42, 70, 0.18);
transform: translateX(-50%);
}
.summary-card-close {
position: absolute;
top: 18px;
right: 16px;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgba(21, 39, 70, 0.16);
color: #182842;
background: #ffffff;
font-size: 30px;
line-height: 1;
box-shadow: var(--shadow-sm);
}
.summary-card-head {
min-height: 46px;
padding: 12px 48px 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.summary-card-head h2 {
min-width: 0;
margin: 0;
color: #101828;
font-size: 22px;
line-height: 1.2;
font-weight: 900;
overflow-wrap: anywhere;
}
.summary-card-head span {
flex-shrink: 0;
padding: 2px 7px;
border: 1px solid rgba(46, 107, 230, 0.35);
border-radius: 999px;
color: #1f62dc;
background: #ffffff;
font-size: 12px;
font-weight: 800;
}
.summary-card-grid {
display: grid;
gap: 8px;
}
@media (min-width: 400px) {
.summary-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.summary-card-block {
min-width: 0;
padding: 12px;
border: 1px solid rgba(213, 225, 233, 0.8);
border-radius: 10px;
background: #ffffff;
box-shadow: 0 6px 18px rgba(17, 33, 63, 0.06);
}
.summary-card-block h3 {
margin: 0 0 10px;
display: flex;
align-items: center;
gap: 7px;
color: #101828;
font-size: 15px;
line-height: 1.2;
font-weight: 900;
}
.summary-card-block h3::before {
content: "";
width: 5px;
height: 22px;
border-radius: 999px;
background: var(--card-accent);
}
.summary-card-block.is-steps {
--card-accent: #2e6be6;
}
.summary-card-block.is-objections {
--card-accent: #ff7a1a;
}
.summary-card-block.is-scripts {
--card-accent: #18b66a;
}
.summary-card-block.is-reminders {
--card-accent: #f6b91a;
}
.summary-card-block ol,
.summary-card-block ul {
margin: 0;
padding: 0;
list-style: none;
}
.summary-card-block.is-steps ol {
display: grid;
gap: 7px;
}
.summary-card-block.is-steps li {
min-height: 54px;
padding: 8px;
border-radius: 10px;
background: linear-gradient(135deg, rgba(46, 107, 230, 0.11), rgba(46, 107, 230, 0.04));
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 9px;
}
.summary-card-block.is-steps em {
width: 30px;
height: 30px;
border-radius: 50%;
color: #ffffff;
background: #2e6be6;
display: grid;
place-items: center;
font-size: 16px;
font-style: normal;
font-weight: 900;
}
.summary-card-block b {
display: block;
min-width: 0;
color: #101828;
font-size: 14px;
line-height: 1.25;
font-weight: 900;
}
.summary-card-block small,
.summary-card-block span {
min-width: 0;
color: #475467;
font-size: 12px;
line-height: 1.35;
font-weight: 700;
}
.objection-list {
display: grid;
gap: 8px;
}
.objection-list p {
margin: 0;
min-height: 42px;
padding: 8px 10px;
border-radius: 10px;
background: linear-gradient(90deg, rgba(255, 122, 26, 0.14), rgba(255, 122, 26, 0.05));
display: grid;
grid-template-columns: minmax(72px, 0.55fr) minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.summary-card-block.is-scripts blockquote {
position: relative;
margin: 0;
padding: 11px 14px;
border: 1px solid rgba(24, 182, 106, 0.18);
border-radius: 10px;
color: #183b2b;
background: rgba(24, 182, 106, 0.08);
font-size: 13px;
line-height: 1.55;
font-weight: 750;
}
.summary-card-block.is-scripts blockquote + blockquote {
margin-top: 8px;
}
.summary-card-block.is-scripts blockquote::before,
.summary-card-block.is-scripts blockquote::after {
position: absolute;
color: rgba(24, 182, 106, 0.32);
font-size: 28px;
line-height: 1;
font-weight: 900;
}
.summary-card-block.is-scripts blockquote::before {
content: "“";
left: 5px;
top: 2px;
}
.summary-card-block.is-scripts blockquote::after {
content: "”";
right: 8px;
bottom: 0;
}
.summary-card-block.is-reminders ul {
display: grid;
gap: 9px;
}
.summary-card-block.is-reminders li {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 8px;
color: #182842;
font-size: 13px;
line-height: 1.35;
font-weight: 780;
}
.summary-card-block.is-reminders li::before {
content: "✓";
width: 18px;
height: 18px;
border-radius: 50%;
color: #ffffff;
background: #f6b91a;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 900;
}
.summary-card-inline-message {
margin: 10px 0 0;
color: #c25712;
text-align: center;
font-size: 12px;
line-height: 1.35;
font-weight: 750;
}
.summary-card-footer {
position: sticky;
bottom: 0;
margin: 14px -16px 0;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(213, 225, 233, 0.72);
background: rgba(248, 251, 255, 0.96);
}
.summary-card-footer-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.summary-card-footer button {
min-height: 52px;
border-radius: 12px;
color: #ffffff;
font-size: 16px;
font-weight: 900;
}
.summary-card-footer button.listen {
background: linear-gradient(135deg, #2e6be6, #0f58d6);
box-shadow: 0 8px 18px rgba(46, 107, 230, 0.2);
}
.summary-card-footer button.practice {
background: linear-gradient(135deg, #20bf72, #0a9f59);
box-shadow: 0 8px 18px rgba(32, 191, 114, 0.2);
}
.summary-card-footer button:disabled {
opacity: 0.62;
}
.summary-card-footer p {
margin: 8px 0 0;
color: #667085;
text-align: center;
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -5980,7 +6586,8 @@ p {
.mentor-avatar img,
.mentor-eyelid,
.mentor-thinking i,
.mentor-waves i {
.mentor-waves i,
.summary-card-sheet {
animation: none !important;
transition: none !important;
}