真机走查第二批: 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
150 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|