diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java index 261bce86..bfc96ba8 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java @@ -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 summaryCard(@RequestBody SummaryCardRequest request) { + return R.ok(sopSeedService.summaryCard(request)); + } + @PostMapping("/doc/upload") public R uploadDoc(@RequestPart("file") MultipartFile file, @RequestParam("category") String category) { return R.ok(sopSeedService.uploadDoc(file, category)); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java index 0780b77d..f50ae605 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java @@ -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 steps, List objections, List scripts, List 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 tags, List snippets) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index 1f9a1786..c0ac6dba 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -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 hits; + try { + hits = dbHits(dbCategory(category), queryText, 5); + } catch (DataAccessException e) { + hits = List.of(); + } + if (hits.isEmpty()) { + throw new ServiceException("知识库中未检索到相关内容"); + } + Optional 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 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 keywordHits = likeHits(category, queryText, limitValue); - List textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue); - List hits = rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue)); + List hits = dbHits(category, queryText, limit); if (hits.isEmpty()) { return null; } @@ -1580,6 +1630,13 @@ public class AihrSopSeedService { } } + private List dbHits(String category, String queryText, Integer limit) { + int limitValue = limitValue(limit); + List keywordHits = likeHits(category, queryText, limitValue); + List textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue); + return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue)); + } + private List 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 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 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 jsonCardSteps(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + List 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 jsonCardObjections(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + List 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 keyPoints, List cautions) { } diff --git a/mobile/src/App.vue b/mobile/src/App.vue index 4eff5db9..c032589a 100644 --- a/mobile/src/App.vue +++ b/mobile/src/App.vue @@ -827,6 +827,13 @@

⚠ {{ item }}

{{ sopResult.reference }}

+
+ + {{ summaryCardMessage }} +
@@ -850,6 +857,66 @@
+
+ +
+
{{ toastMessage }}
@@ -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()); const sopSpeaking = ref(false); const sopSpeakLoading = ref(false); +const summaryCard = ref(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(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( + 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); @@ -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; }