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

style(outline): 标准对话工作流对齐多 Agent 卡片外观

对话里的标准意图分析/生成不再用普通聊天的左边框时间线,改成与模板多 Agent 面板同一套天空色卡片(标题、运行状态、完成数、内嵌工具执行)。快速模式和真正的多 Agent 面板保持原样,执行路径不变。

Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>
Cursor Agent пре 3 недеља
родитељ
комит
e92b5d74ff

+ 6 - 0
src/components/chat/agent-tool-call-message.tsx

@@ -10,6 +10,8 @@ interface AgentToolCallMessageProps {
   contextTrace?: ContextTrace | null
   thinkingContent?: string
   thinkingStreaming?: boolean
+  chrome?: "line" | "none"
+  showEventSummary?: boolean
   onConfirmSave?: (call: ToolCallRecord & { preview?: string }) => void
   onReject?: (call: ToolCallRecord & { preview?: string }) => void
 }
@@ -19,6 +21,8 @@ export function AgentToolCallMessage({
   contextTrace,
   thinkingContent,
   thinkingStreaming,
+  chrome,
+  showEventSummary,
   onConfirmSave,
   onReject,
 }: AgentToolCallMessageProps) {
@@ -28,6 +32,8 @@ export function AgentToolCallMessage({
       contextTrace={contextTrace}
       thinkingContent={thinkingContent}
       thinkingStreaming={thinkingStreaming}
+      chrome={chrome}
+      showEventSummary={showEventSummary}
       onConfirmSave={onConfirmSave}
       onReject={onReject}
     />

+ 17 - 2
src/components/chat/agent-workflow-panel.tsx

@@ -14,6 +14,8 @@ interface AgentWorkflowPanelProps {
   contextTrace?: ContextTrace | null
   thinkingContent?: string
   thinkingStreaming?: boolean
+  chrome?: "line" | "none"
+  showEventSummary?: boolean
   onConfirmSave?: (call: ToolCallRecord & { preview?: string }) => void
   onReject?: (call: ToolCallRecord & { preview?: string }) => void
 }
@@ -55,6 +57,8 @@ export function AgentWorkflowPanel({
   contextTrace,
   thinkingContent,
   thinkingStreaming,
+  chrome = "line",
+  showEventSummary = true,
   onConfirmSave,
   onReject,
 }: AgentWorkflowPanelProps) {
@@ -135,8 +139,19 @@ export function AgentWorkflowPanel({
   })()
 
   return (
-    <div className="mb-2 w-full min-w-0 max-w-full overflow-hidden border-l border-border/80 pl-3">
-      <EventStream events={events} isStreaming={isStreaming} totalDurationMs={totalDurationMs} />
+    <div
+      className={
+        chrome === "none"
+          ? "w-full min-w-0 max-w-full overflow-hidden"
+          : "mb-2 w-full min-w-0 max-w-full overflow-hidden border-l border-border/80 pl-3"
+      }
+    >
+      <EventStream
+        events={events}
+        isStreaming={isStreaming}
+        totalDurationMs={totalDurationMs}
+        showSummary={showEventSummary}
+      />
 
       {pendingApprovalCalls.length > 0 && onConfirmSave && onReject ? (
         <div className="mt-3 space-y-2 rounded-md border border-amber-200 bg-amber-50/70 p-2 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200">

+ 3 - 2
src/components/common/event-stream.tsx

@@ -11,9 +11,10 @@ interface EventStreamProps {
   isStreaming: boolean
   totalDurationMs?: number
   totalTokens?: number
+  showSummary?: boolean
 }
 
-function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }: EventStreamProps) {
+function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens, showSummary = true }: EventStreamProps) {
   const containerRef = useRef<HTMLDivElement>(null)
   const userScrolledRef = useRef(false)
   const scrollFrameRef = useRef<number | null>(null)
@@ -106,7 +107,7 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
           )
         })}
 
-        {!isStreaming && (totalDurationMs !== undefined || totalTokens !== undefined) && (
+        {showSummary && !isStreaming && (totalDurationMs !== undefined || totalTokens !== undefined) && (
           <div
             className="mt-2 flex items-center gap-3 px-2 pt-2 border-t border-border/50 text-[11px] text-muted-foreground/70"
           >

+ 2 - 0
src/components/common/workflow-responsive-width.spec.tsx

@@ -23,8 +23,10 @@ describe("普通 AI 会话与 AI 大纲思考流程框宽度", () => {
 
   it("AI 大纲仅用户气泡使用内容宽度,助手工作流始终占满消息列", () => {
     const panel = source("../sources/outline-chat-panel.tsx")
+    const standardWorkflow = source("../sources/outline-standard-workflow-panel.tsx")
     expect(panel).toContain("h-full w-full min-w-0 max-w-full")
     expect(panel).toContain("flex w-full min-w-0 max-w-full")
     expect(panel).toContain('msg.role === "user" ? "w-fit max-w-full lg:max-w-[50vw]" : "w-full min-w-0 max-w-full"')
+    expect(standardWorkflow).toContain("w-full min-w-0 max-w-full overflow-hidden")
   })
 })

+ 8 - 0
src/components/sources/outline-chat-panel.spec.tsx

@@ -470,6 +470,14 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("historyPlan.showToolProcess")
   })
 
+  it("标准模式对话工作流用多 Agent 同款卡片,不改成真正的多 Agent", () => {
+    expect(source).toContain("OutlineStandardWorkflowPanel")
+    expect(source).toContain("shouldUseOutlineStandardWorkflowCard")
+    expect(source).toContain("useStandardWorkflowCard")
+    expect(source).not.toContain("enableMultiAgent: true")
+    expect(source).toContain("enableMultiAgent: !fastMode")
+  })
+
   it("顶栏会话 chips 保底可见,阶段徽章不和会话列表抢宽度", () => {
     expect(source).toContain("min-w-[72px]")
     expect(source).toContain("topConversations.length > 0")

+ 31 - 7
src/components/sources/outline-chat-panel.tsx

@@ -63,6 +63,10 @@ import {
 import { OutlineWizardDialog } from "@/components/sources/outline-wizard-dialog";
 import { NovelGenerationRequestMessage } from "@/components/sources/novel-generation-request-message";
 import { OutlineMultiAgentPanel } from "@/components/sources/outline-multi-agent-panel";
+import {
+  OutlineStandardWorkflowPanel,
+  shouldUseOutlineStandardWorkflowCard,
+} from "@/components/sources/outline-standard-workflow-panel";
 import { TooltipProvider } from "@/components/ui/tooltip";
 import { OUTLINE_SECTION_GENERATION_CONFIGS } from "@/lib/novel/outline-section-configs";
 import {
@@ -1061,6 +1065,14 @@ function OutlineAssistantMessage({
   const currentContextHubSnapshot = msg.contextHubSnapshot
     ? parseContextHubSnapshotRef(msg.contextHubSnapshot)
     : null;
+  const isOutlineFastMode = resolveOutlineWorkflowMode(
+    useWikiStore((s) => s.outlineWorkflowMode),
+  ) === "fast";
+  const useStandardWorkflowCard = shouldUseOutlineStandardWorkflowCard({
+    fastMode: isOutlineFastMode,
+    intentPhase: msg.intentPhase,
+    hasMultiAgentRun: Boolean(msg.multiAgentRun),
+  });
 
   return (
     <>
@@ -1070,13 +1082,25 @@ function OutlineAssistantMessage({
         onResume={() => { void onResumeMultiAgent(msg.id) }}
         resumeDisabled={resumeMultiAgentDisabled}
       />
-      <AgentToolCallMessage
-        toolCalls={msg.agentToolCalls}
-        thinkingContent={thinking || undefined}
-        thinkingStreaming={messageIsStreaming}
-        onConfirmSave={onConfirmToolSave}
-        onReject={onRejectTool}
-      />
+      {useStandardWorkflowCard ? (
+        <OutlineStandardWorkflowPanel
+          intentPhase={msg.intentPhase}
+          isRunning={messageIsStreaming || Boolean(msg.isAgentRunning)}
+          toolCalls={msg.agentToolCalls}
+          thinkingContent={thinking || undefined}
+          thinkingStreaming={messageIsStreaming}
+          onConfirmSave={onConfirmToolSave}
+          onReject={onRejectTool}
+        />
+      ) : (
+        <AgentToolCallMessage
+          toolCalls={msg.agentToolCalls}
+          thinkingContent={thinking || undefined}
+          thinkingStreaming={messageIsStreaming}
+          onConfirmSave={onConfirmToolSave}
+          onReject={onRejectTool}
+        />
+      )}
       {messageIsStreaming && !msg.content && runStatusText ? (
         <div className="mb-1 whitespace-pre-wrap text-xs text-muted-foreground">
           {runStatusText}

+ 131 - 0
src/components/sources/outline-standard-workflow-panel.spec.tsx

@@ -0,0 +1,131 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import type { AgentRunRecord } from "@/lib/agent/types"
+import {
+  OutlineStandardWorkflowPanel,
+  shouldUseOutlineStandardWorkflowCard,
+} from "./outline-standard-workflow-panel"
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
+  .IS_REACT_ACT_ENVIRONMENT = true
+
+type ToolCallRecord = AgentRunRecord["toolCalls"][number]
+
+const sampleCalls: ToolCallRecord[] = [
+  {
+    id: "call-1",
+    name: "read_outline",
+    params: { name: "第47章" },
+    result: "章纲内容",
+    status: "done",
+    startedAt: 0,
+    finishedAt: 120,
+  },
+  {
+    id: "call-2",
+    name: "write_outline_node",
+    params: { outlineName: "第47章", content: "草稿" },
+    result: "已写入",
+    status: "running",
+    startedAt: 160,
+  },
+]
+
+let host: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => {
+    root.unmount()
+  })
+  host.remove()
+})
+
+describe("shouldUseOutlineStandardWorkflowCard", () => {
+  it("仅标准模式的意图分析/生成使用卡片,快速模式和多 Agent 不套一层", () => {
+    expect(shouldUseOutlineStandardWorkflowCard({
+      fastMode: false,
+      intentPhase: "generation",
+      hasMultiAgentRun: false,
+    })).toBe(true)
+    expect(shouldUseOutlineStandardWorkflowCard({
+      fastMode: false,
+      intentPhase: "intent_analysis",
+      hasMultiAgentRun: false,
+    })).toBe(true)
+    expect(shouldUseOutlineStandardWorkflowCard({
+      fastMode: true,
+      intentPhase: "generation",
+      hasMultiAgentRun: false,
+    })).toBe(false)
+    expect(shouldUseOutlineStandardWorkflowCard({
+      fastMode: false,
+      intentPhase: "generation",
+      hasMultiAgentRun: true,
+    })).toBe(false)
+    expect(shouldUseOutlineStandardWorkflowCard({
+      fastMode: false,
+      intentPhase: "waiting_user_input",
+      hasMultiAgentRun: false,
+    })).toBe(false)
+    expect(shouldUseOutlineStandardWorkflowCard({
+      fastMode: false,
+      hasMultiAgentRun: false,
+    })).toBe(false)
+  })
+})
+
+describe("OutlineStandardWorkflowPanel", () => {
+  it("用与多 Agent 面板相同的天空色卡片展示标题、状态和工具完成数", async () => {
+    await act(async () => {
+      root.render(
+        <OutlineStandardWorkflowPanel
+          intentPhase="generation"
+          isRunning
+          toolCalls={sampleCalls}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("大纲生成工作流")
+    expect(host.textContent).toContain("运行中")
+    expect(host.textContent).toContain("完成:1/2")
+    expect(host.textContent).toContain("工具执行")
+    expect(host.innerHTML).toContain("border-sky-200/70")
+    expect(host.innerHTML).toContain("bg-sky-50/45")
+    expect(host.innerHTML).not.toContain("border-l border-border/80")
+    expect(host.textContent).not.toContain("思考 0 段")
+    expect(host.textContent).not.toContain("多 Agent 大纲生成")
+  })
+
+  it("意图分析阶段使用对应标题,完成后显示完成状态", async () => {
+    await act(async () => {
+      root.render(
+        <OutlineStandardWorkflowPanel
+          intentPhase="intent_analysis"
+          toolCalls={[{ ...sampleCalls[0], status: "done" }]}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("意图分析工作流")
+    expect(host.textContent).toContain("完成")
+    expect(host.textContent).toContain("完成:1/1")
+  })
+
+  it("没有工具、思考或运行状态时不渲染空卡片", async () => {
+    await act(async () => {
+      root.render(<OutlineStandardWorkflowPanel intentPhase="generation" toolCalls={[]} />)
+    })
+
+    expect(host.textContent).toBe("")
+  })
+})

+ 135 - 0
src/components/sources/outline-standard-workflow-panel.tsx

@@ -0,0 +1,135 @@
+import {
+  CheckCircle2,
+  ChevronRight,
+  Loader2,
+  Workflow,
+  XCircle,
+} from "lucide-react"
+import {
+  AgentToolCallMessage,
+  type ToolCallRecord,
+} from "@/components/chat/agent-tool-call-message"
+import { cn } from "@/lib/utils"
+
+type OutlineWorkflowIntentPhase = "intent_analysis" | "generation" | "waiting_user_input"
+
+export function shouldUseOutlineStandardWorkflowCard(input: {
+  fastMode: boolean
+  intentPhase?: OutlineWorkflowIntentPhase
+  hasMultiAgentRun: boolean
+}): boolean {
+  if (input.fastMode) return false
+  if (input.hasMultiAgentRun) return false
+  return input.intentPhase === "intent_analysis" || input.intentPhase === "generation"
+}
+
+function workflowTitle(intentPhase?: OutlineWorkflowIntentPhase): string {
+  if (intentPhase === "intent_analysis") return "意图分析工作流"
+  return "大纲生成工作流"
+}
+
+function statusLabel(status: "running" | "done" | "error"): string {
+  switch (status) {
+    case "running":
+      return "运行中"
+    case "done":
+      return "完成"
+    case "error":
+      return "失败"
+  }
+}
+
+function StatusIcon({ status }: { status: "running" | "done" | "error" }) {
+  if (status === "running") {
+    return <Loader2 className="h-3.5 w-3.5 animate-spin text-sky-600 dark:text-sky-400" />
+  }
+  if (status === "done") {
+    return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
+  }
+  return <XCircle className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />
+}
+
+interface OutlineStandardWorkflowPanelProps {
+  intentPhase?: OutlineWorkflowIntentPhase
+  isRunning?: boolean
+  toolCalls: ToolCallRecord[] | undefined
+  thinkingContent?: string
+  thinkingStreaming?: boolean
+  onConfirmSave?: (call: ToolCallRecord & { preview?: string }) => void
+  onReject?: (call: ToolCallRecord & { preview?: string }) => void
+}
+
+export function OutlineStandardWorkflowPanel({
+  intentPhase,
+  isRunning = false,
+  toolCalls,
+  thinkingContent,
+  thinkingStreaming,
+  onConfirmSave,
+  onReject,
+}: OutlineStandardWorkflowPanelProps) {
+  const safeToolCalls = toolCalls ?? []
+  const toolsRunning = safeToolCalls.some((call) => call.status === "running")
+  const running = isRunning || Boolean(thinkingStreaming) || toolsRunning
+  const failedCount = safeToolCalls.filter((call) => call.status === "error").length
+  const doneCount = safeToolCalls.filter((call) => call.status === "done").length
+  const hasContent = safeToolCalls.length > 0 || Boolean(thinkingContent) || running
+  if (!hasContent) return null
+
+  const status: "running" | "done" | "error" = running
+    ? "running"
+    : failedCount > 0 && doneCount === 0
+      ? "error"
+      : "done"
+
+  return (
+    <div className="mb-2 w-full min-w-0 max-w-full overflow-hidden rounded-md border border-sky-200/70 bg-sky-50/45 p-3 text-xs dark:border-sky-900/45 dark:bg-sky-950/15">
+      <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
+        <div className="flex min-w-0 items-center gap-2 text-[13px] font-medium text-foreground">
+          <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300">
+            <Workflow className="h-3.5 w-3.5" />
+          </span>
+          <span>{workflowTitle(intentPhase)}</span>
+          <span className="inline-flex items-center gap-1 text-[11px] font-normal text-muted-foreground">
+            <StatusIcon status={status} />
+            {statusLabel(status)}
+          </span>
+        </div>
+        {safeToolCalls.length > 0 ? (
+          <div className="flex shrink-0 flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
+            <span>完成:{doneCount}/{safeToolCalls.length}</span>
+            {failedCount > 0 ? (
+              <span className="text-red-600 dark:text-red-400">失败:{failedCount}</span>
+            ) : null}
+          </div>
+        ) : null}
+      </div>
+
+      <div className="rounded-md border border-border/70 bg-background/75 px-2.5 py-2">
+        <div className="mb-1.5 flex items-center justify-between gap-2">
+          <div className="flex min-w-0 items-center gap-1.5">
+            <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+            <span className="min-w-0 break-words font-medium text-foreground">工具执行</span>
+          </div>
+          <span className={cn(
+            "inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground",
+            status === "error" && "text-red-600 dark:text-red-400",
+            status === "done" && "text-emerald-700 dark:text-emerald-300",
+          )}>
+            <StatusIcon status={status} />
+            {statusLabel(status)}
+          </span>
+        </div>
+        <AgentToolCallMessage
+          toolCalls={safeToolCalls}
+          thinkingContent={thinkingContent}
+          thinkingStreaming={thinkingStreaming || (running && safeToolCalls.length === 0)}
+          chrome="none"
+          showEventSummary={false}
+          onConfirmSave={onConfirmSave}
+          onReject={onReject}
+        />
+      </div>
+    </div>
+  )
+}