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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2958,17 +2958,32 @@
|
||||
return `${n > 0 ? "+" : "-"}${abs}U`;
|
||||
}
|
||||
|
||||
function renderAiMarkdown(text) {
|
||||
const escLocal = (s) =>
|
||||
String(s || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user