From 178dd062601aa33e7653b15d532a9d58c2007f9a Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Tue, 22 Sep 2026 21:51:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=9C=A8=E5=AF=B9=E8=AF=9D=E9=87=8C?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E6=A8=A1=E5=9E=8B=E6=80=9D=E8=80=83=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=9C=A8=E4=B8=80=E8=BD=AE=E7=BB=93=E6=9D=9F=E5=90=8E?= =?UTF-8?q?=E6=94=B6=E8=B5=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 思考随助手事件流出并回传给下一轮;完成后收进 Thought,会话正文行高调到 1.6。 Co-authored-by: Cursor --- docs/architecture.md | 2 +- packages/core/chat/content.ts | 5 + packages/core/chat/reducer.test.ts | 47 +++ packages/core/chat/reducer.ts | 34 ++- packages/core/chat/types.ts | 2 + packages/ui/components/message.tsx | 6 +- packages/ui/components/reasoning.tsx | 8 + packages/views/chat/conversation-timeline.tsx | 285 +++++++++++++----- server/pkg/agent/content.go | 29 +- server/pkg/agent/content_test.go | 14 + server/pkg/agent/engine.go | 9 +- server/pkg/agent/event.go | 1 + server/pkg/agent/model.go | 10 +- server/pkg/agent/openai.go | 39 ++- server/pkg/agent/openai_test.go | 43 +++ 15 files changed, 445 insertions(+), 89 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a432efd..f611a66 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -331,7 +331,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/core` -跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。看板前端在 `board/`(`BoardClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。thinking 用 Run 状态(`queued` / `loading_context` / `running_llm` / `verifying`),不是模型 reasoning token。验证另有独立时间线卡片;跳过验证不画卡片。 +跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。看板前端在 `board/`(`BoardClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。thinking 状态字用 Run 状态(`queued` / `loading_context` / `running_llm` / `verifying`)。模型思考正文走 `assistant.delta` 的 `reasoning` 字段,落在助手消息 Content 里供下一轮回传。验证另有独立时间线卡片;跳过验证不画卡片。 ### `packages/ui` diff --git a/packages/core/chat/content.ts b/packages/core/chat/content.ts index 5c4bd4f..19ceef0 100644 --- a/packages/core/chat/content.ts +++ b/packages/core/chat/content.ts @@ -33,9 +33,11 @@ export function firstLine(text: string, max = 48): string { export type ParsedDelta = | { kind: "text"; text: string } + | { kind: "reasoning"; text: string } | { kind: "tool"; call: ToolCall } | { kind: "unknown" }; +// parseDelta 把 assistant.delta 分成正文、思考或工具调用。 export function parseDelta(delta: unknown): ParsedDelta { if (!delta || typeof delta !== "object") { return { kind: "unknown" }; @@ -52,6 +54,9 @@ export function parseDelta(delta: unknown): ParsedDelta { }, }; } + if (typeof obj.reasoning === "string") { + return { kind: "reasoning", text: obj.reasoning }; + } if (typeof obj.text === "string") { return { kind: "text", text: obj.text }; } diff --git a/packages/core/chat/reducer.test.ts b/packages/core/chat/reducer.test.ts index 62dbabd..4793998 100644 --- a/packages/core/chat/reducer.test.ts +++ b/packages/core/chat/reducer.test.ts @@ -279,6 +279,52 @@ test("assistant deltas concatenate and hide thinking after text", () => { assert.equal(assistant.streaming, true); }); +test("reasoning deltas concatenate and survive later text and completion", () => { + let state = applyEvent( + emptyState(), + ev({ + seq: 1, + type: "run.state_changed", + payload: { from: "queued", to: "running_llm", reason: "" }, + }), + ); + state = applyEvent(state, ev({ seq: 2, type: "assistant.started", payload: { message_id: "a1" } })); + state = applyEvent( + state, + ev({ seq: 3, type: "assistant.delta", payload: { message_id: "a1", delta: { reasoning: "think " } } }), + ); + assert.equal(state.items.some((item) => item.kind === "thinking"), false); + state = applyEvent( + state, + ev({ seq: 4, type: "assistant.delta", payload: { message_id: "a1", delta: { reasoning: "first" } } }), + ); + state = applyEvent( + state, + ev({ seq: 5, type: "assistant.delta", payload: { message_id: "a1", delta: { text: "pong" } } }), + ); + let assistant = state.items.find((item) => item.kind === "assistant"); + assert.ok(assistant && assistant.kind === "assistant"); + assert.equal(assistant.reasoning, "think first"); + assert.equal(assistant.text, "pong"); + state = applyEvent( + state, + ev({ + seq: 6, + type: "assistant.completed", + payload: { message_id: "a1", text: "pong", reasoning: "think first" }, + }), + ); + state = applyEvent( + state, + ev({ seq: 7, type: "run.completed", payload: { status: "completed", stop_reason: "completed" } }), + ); + assistant = state.items.find((item) => item.kind === "assistant"); + assert.ok(assistant && assistant.kind === "assistant"); + assert.equal(assistant.reasoning, "think first"); + assert.equal(assistant.text, "pong"); + assert.equal(assistant.streaming, false); +}); + test("tool and approval lifecycle", () => { let state = emptyState(); state = applyEvent( @@ -617,6 +663,7 @@ test("decodeText and parseDelta accept backend payloads", () => { assert.equal(decodeText('{"text":"hi"}'), "hi"); assert.equal(decodeText('{"text":""}'), ""); assert.deepEqual(parseDelta({ text: "x" }), { kind: "text", text: "x" }); + assert.deepEqual(parseDelta({ reasoning: "why" }), { kind: "reasoning", text: "why" }); assert.equal(parseDelta({ id: "c1", name: "ping" }).kind, "tool"); }); diff --git a/packages/core/chat/reducer.ts b/packages/core/chat/reducer.ts index 2d2d1bf..f7549cc 100644 --- a/packages/core/chat/reducer.ts +++ b/packages/core/chat/reducer.ts @@ -213,17 +213,20 @@ function applyRunStateChanged(state: SessionState, event: AgentEvent): SessionSt return next; } +// applyAssistantStarted 占一条流式助手气泡,正文和思考都先空着。 function applyAssistantStarted(state: SessionState, event: AgentEvent): SessionState { const payload = event.payload as AssistantStartedPayload; return upsertAssistant(state, { runId: event.run_id, messageId: payload.message_id, text: "", + reasoning: "", streaming: true, seq: event.seq, }); } +// applyAssistantDelta 拼接正文或思考片段;任一到达就撤掉状态字 Thinking。 function applyAssistantDelta(state: SessionState, event: AgentEvent): SessionState { const payload = event.payload as AssistantDeltaPayload; const parsed = parseDelta(payload.delta); @@ -242,6 +245,21 @@ function applyAssistantDelta(state: SessionState, event: AgentEvent): SessionSta } return next; } + if (parsed.kind === "reasoning") { + const existing = findAssistant(state, payload.message_id); + const reasoning = (existing?.reasoning ?? "") + parsed.text; + let next = upsertAssistant(state, { + runId: event.run_id, + messageId: payload.message_id, + reasoning, + streaming: true, + seq: event.seq, + }); + if (reasoning) { + next = removeItem(next, thinkingId(event.run_id)); + } + return next; + } if (parsed.kind === "tool") { return upsertTool(state, { runId: event.run_id, @@ -253,12 +271,14 @@ function applyAssistantDelta(state: SessionState, event: AgentEvent): SessionSta return state; } +// applyAssistantCompleted 收齐本轮正文、思考和待执行工具,并结束流式。 function applyAssistantCompleted(state: SessionState, event: AgentEvent): SessionState { const payload = event.payload as AssistantCompletedPayload; let next = upsertAssistant(state, { runId: event.run_id, messageId: payload.message_id, text: decodeText(payload.text), + reasoning: payload.reasoning, streaming: false, seq: event.seq, }); @@ -559,16 +579,26 @@ function upsertThinking( }); } +// upsertAssistant 更新同一条助手消息;未传入的正文或思考沿用已有值。 function upsertAssistant( state: SessionState, - input: { runId: string; messageId: string; text: string; streaming: boolean; seq: number }, + input: { + runId: string; + messageId: string; + text?: string; + reasoning?: string; + streaming: boolean; + seq: number; + }, ): SessionState { + const existing = findAssistant(state, input.messageId); return upsertItem(state, { kind: "assistant", id: assistantId(input.messageId), runId: input.runId, messageId: input.messageId, - text: input.text, + text: input.text ?? existing?.text ?? "", + reasoning: input.reasoning ?? existing?.reasoning, streaming: input.streaming, seq: input.seq, }); diff --git a/packages/core/chat/types.ts b/packages/core/chat/types.ts index 8b4e9f6..07ab9af 100644 --- a/packages/core/chat/types.ts +++ b/packages/core/chat/types.ts @@ -180,6 +180,7 @@ export interface AssistantDeltaPayload { export interface AssistantCompletedPayload { message_id: string; text: string; + reasoning?: string; tool_calls?: ToolCall[]; } @@ -319,6 +320,7 @@ export type TimelineItem = runId: string; messageId: string; text: string; + reasoning?: string; streaming: boolean; seq: number; } diff --git a/packages/ui/components/message.tsx b/packages/ui/components/message.tsx index 035afb0..23aafd8 100644 --- a/packages/ui/components/message.tsx +++ b/packages/ui/components/message.tsx @@ -37,20 +37,22 @@ export function Message({ ); } +// MessageContent 包住一条消息的正文,行高是字号的 1.5 倍。 export function MessageContent({ className, ...props }: HTMLAttributes) { - return
; + return
; } const plugins = { cjk, code, math, mermaid }; export type MessageResponseProps = ComponentProps; +// MessageResponse 渲染助手的 Markdown 正文,行高与消息正文一致。 export const MessageResponse = memo( function MessageResponse({ className, plugins: extra, ...props }: MessageResponseProps) { return ( *:first-child]:mt-0 [&>*:last-child]:mb-0", + "size-full space-y-1 leading-[1.6] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "[&_[data-streamdown^='heading']]:mt-2.5 [&_[data-streamdown^='heading']]:mb-1", "[&_li]:py-0", "[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground", diff --git a/packages/ui/components/reasoning.tsx b/packages/ui/components/reasoning.tsx index 7a0a9f0..596df20 100644 --- a/packages/ui/components/reasoning.tsx +++ b/packages/ui/components/reasoning.tsx @@ -6,6 +6,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -25,6 +26,7 @@ type ReasoningContextValue = { const ReasoningContext = createContext(null); +// Reasoning 折叠模型思考。流式时展开,这一段结束后收回,避免和正文叠在一起。 export function Reasoning({ className, isStreaming = false, @@ -37,11 +39,15 @@ export function Reasoning({ children: ReactNode; }) { const [open, setOpen] = useState(defaultOpen ?? isStreaming); + const wasStreaming = useRef(isStreaming); useEffect(() => { if (isStreaming) { setOpen(true); + } else if (wasStreaming.current) { + setOpen(false); } + wasStreaming.current = isStreaming; }, [isStreaming]); const value = useMemo(() => ({ isStreaming, isOpen: open }), [isStreaming, open]); @@ -59,6 +65,7 @@ export function Reasoning({ ); } +// ReasoningTrigger 是思考折叠的标题,流式时显示 Thinking。 export function ReasoningTrigger({ children, className, @@ -89,6 +96,7 @@ export function ReasoningTrigger({ ); } +// ReasoningContent 在展开后渲染思考正文。 export function ReasoningContent({ className, children, diff --git a/packages/views/chat/conversation-timeline.tsx b/packages/views/chat/conversation-timeline.tsx index 056167c..37df9ae 100644 --- a/packages/views/chat/conversation-timeline.tsx +++ b/packages/views/chat/conversation-timeline.tsx @@ -13,6 +13,9 @@ import { } from "@codedock/core/chat"; import { cn, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, Conversation, ConversationContent, ConversationEmptyState, @@ -20,6 +23,9 @@ import { Message, MessageContent, MessageResponse, + Reasoning, + ReasoningContent, + ReasoningTrigger, Tool, ToolContent, ToolGroup, @@ -30,9 +36,9 @@ import { ToolOutput, type ToolState, } from "@codedock/ui"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; -import { FileCode2, FileText, GitBranch } from "lucide-react"; +import { BrainIcon, ChevronDownIcon, FileCode2, FileText, GitBranch } from "lucide-react"; import { terminalStatusCopy } from "./lib/terminal.ts"; @@ -88,7 +94,7 @@ export function ConversationTimeline({ (item) => !(item.kind === "approval" && item.status === "pending") && !(item.kind === "user" && !item.text.trim()) && - !(item.kind === "assistant" && !item.text.trim()), + !(item.kind === "assistant" && !item.text.trim() && !item.reasoning?.trim()), ); if (items.length === 0) { if (loading) { @@ -122,28 +128,14 @@ export function ConversationTimeline({ {sections.map((section, sectionIndex) => (
- {section.map((row, rowIndex) => { - const latest = sectionIndex === sections.length - 1 && rowIndex === section.length - 1; - return row.kind === "tools" ? ( - - ) : ( - - ); - })} +
))}
@@ -151,6 +143,140 @@ export function ConversationTimeline({ ); } +// SectionRows 把一轮里的思考和工具收进 Thought,正文、计划和文件改动留在外面。 +function SectionRows({ + section, + latestSection, + latestDocIds, + onOpenPlan, + onOpenFile, + onOpenGit, +}: { + section: TimelineRowModel[]; + latestSection: boolean; + latestDocIds: Set; + onOpenPlan?: (preview: PlanPreview, extra?: { toolState?: ToolItem["state"]; error?: string }) => void; + onOpenFile?: (change: FileChangePreview) => void; + onOpenGit?: () => void; +}) { + const thought: ReactNode[] = []; + const users: ReactNode[] = []; + const body: ReactNode[] = []; + let thoughtLive = false; + let thoughtLatest = false; + const finished = section.some( + (row) => row.kind === "item" && row.item.kind === "terminal", + ); + + section.forEach((row, rowIndex) => { + const latest = latestSection && rowIndex === section.length - 1; + if (row.kind === "tools") { + const live = row.tools.some(isLiveTimelineItem); + thoughtLive = thoughtLive || live; + thoughtLatest = thoughtLatest || latest; + thought.push( + , + ); + body.push( + , + ); + return; + } + + const item = row.item; + if (item.kind === "assistant" && item.reasoning?.trim()) { + thoughtLive = thoughtLive || item.streaming; + if (latest && !item.text.trim()) { + thoughtLatest = true; + } + thought.push( + + + +

{item.reasoning}

+
+
, + ); + } + if (item.kind === "assistant" && !item.text.trim()) { + return; + } + const node = ( + + ); + if (item.kind === "user") { + users.push(node); + return; + } + body.push(node); + }); + + return ( + <> + {users} + {thought.length > 0 ? ( + + {thought} + + ) : null} + {body} + + ); +} + +// Thought 是一轮的外层折叠。先点开才看到思考和 Used tools,再点才是正文和工具详情。 +function Thought({ + live, + latest = false, + children, +}: { + live: boolean; + latest?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(live); + + useEffect(() => { + setOpen(live); + }, [live]); + + return ( +
+ + + + Thought + + + {children} + +
+ ); +} + function TimelineRow({ item, latest = false, @@ -173,12 +299,17 @@ function TimelineRow({
); case "assistant": + if (!item.text.trim()) { + return null; + } return ( - - - {item.text} - - +
+ + + {item.text} + + +
); case "approval": return ( @@ -268,8 +399,10 @@ function rollupToolState(tools: ToolItem[]): ToolState { } // ToolCallsRow 把连续工具收成一组;计划仍用芯片,文件改动排成竖向列表。 +// part 为 group 时只留 Used tools,为 artifacts 时只留计划和文件。 function ToolCallsRow({ tools, + part = "all", latest = false, live = false, latestDocIds, @@ -278,6 +411,7 @@ function ToolCallsRow({ onOpenGit, }: { tools: ToolItem[]; + part?: "all" | "group" | "artifacts"; latest?: boolean; live?: boolean; latestDocIds: Set; @@ -299,49 +433,64 @@ function ToolCallsRow({ } } const files = [...filesByPath.values()]; + const group = ( + + + + {tools.map((item) => { + if (item.name === "explore") { + return ; + } + const dump = compactToolDump(item); + return ( + + + + + + + + ); + })} + + + ); + const artifacts = + plans.length > 0 || files.length > 0 ? ( +
+ {plans.length > 0 ? ( +
+ {plans.map(({ item, preview }) => ( + onOpenPlan(preview, { toolState: item.state, error: item.error }) + : undefined + } + /> + ))} +
+ ) : null} + {files.length > 0 ? ( + + ) : null} +
+ ) : null; + if (part === "group") { + return group; + } + if (part === "artifacts") { + return artifacts; + } return (
0 || files.length > 0 ? "flex flex-col gap-2" : undefined} {...(latest ? { "data-conversation-latest": "" } : {})} > - - - - {tools.map((item) => { - if (item.name === "explore") { - return ; - } - const dump = compactToolDump(item); - return ( - - - - - - - - ); - })} - - - {plans.length > 0 ? ( -
- {plans.map(({ item, preview }) => ( - onOpenPlan(preview, { toolState: item.state, error: item.error }) - : undefined - } - /> - ))} -
- ) : null} - {files.length > 0 ? ( - - ) : null} + {group} + {artifacts}
); } diff --git a/server/pkg/agent/content.go b/server/pkg/agent/content.go index dc60ba6..0214dd6 100644 --- a/server/pkg/agent/content.go +++ b/server/pkg/agent/content.go @@ -9,7 +9,13 @@ import ( // TextContent 是用户与助手消息的统一文本载荷。 type TextContent struct { - Text string `json:"text"` + Text string `json:"text"` + Reasoning string `json:"reasoning,omitempty"` // 模型思考正文,不进助手可见回复 +} + +// ReasoningDelta 是 assistant.delta 里的思考片段。 +type ReasoningDelta struct { + Reasoning string `json:"reasoning"` } // ToolResultContent 是工具结果消息的统一载荷。 @@ -43,6 +49,27 @@ func DecodeText(content json.RawMessage) string { return string(content) } +// EncodeTextContent 把正文和思考一起编码为助手 Content。 +func EncodeTextContent(text, reasoning string) json.RawMessage { + body, err := json.Marshal(TextContent{Text: text, Reasoning: reasoning}) + if err != nil { + return json.RawMessage(`{"text":""}`) + } + return body +} + +// DecodeReasoning 从助手 Content 取出思考正文。 +func DecodeReasoning(content json.RawMessage) string { + if len(content) == 0 { + return "" + } + var payload TextContent + if err := json.Unmarshal(content, &payload); err != nil { + return "" + } + return payload.Reasoning +} + // assistantBlank 判断助手消息是否既无正文也无工具调用。 // 空 content 且没有 tool_calls 的助手消息会被兼容网关拒绝。 func assistantBlank(content json.RawMessage, calls []tool.Call) bool { diff --git a/server/pkg/agent/content_test.go b/server/pkg/agent/content_test.go index 2ac01fd..d643e19 100644 --- a/server/pkg/agent/content_test.go +++ b/server/pkg/agent/content_test.go @@ -19,6 +19,20 @@ func TestDecodeTextEmptyObject(t *testing.T) { } } +// TestEncodeTextContentKeepsReasoningOutOfDecodeText 确认思考写在独立字段,DecodeText 仍只取正文。 +func TestEncodeTextContentKeepsReasoningOutOfDecodeText(t *testing.T) { + got := EncodeTextContent("hi", "think") + if DecodeText(got) != "hi" { + t.Fatalf("text = %q", DecodeText(got)) + } + if DecodeReasoning(got) != "think" { + t.Fatalf("reasoning = %q", DecodeReasoning(got)) + } + if DecodeReasoning(EncodeText("plain")) != "" { + t.Fatal("plain text should have no reasoning") + } +} + func TestCompleteToolResultsFillsMissingCalls(t *testing.T) { messages := []Message{ {Role: RoleUser, Content: EncodeText("hi")}, diff --git a/server/pkg/agent/engine.go b/server/pkg/agent/engine.go index 63f840f..2de173f 100644 --- a/server/pkg/agent/engine.go +++ b/server/pkg/agent/engine.go @@ -211,7 +211,7 @@ func (e *Engine) callLLM(ctx context.Context, in StepInput, _ Instruction) (Step return e.finish(ctx, StepInput{State: state, Job: in.Job}, finishInstructions(RunCancelled, StopCancelled)[0]) } switch event.Type { - case ModelStreamTextDelta, ModelStreamToolDelta: + case ModelStreamTextDelta, ModelStreamReasoningDelta, ModelStreamToolDelta: _ = e.appendFact(ctx, state.RunID, Fact{ Type: EventAssistantDelta, TurnID: state.TurnID, @@ -242,16 +242,17 @@ func (e *Engine) callLLM(ctx context.Context, in StepInput, _ Instruction) (Step } } blank := assistantBlank(assistant.Content, assistant.ToolCalls) - if !blank && len(assistant.Content) == 0 { - assistant.Content = EncodeText("") - } if !blank { + assistant.Content = EncodeTextContent(DecodeText(assistant.Content), result.Reasoning) + } + if !blank || strings.TrimSpace(result.Reasoning) != "" { _ = e.appendFact(ctx, state.RunID, Fact{ Type: EventAssistantCompleted, TurnID: state.TurnID, Payload: MarshalPayload(AssistantCompletedPayload{ MessageID: msgID, Text: DecodeText(assistant.Content), + Reasoning: result.Reasoning, ToolCalls: result.ToolCalls, }), }) diff --git a/server/pkg/agent/event.go b/server/pkg/agent/event.go index 9b420ee..78be4f8 100644 --- a/server/pkg/agent/event.go +++ b/server/pkg/agent/event.go @@ -43,6 +43,7 @@ type AssistantDeltaPayload struct { type AssistantCompletedPayload struct { MessageID string `json:"message_id"` Text string `json:"text"` + Reasoning string `json:"reasoning,omitempty"` ToolCalls []tool.Call `json:"tool_calls,omitempty"` } diff --git a/server/pkg/agent/model.go b/server/pkg/agent/model.go index f303031..175f3c3 100644 --- a/server/pkg/agent/model.go +++ b/server/pkg/agent/model.go @@ -15,10 +15,11 @@ import ( type ModelStreamEventType string const ( - ModelStreamStarted ModelStreamEventType = "started" - ModelStreamTextDelta ModelStreamEventType = "text_delta" - ModelStreamToolDelta ModelStreamEventType = "tool_delta" - ModelStreamCompleted ModelStreamEventType = "completed" + ModelStreamStarted ModelStreamEventType = "started" + ModelStreamTextDelta ModelStreamEventType = "text_delta" + ModelStreamReasoningDelta ModelStreamEventType = "reasoning_delta" + ModelStreamToolDelta ModelStreamEventType = "tool_delta" + ModelStreamCompleted ModelStreamEventType = "completed" ) // Chat 是为一次 Turn 装配的模型调用。 @@ -63,6 +64,7 @@ type ProviderUsage struct { type ModelStreamResult struct { Message Message ToolCalls []tool.Call + Reasoning string // 本轮思考正文,不写进助手可见 content Usage ProviderUsage } diff --git a/server/pkg/agent/openai.go b/server/pkg/agent/openai.go index d900009..1fb49d9 100644 --- a/server/pkg/agent/openai.go +++ b/server/pkg/agent/openai.go @@ -38,10 +38,11 @@ type openaiChatRequest struct { } type openaiChatMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` } type openaiTool struct { @@ -67,8 +68,10 @@ type openaiStreamChunk struct { ID string `json:"id"` Choices []struct { Delta struct { - Content string `json:"content"` - ToolCalls []openaiToolCall `json:"tool_calls"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + Reasoning string `json:"reasoning"` + ToolCalls []openaiToolCall `json:"tool_calls"` } `json:"delta"` } `json:"choices"` Usage *struct { @@ -204,6 +207,14 @@ func developerWireRole(model ModelConfig) string { return "system" } +// reasoningDelta 取出本段思考;兼容 reasoning_content 与 reasoning 两种字段。 +func reasoningDelta(content, alt string) string { + if content != "" { + return content + } + return alt +} + // consumeOpenAI 解析 SSE 增量,拼出最终文本、工具调用和用量后关闭流。 // 读完后若请求已取消,把空输出当成流失败,避免打断被记成一次成功的空回复。 func consumeOpenAI(ctx context.Context, chat Chat, body io.ReadCloser, stream *staticStream) { @@ -215,6 +226,7 @@ func consumeOpenAI(ctx context.Context, chat Chat, body io.ReadCloser, stream *s stream.events <- ModelStreamEvent{Type: ModelStreamStarted, OccurredAt: now} var text strings.Builder + var reasoning strings.Builder var calls []tool.Call usage := ProviderUsage{Provider: "openai", Model: chat.Model.Model, RequestID: chat.TurnID} scanner := bufio.NewScanner(body) @@ -240,6 +252,14 @@ func consumeOpenAI(ctx context.Context, chat Chat, body io.ReadCloser, stream *s usage.RequestID = chunk.ID } for _, choice := range chunk.Choices { + if piece := reasoningDelta(choice.Delta.ReasoningContent, choice.Delta.Reasoning); piece != "" { + reasoning.WriteString(piece) + stream.events <- ModelStreamEvent{ + Type: ModelStreamReasoningDelta, + Delta: MarshalPayload(ReasoningDelta{Reasoning: piece}), + OccurredAt: time.Now().UTC(), + } + } if choice.Delta.Content != "" { text.WriteString(choice.Delta.Content) stream.events <- ModelStreamEvent{ @@ -303,6 +323,7 @@ func consumeOpenAI(ctx context.Context, chat Chat, body io.ReadCloser, stream *s ToolCalls: calls, }, ToolCalls: calls, + Reasoning: reasoning.String(), Usage: usage, } stream.events <- ModelStreamEvent{Type: ModelStreamCompleted, OccurredAt: time.Now().UTC()} @@ -320,7 +341,11 @@ func toOpenAIMessages(chat Chat) []openaiChatMessage { if assistantBlank(msg.Content, msg.ToolCalls) { continue } - item := openaiChatMessage{Role: "assistant", Content: DecodeText(msg.Content)} + item := openaiChatMessage{ + Role: "assistant", + Content: DecodeText(msg.Content), + ReasoningContent: DecodeReasoning(msg.Content), + } for _, call := range msg.ToolCalls { item.ToolCalls = append(item.ToolCalls, openaiToolCall{ ID: call.ID, diff --git a/server/pkg/agent/openai_test.go b/server/pkg/agent/openai_test.go index a5a2a7d..cc5bdf3 100644 --- a/server/pkg/agent/openai_test.go +++ b/server/pkg/agent/openai_test.go @@ -159,6 +159,36 @@ func TestStreamOpenAI(t *testing.T) { t.Fatalf("%+v %v", got, err) } }) + t.Run("sse reasoning and text", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think \"}}]}\n\n")) + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"first\"}}]}\n\n")) + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"pong\"}}]}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + opts, _ := json.Marshal(map[string]string{"api_key": "k", "base_url": server.URL}) + stream, err := Stream(context.Background(), Chat{ + TurnID: "t-reason", + Model: ModelConfig{Provider: "openai", Model: "gpt", Options: opts}, + }) + if err != nil { + t.Fatal(err) + } + defer stream.Close() + var kinds []string + for event := range stream.Events() { + kinds = append(kinds, string(event.Type)) + } + got, err := stream.Result(context.Background()) + if err != nil || DecodeText(got.Message.Content) != "pong" || got.Reasoning != "think first" { + t.Fatalf("%+v %v kinds=%v", got, err, kinds) + } + if len(kinds) < 3 || kinds[1] != string(ModelStreamReasoningDelta) { + t.Fatalf("kinds=%v", kinds) + } + }) t.Run("http error", func(t *testing.T) { opts, _ := json.Marshal(map[string]string{"api_key": "k", "base_url": "http://127.0.0.1:1"}) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) @@ -244,6 +274,19 @@ func TestToOpenAIMessagesSkipsBlankAssistant(t *testing.T) { } } +// TestToOpenAIMessagesSendsReasoningContent 确认思考回传在 reasoning_content,不拼进 content。 +func TestToOpenAIMessagesSendsReasoningContent(t *testing.T) { + msgs := toOpenAIMessages(Chat{ + Messages: []Message{ + {Role: RoleAssistant, Content: EncodeTextContent("pong", "think first"), ToolCalls: []tool.Call{{ID: "c1", Name: "ping", Arguments: json.RawMessage(`{}`)}}}, + {Role: RoleUser, Content: EncodeText("again")}, + }, + }) + if len(msgs) != 2 || msgs[0].Content != "pong" || msgs[0].ReasoningContent != "think first" || msgs[1].Role != "user" { + t.Fatalf("%+v", msgs) + } +} + // TestConsumeOpenAICancelAfterCleanEOF 确认请求已取消时,干净结束的空流不算成功回复。 func TestConsumeOpenAICancelAfterCleanEOF(t *testing.T) { ctx, cancel := context.WithCancel(context.Background())