From c6607065b9fa5264d14ce1d802e1897b5b475901 Mon Sep 17 00:00:00 2001 From: Piotr Rogowski Date: Sun, 26 Jan 2025 02:11:56 +0100 Subject: [PATCH] Add support for displaying reasoning for openrouter models --- src/api/providers/openrouter.ts | 8 +++ src/api/transform/stream.ts | 7 +- src/core/Cline.ts | 7 +- src/shared/ExtensionMessage.ts | 2 + webview-ui/src/components/chat/ChatRow.tsx | 17 +++++ .../src/components/chat/ReasoningBlock.tsx | 70 +++++++++++++++++++ 6 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 webview-ui/src/components/chat/ReasoningBlock.tsx diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 5e15cb2..4cb96cb 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -10,6 +10,7 @@ import delay from "delay" // Add custom interface for OpenRouter params type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { transforms?: string[] + include_reasoning?: boolean } // Add custom interface for OpenRouter usage chunk @@ -126,6 +127,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler { temperature: temperature, messages: openAiMessages, stream: true, + include_reasoning: true, // This way, the transforms field will only be included in the parameters when openRouterUseMiddleOutTransform is true. ...(this.options.openRouterUseMiddleOutTransform && { transforms: ["middle-out"] }), } as OpenRouterChatCompletionParams) @@ -145,6 +147,12 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler { } const delta = chunk.choices[0]?.delta + if ("reasoning" in delta && delta.reasoning) { + yield { + type: "reasoning", + text: delta.reasoning, + } as ApiStreamChunk + } if (delta?.content) { fullResponseText += delta.content yield { diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index 0290201..97751ed 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -1,11 +1,16 @@ export type ApiStream = AsyncGenerator -export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamUsageChunk +export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamUsageChunk | ApiStreamReasoningChunk export interface ApiStreamTextChunk { type: "text" text: string } +export interface ApiStreamReasoningChunk { + type: "reasoning" + text: string +} + export interface ApiStreamUsageChunk { type: "usage" inputTokens: number diff --git a/src/core/Cline.ts b/src/core/Cline.ts index b5deecc..aaf9890 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -2219,7 +2219,7 @@ export class Cline { } /* - Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present. + Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present. When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI. */ this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked @@ -2391,9 +2391,14 @@ export class Cline { const stream = this.attemptApiRequest(previousApiReqIndex) // yields only if the first chunk is successful, otherwise will allow the user to retry the request (most likely due to rate limit error, which gets thrown on the first chunk) let assistantMessage = "" + let reasoningMessage = "" try { for await (const chunk of stream) { switch (chunk.type) { + case "reasoning": + reasoningMessage += chunk.text + await this.say("reasoning", reasoningMessage, undefined, true) + break case "usage": inputTokens += chunk.inputTokens outputTokens += chunk.outputTokens diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 88724e6..e8f61b3 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -121,6 +121,7 @@ export interface ClineMessage { text?: string images?: string[] partial?: boolean + reasoning?: string } export type ClineAsk = @@ -142,6 +143,7 @@ export type ClineSay = | "api_req_started" | "api_req_finished" | "text" + | "reasoning" | "completion_result" | "user_feedback" | "user_feedback_diff" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 2757947..530e31f 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -15,6 +15,7 @@ import { vscode } from "../../utils/vscode" import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian" import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" import MarkdownBlock from "../common/MarkdownBlock" +import ReasoningBlock from "./ReasoningBlock" import Thumbnails from "../common/Thumbnails" import McpResourceRow from "../mcp/McpResourceRow" import McpToolRow from "../mcp/McpToolRow" @@ -79,6 +80,14 @@ export const ChatRowContent = ({ isStreaming, }: ChatRowContentProps) => { const { mcpServers } = useExtensionState() + const [reasoningCollapsed, setReasoningCollapsed] = useState(false) + + // Auto-collapse reasoning when new messages arrive + useEffect(() => { + if (!isLast && message.say === "reasoning") { + setReasoningCollapsed(true) + } + }, [isLast, message.say]) const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => { if (message.text != null && message.say === "api_req_started") { const info: ClineApiReqInfo = JSON.parse(message.text) @@ -472,6 +481,14 @@ export const ChatRowContent = ({ switch (message.type) { case "say": switch (message.say) { + case "reasoning": + return ( + setReasoningCollapsed(!reasoningCollapsed)} + /> + ) case "api_req_started": return ( <> diff --git a/webview-ui/src/components/chat/ReasoningBlock.tsx b/webview-ui/src/components/chat/ReasoningBlock.tsx new file mode 100644 index 0000000..0c9971f --- /dev/null +++ b/webview-ui/src/components/chat/ReasoningBlock.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useRef } from "react" +import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" +import MarkdownBlock from "../common/MarkdownBlock" + +interface ReasoningBlockProps { + content: string + isCollapsed?: boolean + onToggleCollapse?: () => void + autoHeight?: boolean +} + +const ReasoningBlock: React.FC = ({ + content, + isCollapsed = false, + onToggleCollapse, + autoHeight = false, +}) => { + const contentRef = useRef(null) + + // Scroll to bottom when content updates + useEffect(() => { + if (contentRef.current && !isCollapsed) { + contentRef.current.scrollTop = contentRef.current.scrollHeight + } + }, [content, isCollapsed]) + + return ( +
+
+ Reasoning + +
+ {!isCollapsed && ( +
+
+ +
+
+ )} +
+ ) +} + +export default ReasoningBlock