fix(hub): render AI coach summary and chat as Markdown

Reuse shared ai_review_render.js so summaries and coach replies display formatted lists and bold text instead of raw syntax.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-07 00:23:44 +08:00
parent 821e260912
commit 8417784dd8
4 changed files with 110 additions and 14 deletions
+15
View File
@@ -234,6 +234,21 @@ async def _hub_lifespan(_app: FastAPI):
app = FastAPI(title="复盘系统中控", docs_url=None, redoc_url=None, lifespan=_hub_lifespan)
STATIC_DIR = DIR / "static"
_REPO_STATIC = _REPO_ROOT / "static"
_AI_REVIEW_RENDER_JS = _REPO_STATIC / "ai_review_render.js"
@app.get("/assets/ai_review_render.js")
def hub_ai_review_render_js():
"""与四所实例共用仓库根 static/ai_review_render.js(须在 /assets mount 之前注册)。"""
if not _AI_REVIEW_RENDER_JS.is_file():
raise HTTPException(status_code=404, detail="ai_review_render.js not found")
return FileResponse(
str(_AI_REVIEW_RENDER_JS),
media_type="application/javascript; charset=utf-8",
)
if STATIC_DIR.is_dir():
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets")
+62
View File
@@ -3513,6 +3513,68 @@ body.hub-page-ai #page-ai {
overflow-wrap: anywhere;
word-break: break-word;
}
.ai-md-body.ai-result-md,
.ai-bubble-assistant.ai-result-md {
white-space: normal;
}
.ai-result-md p {
margin: 6px 0;
color: var(--text);
}
.ai-result-md ul,
.ai-result-md ol {
margin: 6px 0 8px 1.25em;
padding: 0;
}
.ai-result-md li {
margin: 5px 0;
line-height: 1.5;
}
.ai-result-md strong {
color: var(--text);
font-weight: 600;
}
.ai-result-md h2 {
font-size: 1.02rem;
color: var(--accent-2, var(--accent));
margin: 14px 0 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border-soft);
}
.ai-result-md h3,
.ai-result-md h4 {
font-size: 0.92rem;
color: var(--accent-2, var(--accent));
margin: 10px 0 6px;
}
.ai-result-md code {
background: color-mix(in srgb, var(--inset-surface) 70%, var(--border-soft));
padding: 1px 4px;
border-radius: 4px;
font-size: 0.82em;
}
.ai-result-md .md-raw-block-title {
margin-top: 14px;
padding-top: 10px;
border-top: 1px dashed var(--border-soft);
color: var(--muted);
font-weight: 600;
}
.ai-bubble-assistant.ai-result-md p {
margin: 4px 0;
}
.ai-bubble-assistant.ai-result-md h2,
.ai-bubble-assistant.ai-result-md h3,
.ai-bubble-assistant.ai-result-md h4 {
margin: 8px 0 4px;
font-size: 0.92rem;
border-bottom: none;
padding-bottom: 0;
}
.ai-bubble-assistant.ai-result-md ul,
.ai-bubble-assistant.ai-result-md ol {
margin: 4px 0 6px 1.15em;
}
.ai-placeholder {
color: var(--muted);
margin: 0;
+31 -13
View File
@@ -2958,17 +2958,32 @@
return `${n > 0 ? "+" : "-"}${abs}U`;
}
function renderAiMarkdown(text) {
const escLocal = (s) =>
String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return escLocal(text)
function renderHubMarkdown(text) {
const raw = String(text || "");
if (typeof window !== "undefined" && window.AiReviewRender && window.AiReviewRender.renderMarkdown) {
return window.AiReviewRender.renderMarkdown(raw);
}
return esc(raw)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\n/g, "<br>");
}
function renderAiMarkdown(text) {
return renderHubMarkdown(text);
}
function setAiSummaryMarkdown(body, contentMd) {
if (!body) return;
body.classList.add("ai-result-md");
body.innerHTML = renderHubMarkdown(contentMd);
}
function setAiSummaryPlaceholder(body, html) {
if (!body) return;
body.classList.remove("ai-result-md");
body.innerHTML = html;
}
function renderAiSummaryStats(snapshot) {
const el = document.getElementById("ai-summary-stats");
if (!el) return;
@@ -2994,10 +3009,13 @@
const label = isUser ? "主人" : "AI教练";
const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach";
const bubbleCls = isUser ? "ai-bubble-user" : "ai-bubble-assistant";
const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking");
const bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "");
const mdCls = !isUser && !isThinking ? " ai-result-md" : "";
return (
`<div class="ai-msg-row ${rowCls}">` +
`<span class="ai-msg-role">${label}</span>` +
`<div class="ai-bubble ${bubbleCls}${extraClass ? " " + extraClass : ""}">${esc(content || "")}</div>` +
`<div class="ai-bubble ${bubbleCls}${mdCls}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
`</div>`
);
}
@@ -3057,7 +3075,7 @@
const j = await r.json();
const latest = j.latest;
if (latest && latest.content_md) {
if (body) body.innerHTML = renderAiMarkdown(latest.content_md);
if (body) setAiSummaryMarkdown(body, latest.content_md);
renderAiSummaryStats(latest.stats_snapshot);
const sm = document.getElementById("ai-summary-meta");
if (sm && latest.generated_at) {
@@ -3065,7 +3083,7 @@
}
}
} catch (e) {
if (body) body.innerHTML = `<p class="ai-placeholder">${esc(String(e))}</p>`;
if (body) setAiSummaryPlaceholder(body, `<p class="ai-placeholder">${esc(String(e))}</p>`);
}
}
@@ -3087,7 +3105,7 @@
const btn = document.getElementById("btn-ai-summary");
const body = document.getElementById("ai-summary-body");
if (btn) btn.disabled = true;
if (body) body.innerHTML = '<p class="ai-placeholder">正在聚合四户数据并生成总结…</p>';
if (body) setAiSummaryPlaceholder(body, '<p class="ai-placeholder">正在聚合四户数据并生成总结…</p>');
try {
const r = await apiFetch("/api/ai/summary/generate", {
method: "POST",
@@ -3099,14 +3117,14 @@
if (!j.ok && j.detail) throw new Error(j.detail);
const sum = j.summary;
if (sum && sum.content_md && body) {
body.innerHTML = renderAiMarkdown(sum.content_md);
setAiSummaryMarkdown(body, sum.content_md);
renderAiSummaryStats(sum.stats_snapshot);
}
showToast(j.cached ? "已是最新上下文,返回缓存总结" : "今日总结已生成");
await loadAiSummary();
} catch (e) {
showToast(String(e), true);
if (body) body.innerHTML = `<p class="ai-placeholder">${esc(String(e))}</p>`;
if (body) setAiSummaryPlaceholder(body, `<p class="ai-placeholder">${esc(String(e))}</p>`);
} finally {
aiSummaryLoading = false;
if (btn) btn.disabled = false;
+2 -1
View File
@@ -287,6 +287,7 @@
<div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260604-upnl-contracts"></script>
<script src="/assets/app.js?v=20260606-hub-ai3"></script>
<script src="/assets/ai_review_render.js?v=2"></script>
<script src="/assets/app.js?v=20260606-hub-ai-md"></script>
</body>
</html>