diff --git a/frontend/public/badge-kimi-logo.png b/frontend/public/badge-kimi-logo.png new file mode 100644 index 00000000..f8602db9 Binary files /dev/null and b/frontend/public/badge-kimi-logo.png differ diff --git a/frontend/src/components/Marketing.tsx b/frontend/src/components/Marketing.tsx index 07d93f37..f0358b13 100644 --- a/frontend/src/components/Marketing.tsx +++ b/frontend/src/components/Marketing.tsx @@ -27,6 +27,11 @@ const AI_MODELS = [ alt: "DeepSeek", labels: ["DeepSeek R1"] }, + { + src: "/badge-kimi-logo.png", + alt: "Moonshot", + labels: ["Kimi K2"] + }, { src: "/badge-qwen-logo.png", alt: "Qwen", labels: ["Qwen3 Coder", "Qwen3-VL"] }, { src: "/badge-meta-logo.png", alt: "Meta", labels: ["Meta Llama"] } ]; @@ -428,7 +433,7 @@ export function Marketing() { We use full-size open models from the biggest providers.

-
+
{AI_MODELS.map((model) => (
= { requiresPro: true, tokenLimit: 130000 }, + "kimi-k2-thinking": { + displayName: "Kimi K2 Thinking", + shortName: "Kimi K2", + badges: ["Pro", "Reasoning", "New"], + requiresPro: true, + tokenLimit: 256000 + }, "gpt-oss-120b": { displayName: "OpenAI GPT-OSS 120B", shortName: "GPT-OSS", @@ -106,8 +113,8 @@ type ModelCategory = "free" | "quick" | "reasoning" | "math" | "image" | "advanc export const CATEGORY_MODELS = { free: "llama-3.3-70b", quick: "gpt-oss-120b", - reasoning_on: "deepseek-r1-0528", // R1 with thinking - reasoning_off: "deepseek-r1-0528", // R1 without thinking (brain toggle temporarily disabled) + reasoning_on: "kimi-k2-thinking", // Kimi K2 with thinking + reasoning_off: "deepseek-r1-0528", // DeepSeek R1 without thinking math: "qwen3-coder-480b", image: "qwen3-vl-30b" // Qwen3-VL for image analysis }; diff --git a/frontend/src/components/UnifiedChat.tsx b/frontend/src/components/UnifiedChat.tsx index f72d8076..7ee2c5ba 100644 --- a/frontend/src/components/UnifiedChat.tsx +++ b/frontend/src/components/UnifiedChat.tsx @@ -50,7 +50,7 @@ import { useIsMobile } from "@/utils/utils"; import { fileToDataURL } from "@/utils/file"; import { useOpenAI } from "@/ai/useOpenAi"; import { DEFAULT_MODEL_ID } from "@/state/LocalStateContext"; -import { Markdown } from "@/components/markdown"; +import { Markdown, ThinkingBlock } from "@/components/markdown"; import { ModelSelector, CATEGORY_MODELS } from "@/components/ModelSelector"; import { useLocalState } from "@/state/useLocalState"; import { useOpenSecret } from "@opensecret/react"; @@ -103,13 +103,24 @@ type ExtendedMessage = OpenAIMessage & { status?: "completed" | "in_progress" | "incomplete" | "streaming" | "error"; }; -// Union type for all possible conversation items (messages, tool calls, tool outputs, web search) +// Reasoning item type for model thinking/reasoning (e.g., Kimi K2) +type ReasoningContentItem = { type: "text"; text: string }; +type ReasoningItem = { + type: "reasoning"; + id: string; + content: ReasoningContentItem[]; + status?: "completed" | "in_progress" | "incomplete" | "streaming"; + created_at?: number; +}; + +// Union type for all possible conversation items (messages, tool calls, tool outputs, web search, reasoning) // This combines OpenAI's native types with response streaming types type Message = | ExtendedMessage | (ResponseFunctionWebSearch & { id: string }) | (ResponseFunctionToolCall & { id: string }) - | (ResponseFunctionToolCallOutputItem & { id: string }); + | (ResponseFunctionToolCallOutputItem & { id: string }) + | ReasoningItem; // Helper function to merge messages while ensuring uniqueness by ID // This prevents duplicate key warnings in React by deduplicating messages @@ -350,6 +361,11 @@ function ToolCallRenderer({ return null; } +// Types for grouping messages into turns +type MessageGroup = + | { type: "user"; message: ExtendedMessage; id: string } + | { type: "assistant"; items: Message[]; id: string }; + // Memoized message list component to prevent re-renders on input changes const MessageList = memo( ({ @@ -366,7 +382,6 @@ const MessageList = memo( isLoadingOlderMessages?: boolean; }) => { // Build Maps for O(1) lookup of tool calls and outputs by call_id - // This handles out-of-order tool calls/outputs (e.g., parallel tool execution) const { callMap, outputMap } = useMemo(() => { const calls = new Map(); const outputs = new Map(); @@ -382,6 +397,191 @@ const MessageList = memo( return { callMap: calls, outputMap: outputs }; }, [messages]); + // Group messages into user turns and assistant turns + // Assistant turns include: reasoning, tool calls, tool outputs, web search, and assistant messages + const groupedMessages = useMemo(() => { + const groups: MessageGroup[] = []; + let currentAssistantItems: Message[] = []; + + for (const item of messages) { + // Check if this is a user message + if (item.type === "message" && (item as unknown as ExtendedMessage).role === "user") { + // Flush any pending assistant items first + if (currentAssistantItems.length > 0) { + groups.push({ + type: "assistant", + items: currentAssistantItems, + id: `assistant-${currentAssistantItems[0].id}` + }); + currentAssistantItems = []; + } + groups.push({ + type: "user", + message: item as unknown as ExtendedMessage, + id: item.id + }); + } else { + // This is an assistant-related item (reasoning, tool calls, assistant message, etc.) + currentAssistantItems.push(item); + } + } + + // Don't forget trailing assistant items + if (currentAssistantItems.length > 0) { + groups.push({ + type: "assistant", + items: currentAssistantItems, + id: `assistant-${currentAssistantItems[0].id}` + }); + } + + return groups; + }, [messages]); + + // Helper to render an individual item within an assistant group + const renderAssistantItem = (item: Message) => { + const itemType = item.type; + + // Tool calls - render with pairing + if (itemType === "function_call") { + const toolCall = item as unknown as ResponseFunctionToolCall; + const output = outputMap.get(toolCall.call_id) as + | ResponseFunctionToolCallOutputItem + | undefined; + + return ( +
+ +
+ ); + } + + // Tool outputs - skip if already rendered with call + if (itemType === "function_call_output") { + const output = item as unknown as ResponseFunctionToolCallOutputItem; + const matchingCall = callMap.get(output.call_id); + + if (matchingCall) { + return null; // Already rendered with the call + } + return ( +
+ +
+ ); + } + + // Web search + if (itemType === "web_search_call") { + const webSearch = item as unknown as ResponseFunctionWebSearch; + return ( +
+ +
+ ); + } + + // Reasoning - render with ThinkingBlock + if (itemType === "reasoning") { + const reasoning = item as ReasoningItem; + const text = reasoning.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join(""); + const isThinking = reasoning.status === "in_progress" || reasoning.status === "streaming"; + + return ( +
+ +
+ ); + } + + // Assistant message content + if (itemType === "message") { + const message = item as unknown as ExtendedMessage; + if (message.role !== "assistant") return null; + + const isAssistantLoading = message.status === "in_progress"; + if ((!message.content || message.content.length === 0) && !isAssistantLoading) { + return null; + } + + return ( +
+
+
+ {message.content?.map((part, partIdx) => { + if ( + (part.type === "input_text" || + part.type === "output_text" || + part.type === "text") && + "text" in part && + part.text + ) { + return ( +
+ +
+ ); + } + if (part.type === "input_image" && "image_url" in part && part.image_url) { + return ( +
+ {`Image +
+ ); + } + return null; + })} +
+
+ + {/* Status indicators */} + {message.status === "in_progress" && ( +
+
+
+
+
+ )} + {message.status === "incomplete" && ( +
+
+ Chat Canceled +
+ )} +
+ ); + } + + return null; + }; + + // Get all text content from an assistant group for the copy button + const getAssistantGroupText = (items: Message[]) => { + return items + .filter((item) => item.type === "message") + .flatMap((item) => { + const message = item as unknown as ExtendedMessage; + return ( + message.content + ?.filter((p) => "text" in p && p.text) + .map((p) => ("text" in p ? p.text : "")) || [] + ); + }) + .join(""); + }; + return ( <> {/* Loading indicator for older messages */} @@ -395,107 +595,29 @@ const MessageList = memo(
)} - {messages.map((item, index) => { - // Handle different item types - messages, tool calls, tool outputs - const itemType = item.type; - - // Tool calls and outputs - render as standalone items with pairing - if (itemType === "function_call") { - const toolCall = item as unknown as ResponseFunctionToolCall; - // Look up matching output by call_id (handles out-of-order arrival) - const output = outputMap.get(toolCall.call_id) as - | ResponseFunctionToolCallOutputItem - | undefined; + {groupedMessages.map((group, groupIndex) => { + if (group.type === "user") { + const message = group.message; + if (!message.content || message.content.length === 0) return null; return (
-
- -
-
- ); - } - - if (itemType === "function_call_output") { - const output = item as unknown as ResponseFunctionToolCallOutputItem; - // Check if matching call exists (handles out-of-order arrival) - const matchingCall = callMap.get(output.call_id); - - if (matchingCall) { - // Already rendered with the call, skip - return null; - } else { - // Orphan output (call hasn't arrived yet), render standalone - return ( -
-
- -
-
- ); - } - } - - if (itemType === "web_search_call") { - const webSearch = item as unknown as ResponseFunctionWebSearch; - return ( -
-
- -
-
- ); - } - - // Regular message - render with role and content - if (itemType === "message") { - const message = item as unknown as ExtendedMessage; - // Skip if no content, UNLESS it's an assistant message with in_progress status - // (we want to show the three-dot loading indicator for those) - const isAssistantLoading = - message.role === "assistant" && message.status === "in_progress"; - if ((!message.content || message.content.length === 0) && !isAssistantLoading) - return null; - - return ( -
- {message.role === "user" ? ( -
- -
- ) : ( -
- -
- )} +
+ +
-
- {message.role === "user" ? "You" : "Maple"} -
+
You
{message.content.map((part, partIdx) => { - // Text content if ( (part.type === "input_text" || part.type === "output_text" || @@ -505,18 +627,11 @@ const MessageList = memo( ) { return (
- +
); } - // Image content - else if ( + if ( part.type === "input_image" && "image_url" in part && part.image_url @@ -536,35 +651,52 @@ const MessageList = memo( })}
+
+
+
+
+ ); + } - {/* Status indicators */} - {message.role === "assistant" && message.status === "in_progress" && ( -
-
-
-
-
- )} - {message.role === "assistant" && message.status === "incomplete" && ( -
-
- Chat Canceled + // Assistant group - render all items in one Maple box + if (group.type === "assistant") { + const hasContent = group.items.some((item) => { + if (item.type === "message") { + const msg = item as unknown as ExtendedMessage; + return ( + msg.role === "assistant" && + (msg.content?.length > 0 || msg.status === "in_progress") + ); + } + return true; // reasoning, tool calls always count + }); + + if (!hasContent) return null; + + const textContent = getAssistantGroupText(group.items); + + return ( +
+
+
+
+ +
+
+
+
+
Maple
+ {group.items.map((item) => renderAssistantItem(item))} + {/* Copy button for the assistant's text content */} + {textContent && ( +
+
)} - - {/* Actions - always visible on mobile, show on hover for desktop */} - {message.role === "assistant" && - message.content && - message.content.length > 0 && ( -
- "text" in p && p.text) - .map((p) => ("text" in p ? p.text : "")) - .join("")} - /> -
- )}
@@ -572,37 +704,60 @@ const MessageList = memo( ); } - // Unknown item type return null; })} {/* Loading indicator - modern style */} - {isGenerating && - !messages.some( + {/* Only show when generating AND no assistant content is being rendered */} + {(() => { + // Check if the last item is tool-related (means we're in tool phase, already rendering in Maple box) + const lastItem = messages[messages.length - 1]; + const isLastItemToolRelated = + lastItem && + (lastItem.type === "function_call" || + lastItem.type === "function_call_output" || + lastItem.type === "web_search_call"); + + const hasStreamingAssistant = messages.some( (item) => item.type === "message" && (item as unknown as ExtendedMessage).role === "assistant" && ((item as { status?: string }).status === "streaming" || (item as { status?: string }).status === "in_progress") - ) && ( -
-
-
-
- -
+ ); + + const hasStreamingReasoning = messages.some( + (item) => + item.type === "reasoning" && + ((item as { status?: string }).status === "streaming" || + (item as { status?: string }).status === "in_progress") + ); + + return ( + isGenerating && + !hasStreamingAssistant && + !hasStreamingReasoning && + !isLastItemToolRelated + ); + })() && ( +
+
+
+
+
-
-
Maple
-
-
-
-
-
+
+
+
Maple
+
+
+
+
- )} +
+ )} ); } @@ -1651,7 +1806,9 @@ export function UnifiedChat() { // Helper function to process streaming response - used by both initial request and retry const processStreamingResponse = useCallback(async (stream: AsyncIterable) => { let serverAssistantId: string | undefined; + let serverReasoningId: string | undefined; let accumulatedContent = ""; + let accumulatedReasoning = ""; for await (const event of stream) { const eventType = (event as { type: string }).type; @@ -1698,6 +1855,24 @@ export function UnifiedChat() { setMessages((prev) => mergeMessagesById(prev, [webSearchItem])); } + } else if ( + eventType === "response.output_item.added" && + (event as { item?: { type?: string } }).item?.type === "reasoning" + ) { + // Reasoning item created - add immediately as a flat item (for models like Kimi K2) + const eventWithItem = event as { item?: { id?: string } }; + if (eventWithItem.item?.id) { + serverReasoningId = eventWithItem.item.id; + + const reasoningItem: ReasoningItem = { + id: serverReasoningId, + type: "reasoning", + content: [], + status: "in_progress" + }; + + setMessages((prev) => mergeMessagesById(prev, [reasoningItem])); + } } else if (eventType === "response.web_search_call.in_progress") { // Update web search status const webSearchEvent = event as { item_id?: string }; @@ -1802,6 +1977,56 @@ export function UnifiedChat() { return withOutput; }); } + } else if ( + eventType === "response.reasoning_text.delta" && + (event as { delta?: string }).delta + ) { + // Reasoning text delta - update the reasoning item (for models like Kimi K2) + const reasoningEvent = event as { delta: string; item_id?: string }; + const delta = reasoningEvent.delta; + accumulatedReasoning += delta; + + // Use item_id from event if available, otherwise fall back to serverReasoningId + const reasoningId = reasoningEvent.item_id || serverReasoningId; + + if (reasoningId) { + setMessages((prev) => { + const itemToUpdate = prev.find((m) => m.id === reasoningId); + if (itemToUpdate && itemToUpdate.type === "reasoning") { + const updated: ReasoningItem = { + ...(itemToUpdate as ReasoningItem), + content: [{ type: "text", text: accumulatedReasoning }], + status: "streaming" + }; + return mergeMessagesById(prev, [updated]); + } + return prev; + }); + } + } else if (eventType === "response.reasoning_text.done") { + // Reasoning completed - finalize the reasoning item + const doneEvent = event as { text?: string; item_id?: string }; + if (doneEvent.text) { + accumulatedReasoning = doneEvent.text; + } + + // Use item_id from event if available, otherwise fall back to serverReasoningId + const reasoningId = doneEvent.item_id || serverReasoningId; + + if (reasoningId) { + setMessages((prev) => { + const itemToUpdate = prev.find((m) => m.id === reasoningId); + if (itemToUpdate && itemToUpdate.type === "reasoning") { + const updated: ReasoningItem = { + ...(itemToUpdate as ReasoningItem), + content: [{ type: "text", text: accumulatedReasoning }], + status: "completed" + }; + return mergeMessagesById(prev, [updated]); + } + return prev; + }); + } } else if ( eventType === "response.output_text.delta" && (event as { delta?: string }).delta @@ -1831,8 +2056,26 @@ export function UnifiedChat() { }); } } else if (eventType === "response.output_item.done") { - if (serverAssistantId) { - // Update status to completed + // Handle completion for any item type (reasoning, message, etc.) + const doneEvent = event as { item?: { id?: string; type?: string } }; + const itemId = doneEvent.item?.id; + + if (itemId) { + setMessages((prev) => { + const itemToUpdate = prev.find((m) => m.id === itemId); + if (itemToUpdate) { + const updated = { + ...itemToUpdate, + status: "completed" + } as unknown as Message; + return mergeMessagesById(prev, [updated]); + } + return prev; + }); + // Update lastSeenItemId for polling (use the latest completed item) + setLastSeenItemId(itemId); + } else if (serverAssistantId) { + // Fallback to serverAssistantId if item.id not in event setMessages((prev) => { const msgToUpdate = prev.find((m) => m.id === serverAssistantId); if (msgToUpdate) { @@ -2525,41 +2768,39 @@ export function UnifiedChat() { } /> - {/* Thinking toggle button - temporarily disabled while we remove V3.1 */} - {/* eslint-disable-next-line no-constant-binary-expression */} - {false && - (localState.model === CATEGORY_MODELS.reasoning_on || - localState.model === CATEGORY_MODELS.reasoning_off) && ( - - )} + ? "text-purple-500" + : "text-muted-foreground" + }`} + /> + + )} {/* Web search toggle button - always visible */} - )} + ? "text-purple-500" + : "text-muted-foreground" + }`} + /> + + )} {/* Web search toggle button - always visible */}