Files
Jyotisha/frontend/src/components/chat-message-content.tsx
T
Jesse_ChenandClaude Opus 5 524015cfc1
Independent Staging Quality Gate / validate (push) Canceled after 8m15s
Independent Staging Quality Gate / publish (push) Canceled after 0s
fix(ui): 回答不再折叠,次级页侧栏改真链接并补回头像
真机走查第二批:

1. 普通对话的回答,首个 H2 之后的全部内容(带推理的那一半)被
   <details class="answer-detail">「完整分析」默认收起——用户「输出根本
   看不到」。折叠控件与组件文件一并删除,报告层直接渲染进正文。
   技法审计表保留自己的折叠:那是给人核对的证据,不是回复正文。
   product-voice.ts 同步改掉「UI 会折叠」那句,否则模型继续按折叠写。

2. 次级页侧栏点不了头像、头像样式与首页不一致。根因是 R4 的取舍:
   SecondaryShell 共用,但里面挂的是新写的 AppNavRail 而不是 AppSidebar。
   现在 use-nav-rail 也取 avatar,渲染同一个 UserAvatar;页脚改成真链接,
   指向 / —— 账户菜单连着设置弹窗栈,留在那儿,不在这里复制第二份。

3. 「新建对话」整页刷新。原来走 location.assign,现在是 next/link。
   /login 保留硬跳转:它跨鉴权边界,要先存返回目标。

   注意:第一版我改成了 useRouter(),它在 app-router 上下文外会抛
   「invariant expected app router to be mounted」,打红 12 条渲染测试。
   改用 <Link>,静态渲染也安全;折叠态 tooltip 换成原生 title。

测试 3369,fail 仍 31 且与基线逐条一致;四个路由标记不变;
CSS gzip 40,002。

未修:截图里 `## 适合推进 / 需要避开` 被当字面量渲染,是模型输出里
那两个 H2 前面没有换行,属生成层,另记。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 08:15:25 +00:00

150 lines
4.8 KiB
TypeScript

"use client";
import { memo, useEffect, useState, type ReactNode } from "react";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { plainParagraphs } from "@/components/chat-message-paragraphs";
import { TechniqueAuditDisclosure } from "@/components/technique-audit-disclosure";
import { splitSpokenAndReport } from "@/lib/chat-answer-split";
import { splitStableMarkdown } from "@/lib/chat-markdown-split";
import type { TechniqueAuditRow } from "@/lib/consultation-agent-events";
import {
resolveTechniqueAuditRows,
splitSpokenAnswerAndTechniqueAudit,
} from "@/lib/consultation-technique-audit";
type MarkdownRenderer = (text: string) => ReactNode;
let markdownRenderer: MarkdownRenderer | undefined;
let markdownRendererRequest: Promise<MarkdownRenderer> | undefined;
function loadMarkdownRenderer() {
markdownRendererRequest ??= import("@/components/chat-markdown-view").then((module) => {
markdownRenderer = module.renderChatMarkdown;
return module.renderChatMarkdown;
});
return markdownRendererRequest;
}
prefetchOnIdle(loadMarkdownRenderer);
function useMarkdownRenderer() {
const [renderer, setRenderer] = useState<MarkdownRenderer | undefined>(() => markdownRenderer);
useEffect(() => {
if (renderer) return;
let active = true;
void loadMarkdownRenderer().then((loaded) => {
if (active) setRenderer(() => loaded);
});
return () => { active = false; };
}, [renderer]);
return renderer;
}
function renderProse(text: string, renderMarkdown: MarkdownRenderer | undefined): ReactNode {
if (!text) return null;
return renderMarkdown
? renderMarkdown(text)
: (plainParagraphs(text) ?? []).map((paragraph, index) => (
<p key={index}>{paragraph}</p>
));
}
const settledMarkdownNodes = new WeakMap<MarkdownRenderer, Map<string, ReactNode>>();
function rememberedProse(text: string, renderMarkdown: MarkdownRenderer | undefined): ReactNode {
if (!text) return null;
if (!renderMarkdown) return renderProse(text, undefined);
let byText = settledMarkdownNodes.get(renderMarkdown);
if (!byText) {
byText = new Map();
settledMarkdownNodes.set(renderMarkdown, byText);
}
const cached = byText.get(text);
if (cached !== undefined) return cached;
const node = renderMarkdown(text);
byText.set(text, node);
return node;
}
/**
* Settled prose and the completed prefix of a streaming answer. `memo` plus a
* content-keyed parse cache keep React from calling the markdown parser again
* while `text` is unchanged, so a parent re-render (or a frame that only grew
* the tail) does not parse the whole answer.
*/
export const StableMarkdownPrefix = memo(function StableMarkdownPrefix({
text,
renderMarkdown,
}: Readonly<{
text: string;
renderMarkdown: MarkdownRenderer | undefined;
}>) {
return <>{rememberedProse(text, renderMarkdown)}</>;
});
export function StreamingMarkdown({
text,
renderMarkdown,
}: Readonly<{
text: string;
renderMarkdown: MarkdownRenderer | undefined;
}>) {
const split = splitStableMarkdown(text);
return (
<>
{split.stable ? <StableMarkdownPrefix text={split.stable} renderMarkdown={renderMarkdown} /> : null}
{renderProse(split.tail, renderMarkdown)}
</>
);
}
export function ChatMessageContent({
text,
auditRows,
streaming = false,
}: {
text: string;
auditRows?: readonly TechniqueAuditRow[];
streaming?: boolean;
}) {
const renderMarkdown = useMarkdownRenderer();
const split = splitSpokenAnswerAndTechniqueAudit(text);
const rows = resolveTechniqueAuditRows(split, auditRows);
const layers = splitSpokenAndReport(split.spoken);
const spoken = layers.spoken;
const report = layers.report;
const showFoldedAudit = rows.length > 0 && split.rows.length === 0;
const foldedAudit = showFoldedAudit ? <TechniqueAuditDisclosure rows={rows} /> : null;
const reportBody = !streaming && report
? (
<>
<div className="message-markdown">
<StableMarkdownPrefix text={report} renderMarkdown={renderMarkdown} />
</div>
{foldedAudit}
</>
)
: null;
return (
<div className="message-answer">
{spoken ? (
<div className="message-markdown">
{streaming
? <StreamingMarkdown text={spoken} renderMarkdown={renderMarkdown} />
: <StableMarkdownPrefix text={spoken} renderMarkdown={renderMarkdown} />}
</div>
) : null}
{/* The body used to sit inside a collapsed 完整分析 control: everything
from the first H2 down was one click away, so the part of the answer
that carries the reasoning was invisible by default. The answer is the
product — it renders inline. The technique audit keeps its own
disclosure, because that one is evidence for checking, not the reply. */}
{report ? <div className="answer-report-body">{reportBody}</div> : foldedAudit}
</div>
);
}