Преглед изворни кода

fix(chat): 修复思考过程展示挤成窄列的问题

调整消息气泡 flex 布局,避免 reasoning 文本被压成逐字换行;
合并流式 reasoning 碎片换行并区分「思考过程」与真实工作流阶段计数。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi пре 2 месеци
родитељ
комит
e8da00eefc
2 измењених фајлова са 64 додато и 31 уклоњено
  1. 13 0
      src/components/chat/chat-message.spec.tsx
  2. 51 31
      src/components/chat/chat-message.tsx

+ 13 - 0
src/components/chat/chat-message.spec.tsx

@@ -33,6 +33,19 @@ describe("chat thinking display", () => {
     expect(html).toContain("max-h-")
     expect(html).toContain("max-h-")
     expect(html).toContain("overflow-y-auto")
     expect(html).toContain("overflow-y-auto")
   })
   })
+
+  it("labels fragmented model reasoning as 思考过程 instead of hundreds of workflow stages", () => {
+    const fragmentedReasoning = ["好的,", "用户", "问", "的是", "当前", "情节", "中", "的人物"]
+      .join("\n\n")
+    const html = renderToStaticMarkup(
+      <StreamingMessage content={`<think>\n${fragmentedReasoning}\n</think>\n\n回答正文`} />,
+    )
+
+    expect(html).toContain("思考过程")
+    expect(html).not.toContain("工作流阶段")
+    expect(html).not.toContain("个阶段")
+    expect(html).toContain("好的,用户问的是当前情节中的人物")
+  })
 })
 })
 
 
 describe("deep chapter thinking toggle style", () => {
 describe("deep chapter thinking toggle style", () => {

+ 51 - 31
src/components/chat/chat-message.tsx

@@ -53,7 +53,7 @@ export function ChatMessage({ message, isLastAssistant, onRegenerate, novelMode,
 
 
   return (
   return (
     <div
     <div
-      className={`flex gap-2 ${isUser ? "flex-row-reverse" : "flex-row"}`}
+      className={`flex w-full gap-2 ${isUser ? "flex-row-reverse" : "flex-row"}`}
       onMouseEnter={() => setHovered(true)}
       onMouseEnter={() => setHovered(true)}
       onMouseLeave={() => setHovered(false)}
       onMouseLeave={() => setHovered(false)}
     >
     >
@@ -68,9 +68,9 @@ export function ChatMessage({ message, isLastAssistant, onRegenerate, novelMode,
       >
       >
         {isUser ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
         {isUser ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
       </div>
       </div>
-      <div className="max-w-[80%] flex flex-col gap-1.5">
+      <div className="min-w-0 max-w-[80%] flex-1 flex flex-col gap-1.5">
         <div
         <div
-          className={`rounded-lg px-3 py-2 text-sm ${
+          className={`w-full min-w-0 rounded-lg px-3 py-2 text-sm ${
             isUser
             isUser
               ? "bg-primary text-primary-foreground"
               ? "bg-primary text-primary-foreground"
               : message.discarded
               : message.discarded
@@ -532,11 +532,11 @@ export function StreamingMessage({ content }: StreamingMessageProps) {
   const isThinking = thinking !== null && answer.length === 0
   const isThinking = thinking !== null && answer.length === 0
 
 
   return (
   return (
-    <div className="flex gap-2 flex-row">
+    <div className="flex w-full gap-2 flex-row">
       <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
       <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
         <Bot className="h-4 w-4" />
         <Bot className="h-4 w-4" />
       </div>
       </div>
-      <div className="max-w-[80%] rounded-lg px-3 py-2 text-sm bg-muted text-foreground">
+      <div className="min-w-0 w-full max-w-[80%] flex-1 rounded-lg px-3 py-2 text-sm bg-muted text-foreground">
         {isThinking ? (
         {isThinking ? (
           <StreamingWorkflowBlock content={thinking} />
           <StreamingWorkflowBlock content={thinking} />
         ) : (
         ) : (
@@ -605,7 +605,7 @@ function MarkdownContent({ content }: { content: string }) {
   const htmlLang = getHtmlLang(renderLanguage)
   const htmlLang = getHtmlLang(renderLanguage)
 
 
   return (
   return (
-    <div>
+    <div className="w-full min-w-0">
       {thinking && <WorkflowBlock content={thinking} />}
       {thinking && <WorkflowBlock content={thinking} />}
       <div
       <div
         className="chat-markdown prose prose-sm max-w-none dark:prose-invert prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none"
         className="chat-markdown prose prose-sm max-w-none dark:prose-invert prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none"
@@ -839,25 +839,49 @@ function separateThinking(text: string): { thinking: string | null; answer: stri
   return { thinking: filteredThinking, answer: answer.trim() }
   return { thinking: filteredThinking, answer: answer.trim() }
 }
 }
 
 
+function countWorkflowStages(content: string): number {
+  return content.split("\n").filter((line) => isWorkflowStageHeader(line)).length
+}
+
+function getThinkingBlockMeta(content: string, streaming: boolean): { title: string; stageCount: number | null } {
+  const stageCount = countWorkflowStages(content)
+  if (stageCount > 0) {
+    return {
+      title: streaming ? "工作流进行中..." : "工作流阶段",
+      stageCount,
+    }
+  }
+  return {
+    title: streaming ? "思考中..." : "思考过程",
+    stageCount: null,
+  }
+}
+
+/** 模型 reasoning 流式输出常带大量换行;合并为连续文本,避免被误切成上百个“阶段”。 */
+function formatThinkingForDisplay(content: string): string {
+  const trimmed = content.trim()
+  if (!trimmed) return ""
+
+  if (countWorkflowStages(trimmed) > 0) {
+    return trimmed.replace(/\n{3,}/g, "\n\n")
+  }
+
+  return trimmed.replace(/\s*\n+\s*/g, "")
+}
+
 /** Streaming workflow: show stages as they come in so user can see progress. */
 /** Streaming workflow: show stages as they come in so user can see progress. */
 function StreamingWorkflowBlock({ content }: { content: string }) {
 function StreamingWorkflowBlock({ content }: { content: string }) {
-  const paragraphs = content
-    .split(/\n\s*\n/)
-    .map((p) => p.replace(/\n/g, " ").trim())
-    .filter((p) => p.length > 0)
+  const displayContent = useMemo(() => formatThinkingForDisplay(content), [content])
+  const { title } = useMemo(() => getThinkingBlockMeta(content, true), [content])
 
 
   return (
   return (
-    <div className="rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 px-2.5 py-2 min-h-[3rem]">
+    <div className="w-full min-w-0 rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 px-2.5 py-2 min-h-[3rem]">
       <div className="flex items-center gap-1.5 mb-1.5">
       <div className="flex items-center gap-1.5 mb-1.5">
         <span className="text-sm animate-pulse">📋</span>
         <span className="text-sm animate-pulse">📋</span>
-        <span className="text-xs font-medium text-blue-700 dark:text-blue-400">工作流进行中...</span>
+        <span className="text-xs font-medium text-blue-700 dark:text-blue-400">{title}</span>
       </div>
       </div>
-      <div className="max-h-72 overflow-y-auto pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap break-words">
-        {paragraphs.map((p, i) => (
-          <div key={`p-${i}`} className={i > 0 ? "mt-1.5" : ""}>
-            {p}
-          </div>
-        ))}
+      <div className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]">
+        {displayContent}
         <span className="animate-pulse text-blue-500">▊</span>
         <span className="animate-pulse text-blue-500">▊</span>
       </div>
       </div>
     </div>
     </div>
@@ -866,24 +890,20 @@ function StreamingWorkflowBlock({ content }: { content: string }) {
 
 
 /** Completed workflow stages: keep visible so user can review what happened. */
 /** Completed workflow stages: keep visible so user can review what happened. */
 function WorkflowBlock({ content }: { content: string }) {
 function WorkflowBlock({ content }: { content: string }) {
-  const paragraphs = content
-    .split(/\n\s*\n/)
-    .map((p) => p.replace(/\n/g, " ").trim())
-    .filter((p) => p.length > 0)
+  const displayContent = useMemo(() => formatThinkingForDisplay(content), [content])
+  const { title, stageCount } = useMemo(() => getThinkingBlockMeta(content, false), [content])
 
 
   return (
   return (
-    <div className="mb-2 rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 min-h-[3rem]">
+    <div className="mb-2 w-full min-w-0 rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 min-h-[3rem]">
       <div className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-xs text-blue-700 dark:text-blue-400">
       <div className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-xs text-blue-700 dark:text-blue-400">
         <span className="text-sm">📋</span>
         <span className="text-sm">📋</span>
-        <span className="font-medium">工作流阶段</span>
-        <span className="text-[10px] text-blue-600/60 dark:text-blue-500/60">{paragraphs.length} 个阶段</span>
+        <span className="font-medium">{title}</span>
+        {stageCount !== null && (
+          <span className="text-[10px] text-blue-600/60 dark:text-blue-500/60">{stageCount} 个阶段</span>
+        )}
       </div>
       </div>
-      <div className="max-h-72 overflow-y-auto border-t border-blue-500/20 px-2.5 py-2 pr-1 text-xs text-blue-800/80 dark:text-blue-300/70 whitespace-pre-wrap break-words leading-relaxed">
-        {paragraphs.map((p, i) => (
-          <div key={`p-${i}`} className={i > 0 ? "mt-1.5" : ""}>
-            {p}
-          </div>
-        ))}
+      <div className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden border-t border-blue-500/20 px-2.5 py-2 pr-1 text-xs text-blue-800/80 dark:text-blue-300/70 whitespace-pre-wrap leading-relaxed [overflow-wrap:anywhere]">
+        {displayContent}
       </div>
       </div>
     </div>
     </div>
   )
   )