diff --git a/src/app/api/agent/route.ts b/src/app/api/agent/route.ts index 406a9e7..a73aa8a 100755 --- a/src/app/api/agent/route.ts +++ b/src/app/api/agent/route.ts @@ -103,6 +103,7 @@ export async function POST(req: Request): Promise { ...githubTools, ...pluginTools, }, + toolChoice: "auto", stopWhen: stepCountIs(10), onError({ error }) { const apiErr = unwrapAPICallError(error) diff --git a/src/components/agent/chat-view.tsx b/src/components/agent/chat-view.tsx index 55417cb..725cf33 100755 --- a/src/components/agent/chat-view.tsx +++ b/src/components/agent/chat-view.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useCallback, useRef, useEffect } from "react" +import { useState, useCallback, useRef, useEffect, memo } from "react" import { CopyIcon, ThumbsUpIcon, @@ -20,9 +20,20 @@ import { IconAlertCircle, IconEye, } from "@tabler/icons-react" -import type { ToolUIPart } from "ai" +import { + isTextUIPart, + isToolUIPart, + isReasoningUIPart, + type UIMessage, + type ToolUIPart, + type DynamicToolUIPart, +} from "ai" import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" +import { + Reasoning, + ReasoningTrigger, + ReasoningContent, +} from "@/components/ai/reasoning" import { Conversation, ConversationContent, @@ -160,102 +171,175 @@ function friendlyToolName(raw: string): string { return TOOL_DISPLAY_NAMES[raw] ?? raw } -// shared message + tool rendering for both variants -function ChatMessage({ - msg, - copiedId, - onCopy, - onRegenerate, -}: { - readonly msg: { - readonly id: string - readonly role: string - readonly parts: ReadonlyArray - } +interface ChatMessageProps { + readonly msg: UIMessage readonly copiedId: string | null - onCopy: (id: string, text: string) => void - onRegenerate: () => void -}) { - if (msg.role === "user") { - const text = getTextContent(msg.parts) - return ( - - {text} - - ) + readonly onCopy: (id: string, text: string) => void + readonly onRegenerate: () => void + readonly isStreaming?: boolean +} + +type AnyToolPart = ToolUIPart | DynamicToolUIPart + +function extractToolName(part: AnyToolPart): string { + if (part.type === "dynamic-tool") { + return part.toolName ?? "" } + return part.type.slice(5) +} - const textParts: string[] = [] - const toolParts: Array<{ - type: string - state: ToolUIPart["state"] - toolName: string - input: unknown - output: unknown - errorText?: string - }> = [] - - for (const part of msg.parts) { - const p = part as Record - if (p.type === "text" && typeof p.text === "string") { - textParts.push(p.text) +// renders parts in their natural order from the AI SDK +const ChatMessage = memo( + function ChatMessage({ + msg, + copiedId, + onCopy, + onRegenerate, + isStreaming: msgStreaming = false, + }: ChatMessageProps) { + if (msg.role === "user") { + const text = msg.parts + .filter(isTextUIPart) + .map((p) => p.text) + .join("") + return ( + + {text} + + ) } - const pType = p.type as string | undefined - // handle static (tool-) and dynamic - // (dynamic-tool) tool parts - if ( - typeof pType === "string" && - (pType.startsWith("tool-") || - pType === "dynamic-tool") - ) { - // extract tool name from type field or toolName - const rawName = pType.startsWith("tool-") - ? pType.slice(5) - : ((p.toolName ?? "") as string) - toolParts.push({ - type: pType, - state: p.state as ToolUIPart["state"], - toolName: - friendlyToolName(rawName) || "Working", - input: p.input, - output: p.output, - errorText: p.errorText as string | undefined, - }) + + // walk parts sequentially, flushing text when + // hitting a tool or reasoning part to preserve + // interleaving. text flushed before the final + // segment is "thinking" (intermediate chain-of- + // thought) and rendered muted + collapsible. + const elements: React.ReactNode[] = [] + let pendingText = "" + let allText = "" + let pendingReasoning = "" + let reasoningStreaming = false + + let sawToolPart = false + + const flushThinking = ( + text: string, + idx: number, + streaming = false + ) => { + if (!text) return + elements.push( + + + {text} + + ) } - } - const text = textParts.join("") - - return ( - - {toolParts.map((tp, i) => ( - - - - - {(tp.state === "output-available" || - tp.state === "output-error") && ( - - )} - - - ))} - {text ? ( - <> - - {text} + const flushText = (idx: number, isFinal: boolean) => { + if (!pendingText) return + if (!isFinal) { + // intermediate text before more tools = thinking + flushThinking(pendingText, idx) + } else { + elements.push( + + + {pendingText} + + ) + } + pendingText = "" + } + + for (let i = 0; i < msg.parts.length; i++) { + const part = msg.parts[i] + + if (isReasoningUIPart(part)) { + pendingReasoning += part.text + reasoningStreaming = part.state === "streaming" + continue + } + + if (isTextUIPart(part)) { + pendingText += part.text + allText += part.text + continue + } + + if (isToolUIPart(part)) { + sawToolPart = true + // flush reasoning accumulated before this tool + flushThinking(pendingReasoning, i, reasoningStreaming) + pendingReasoning = "" + reasoningStreaming = false + // flush text as thinking (not final) + flushText(i, false) + const tp = part as AnyToolPart + const rawName = extractToolName(tp) + elements.push( + + + + + {(tp.state === "output-available" || + tp.state === "output-error") && ( + + )} + + + ) + } + } + + // flush remaining reasoning + flushThinking( + pendingReasoning, + msg.parts.length, + reasoningStreaming + ) + + // while streaming, if no tool calls have arrived yet + // and text is substantial, it's likely chain-of-thought + // that'll be reclassified as thinking once tools come in. + // render it collapsed so it doesn't flood the screen. + const COT_THRESHOLD = 500 + if ( + msgStreaming && + !sawToolPart && + pendingText.length > COT_THRESHOLD + ) { + flushThinking(pendingText, msg.parts.length, true) + pendingText = "" + } + + // flush remaining text as the final response + flushText(msg.parts.length, true) + + const hasContent = elements.length > 0 + + return ( + + {hasContent ? elements : } + {allText && ( onCopy(msg.id, text)} + onClick={() => onCopy(msg.id, allText)} > {copiedId === msg.id ? ( @@ -276,25 +360,23 @@ function ChatMessage({ - - ) : ( - - )} - - ) -} - -function getTextContent( - parts: ReadonlyArray -): string { - return (parts as ReadonlyArray<{ type: string; text?: string }>) - .filter( - (p): p is { type: "text"; text: string } => - p.type === "text" + )} + ) - .map((p) => p.text) - .join("") -} + }, + (prev, next) => { + if (prev.msg !== next.msg) return false + if (prev.onCopy !== next.onCopy) return false + if (prev.onRegenerate !== next.onRegenerate) + return false + if (prev.isStreaming !== next.isStreaming) + return false + const prevCopied = prev.copiedId === prev.msg.id + const nextCopied = next.copiedId === next.msg.id + if (prevCopied !== nextCopied) return false + return true + } +) function ChatInput({ textareaRef, @@ -533,6 +615,21 @@ export function ChatView({ variant }: ChatViewProps) { [isPage, chat.sendMessage] ) + const handleIdleSend = useCallback( + (text: string) => { + setIsActive(true) + chat.sendMessage({ text }) + }, + [chat.sendMessage] + ) + + const handleActiveSend = useCallback( + (text: string) => { + chat.sendMessage({ text }) + }, + [chat.sendMessage] + ) + const suggestions = isPage ? DASHBOARD_SUGGESTIONS : getSuggestionsForPath(chat.pathname) @@ -593,10 +690,7 @@ export function ChatView({ variant }: ChatViewProps) { recorder={recorder} status={chat.status} isGenerating={chat.isGenerating} - onSend={(text) => { - setIsActive(true) - chat.sendMessage({ text }) - }} + onSend={handleIdleSend} className="rounded-2xl" /> @@ -652,13 +746,19 @@ export function ChatView({ variant }: ChatViewProps) { {chat.messages.length > 0 ? ( - {chat.messages.map((msg) => ( + {chat.messages.map((msg, idx) => ( ))} @@ -698,9 +798,7 @@ export function ChatView({ variant }: ChatViewProps) { recorder={recorder} status={chat.status} isGenerating={chat.isGenerating} - onSend={(text) => - chat.sendMessage({ text }) - } + onSend={handleActiveSend} onNewChat={chat.messages.length > 0 ? chat.newChat : undefined} className="rounded-2xl" /> @@ -729,13 +827,19 @@ export function ChatView({ variant }: ChatViewProps) { ) : ( - chat.messages.map((msg) => ( + chat.messages.map((msg, idx) => ( )) )} @@ -751,9 +855,7 @@ export function ChatView({ variant }: ChatViewProps) { recorder={recorder} status={chat.status} isGenerating={chat.isGenerating} - onSend={(text) => - chat.sendMessage({ text }) - } + onSend={handleActiveSend} onNewChat={chat.messages.length > 0 ? chat.newChat : undefined} /> diff --git a/src/components/ai/reasoning.tsx b/src/components/ai/reasoning.tsx index 1499bd9..d1c7f44 100755 --- a/src/components/ai/reasoning.tsx +++ b/src/components/ai/reasoning.tsx @@ -3,7 +3,7 @@ import { useControllableState } from "@radix-ui/react-use-controllable-state" import { BrainIcon, ChevronDownIcon } from "lucide-react" import type { ComponentProps, ReactNode } from "react" -import { createContext, memo, useContext, useEffect, useState } from "react" +import { createContext, memo, useContext, useEffect, useRef, useState } from "react" import { Streamdown } from "streamdown" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" import { cn } from "@/lib/utils" @@ -34,7 +34,7 @@ export type ReasoningProps = ComponentProps & { duration?: number } -const AUTO_CLOSE_DELAY = 1000 +const CLOSE_GRACE_MS = 300 const MS_IN_S = 1000 export const Reasoning = memo( @@ -73,14 +73,13 @@ export const Reasoning = memo( } }, [isStreaming, startTime, setDuration]) - // Auto-open when streaming starts, auto-close when streaming ends (once only) + // close gracefully once streaming finishes useEffect(() => { if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) { - // Add a small delay before closing to allow user to see the content const timer = setTimeout(() => { setIsOpen(false) setHasAutoClosed(true) - }, AUTO_CLOSE_DELAY) + }, CLOSE_GRACE_MS) return () => clearTimeout(timer) } @@ -154,18 +153,35 @@ export type ReasoningContentProps = ComponentProps & children: string } -export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => ( - - {children} - -)) +export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => { + const { isStreaming } = useReasoning() + const scrollRef = useRef(null) + + // auto-scroll to show latest tokens during streaming + useEffect(() => { + if (isStreaming && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [children, isStreaming]) + + return ( + +
+ {children} +
+
+ ) +}) Reasoning.displayName = "Reasoning" ReasoningTrigger.displayName = "ReasoningTrigger" diff --git a/src/lib/agent/system-prompt.ts b/src/lib/agent/system-prompt.ts index f0103df..407e8c7 100755 --- a/src/lib/agent/system-prompt.ts +++ b/src/lib/agent/system-prompt.ts @@ -5,22 +5,6 @@ import type { PromptSection } from "@/lib/agent/plugins/types" type PromptMode = "full" | "minimal" | "none" -type ToolCategory = - | "data" - | "navigation" - | "ui" - | "memory" - | "github" - | "skills" - | "feedback" - -interface ToolMeta { - readonly name: string - readonly summary: string - readonly category: ToolCategory - readonly adminOnly?: true -} - interface DashboardSummary { readonly id: string readonly name: string @@ -38,6 +22,22 @@ interface PromptContext { readonly mode?: PromptMode } +type ToolCategory = + | "data" + | "navigation" + | "ui" + | "memory" + | "github" + | "skills" + | "feedback" + +interface ToolMeta { + readonly name: string + readonly summary: string + readonly category: ToolCategory + readonly adminOnly?: true +} + interface DerivedState { readonly mode: PromptMode readonly page: string @@ -665,6 +665,10 @@ function buildGuidelines( return [ ...core, + "- Tool workflow: data requests -> queryData immediately. " + + "Navigation -> navigateTo, brief confirmation. " + + "Dashboards -> queryData first, then generateUI. " + + "Memories -> save proactively with rememberContext.", '- "How\'s development going?" means fetch repo_stats and ' + 'recent commits right now, not "Would you like to see ' + 'commits or PRs?"',