diff --git a/.gitignore b/.gitignore index 57fd982c96..78f683ee96 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ make/ artifacts/ mikework/ .env +out # Yarn Modern .pnp.* diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 1b16581370..ae5de1887c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -130,6 +130,21 @@ export default defineConfig({ input: { index: "index.html", }, + output: { + manualChunks(id) { + const p = id.replace(/\\/g, "/"); + if (p.includes("node_modules/monaco") || p.includes("node_modules/@monaco")) return "monaco"; + if (p.includes("node_modules/mermaid") || p.includes("node_modules/@mermaid")) + return "mermaid"; + if (p.includes("node_modules/katex") || p.includes("node_modules/@katex")) return "katex"; + if (p.includes("node_modules/shiki") || p.includes("node_modules/@shiki")) { + return "shiki"; + } + if (p.includes("node_modules/cytoscape") || p.includes("node_modules/@cytoscape")) + return "cytoscape"; + return undefined; + }, + }, }, }, optimizeDeps: { diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx index 1a11805d28..02ccb5d947 100644 --- a/frontend/app/aipanel/aimessage.tsx +++ b/frontend/app/aipanel/aimessage.tsx @@ -10,14 +10,14 @@ import { getFileIcon } from "./ai-utils"; import { WaveUIMessage, WaveUIMessagePart } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; -const AIThinking = memo(() => ( +const AIThinking = memo(({ message = "AI is thinking..." }: { message?: string }) => (
- AI is thinking... + {message && {message}}
)); @@ -68,12 +68,137 @@ const UserMessageFiles = memo(({ fileParts }: UserMessageFilesProps) => { UserMessageFiles.displayName = "UserMessageFiles"; +interface AIToolApprovalButtonsProps { + count: number; + onApprove: () => void; + onDeny: () => void; +} + +const AIToolApprovalButtons = memo(({ count, onApprove, onDeny }: AIToolApprovalButtonsProps) => { + const approveText = count > 1 ? `Approve All (${count})` : "Approve"; + const denyText = count > 1 ? "Deny All" : "Deny"; + + return ( +
+ + +
+ ); +}); + +AIToolApprovalButtons.displayName = "AIToolApprovalButtons"; + +interface AIToolUseBatchItemProps { + part: WaveUIMessagePart & { type: "data-tooluse" }; + effectiveApproval: string; +} + +const AIToolUseBatchItem = memo(({ part, effectiveApproval }: AIToolUseBatchItemProps) => { + const statusIcon = part.data.status === "completed" ? "✓" : part.data.status === "error" ? "✗" : "•"; + const statusColor = + part.data.status === "completed" + ? "text-success" + : part.data.status === "error" + ? "text-error" + : "text-gray-400"; + const effectiveErrorMessage = part.data.errormessage || (effectiveApproval === "timeout" ? "Not approved" : null); + + return ( +
+ {statusIcon} +
+ {part.data.tooldesc} + {effectiveErrorMessage &&
{effectiveErrorMessage}
} +
+
+ ); +}); + +AIToolUseBatchItem.displayName = "AIToolUseBatchItem"; + +interface AIToolUseBatchProps { + parts: Array; // parts must not be empty + isStreaming: boolean; +} + +const AIToolUseBatch = memo(({ parts, isStreaming }: AIToolUseBatchProps) => { + const [userApprovalOverride, setUserApprovalOverride] = useState(null); + + const firstTool = parts[0].data; + const baseApproval = userApprovalOverride || firstTool.approval; + const effectiveApproval = !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; + const allNeedApproval = parts.every((p) => (userApprovalOverride || p.data.approval) === "needs-approval"); + + useEffect(() => { + if (!isStreaming || effectiveApproval !== "needs-approval") return; + + const interval = setInterval(() => { + parts.forEach((part) => { + RpcApi.WaveAIToolApproveCommand(TabRpcClient, { + toolcallid: part.data.toolcallid, + keepalive: true, + }); + }); + }, 4000); + + return () => clearInterval(interval); + }, [isStreaming, effectiveApproval, parts]); + + const handleApprove = () => { + setUserApprovalOverride("user-approved"); + parts.forEach((part) => { + RpcApi.WaveAIToolApproveCommand(TabRpcClient, { + toolcallid: part.data.toolcallid, + approval: "user-approved", + }); + }); + }; + + const handleDeny = () => { + setUserApprovalOverride("user-denied"); + parts.forEach((part) => { + RpcApi.WaveAIToolApproveCommand(TabRpcClient, { + toolcallid: part.data.toolcallid, + approval: "user-denied", + }); + }); + }; + + return ( +
+
+
Reading Files
+
+ {parts.map((part, idx) => ( + + ))} +
+ {allNeedApproval && effectiveApproval === "needs-approval" && ( + + )} +
+
+ ); +}); + +AIToolUseBatch.displayName = "AIToolUseBatch"; + interface AIToolUseProps { part: WaveUIMessagePart & { type: "data-tooluse" }; isStreaming: boolean; } -const AIToolUse = memo(({ part }: AIToolUseProps) => { +const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { const toolData = part.data; const [userApprovalOverride, setUserApprovalOverride] = useState(null); @@ -81,10 +206,11 @@ const AIToolUse = memo(({ part }: AIToolUseProps) => { const statusColor = toolData.status === "completed" ? "text-success" : toolData.status === "error" ? "text-error" : "text-gray-400"; - const effectiveApproval = userApprovalOverride || toolData.approval; + const baseApproval = userApprovalOverride || toolData.approval; + const effectiveApproval = !isStreaming && baseApproval === "needs-approval" ? "timeout" : baseApproval; useEffect(() => { - if (effectiveApproval !== "needs-approval") return; + if (!isStreaming || effectiveApproval !== "needs-approval") return; const interval = setInterval(() => { RpcApi.WaveAIToolApproveCommand(TabRpcClient, { @@ -94,7 +220,7 @@ const AIToolUse = memo(({ part }: AIToolUseProps) => { }, 4000); return () => clearInterval(interval); - }, [effectiveApproval, toolData.toolcallid]); + }, [isStreaming, effectiveApproval, toolData.toolcallid]); const handleApprove = () => { setUserApprovalOverride("user-approved"); @@ -118,22 +244,11 @@ const AIToolUse = memo(({ part }: AIToolUseProps) => {
{toolData.toolname}
{toolData.tooldesc &&
{toolData.tooldesc}
} - {toolData.errormessage &&
{toolData.errormessage}
} + {(toolData.errormessage || effectiveApproval === "timeout") && ( +
{toolData.errormessage || "Not approved"}
+ )} {effectiveApproval === "needs-approval" && ( -
- - -
+ )}
@@ -142,6 +257,56 @@ const AIToolUse = memo(({ part }: AIToolUseProps) => { AIToolUse.displayName = "AIToolUse"; +interface AIToolUseGroupProps { + parts: Array; + isStreaming: boolean; +} + +const AIToolUseGroup = memo(({ parts, isStreaming }: AIToolUseGroupProps) => { + const isFileOp = (part: WaveUIMessagePart & { type: "data-tooluse" }) => { + const toolName = part.data?.toolname; + return toolName === "read_text_file" || toolName === "read_dir"; + }; + + const fileOpsNeedApproval: Array = []; + const fileOpsNoApproval: Array = []; + const otherTools: Array = []; + + for (const part of parts) { + if (isFileOp(part)) { + if (part.data.approval === "needs-approval") { + fileOpsNeedApproval.push(part); + } else { + fileOpsNoApproval.push(part); + } + } else { + otherTools.push(part); + } + } + + return ( + <> + {fileOpsNoApproval.length > 0 && ( +
+ +
+ )} + {fileOpsNeedApproval.length > 0 && ( +
+ +
+ )} + {otherTools.map((tool, idx) => ( +
+ +
+ ))} + + ); +}); + +AIToolUseGroup.displayName = "AIToolUseGroup"; + interface AIMessagePartProps { part: WaveUIMessagePart; role: string; @@ -168,10 +333,6 @@ const AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) => } } - if (part.type === "data-tooluse" && part.data) { - return ; - } - return null; }); @@ -190,44 +351,98 @@ const isDisplayPart = (part: WaveUIMessagePart): boolean => { ); }; +type MessagePart = + | { type: "single"; part: WaveUIMessagePart } + | { type: "toolgroup"; parts: Array }; + +const groupMessageParts = (parts: WaveUIMessagePart[]): MessagePart[] => { + const grouped: MessagePart[] = []; + let currentToolGroup: Array = []; + + for (const part of parts) { + if (part.type === "data-tooluse") { + currentToolGroup.push(part as WaveUIMessagePart & { type: "data-tooluse" }); + } else { + if (currentToolGroup.length > 0) { + grouped.push({ type: "toolgroup", parts: currentToolGroup }); + currentToolGroup = []; + } + grouped.push({ type: "single", part }); + } + } + + if (currentToolGroup.length > 0) { + grouped.push({ type: "toolgroup", parts: currentToolGroup }); + } + + return grouped; +}; + +const getThinkingMessage = (parts: WaveUIMessagePart[], isStreaming: boolean, role: string): string | null => { + if (!isStreaming || role !== "assistant") { + return null; + } + + // Check if there are any pending-approval tool calls - this takes priority + const hasPendingApprovals = parts.some( + (part) => part.type === "data-tooluse" && part.data?.approval === "needs-approval" + ); + + if (hasPendingApprovals) { + return "Waiting for Tool Approvals..."; + } + + const lastPart = parts[parts.length - 1]; + + // Check if the last part is a reasoning part + if (lastPart?.type === "reasoning") { + return "AI is thinking..."; + } + + // Only hide thinking indicator if the last part is text and not empty + // (this means text is actively streaming) + if (lastPart?.type === "text" && lastPart.text) { + return null; + } + + // For all other cases (including finish-step, tooluse, etc.), show dots + return ""; +}; + export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { const parts = message.parts || []; const displayParts = parts.filter(isDisplayPart); const fileParts = parts.filter( (part): part is WaveUIMessagePart & { type: "data-userfile" } => part.type === "data-userfile" ); - const hasContent = - displayParts.length > 0 && - displayParts.some( - (part) => - (part.type === "text" && part.text) || part.type.startsWith("tool-") || part.type === "data-tooluse" - ); - const showThinkingOnly = !hasContent && isStreaming && message.role === "assistant"; - const showThinkingInline = hasContent && isStreaming && message.role === "assistant"; + const thinkingMessage = getThinkingMessage(parts, isStreaming, message.role); + const groupedParts = groupMessageParts(displayParts); return (
*:first-child]:!mt-0", + message.role === "user" ? "py-2 bg-accent-800 text-white max-w-[calc(100%-20px)]" : null )} > - {showThinkingOnly ? ( - - ) : !hasContent && !isStreaming ? ( + {displayParts.length === 0 && !isStreaming && !thinkingMessage ? (
(no text content)
) : ( <> - {displayParts.map((part, index: number) => ( -
0 && "mt-2")}> - -
- ))} - {showThinkingInline && ( + {groupedParts.map((group, index: number) => + group.type === "toolgroup" ? ( + + ) : ( +
+ +
+ ) + )} + {thinkingMessage != null && (
- +
)} diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 3ecbdf8830..4231d3add4 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -529,7 +529,11 @@ const AIPanelComponentInner = memo(({ className, onClose }: AIPanelProps) => {
) : ( - + )} {errorMessage && ( model.clearError()} /> diff --git a/package-lock.json b/package-lock.json index 51d7be2188..dc23c81a4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.12.0-beta.0", + "version": "0.12.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.12.0-beta.0", + "version": "0.12.0-beta.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/aiusechat/openai/openai-backend.go b/pkg/aiusechat/openai/openai-backend.go index e8fa7db00b..6f4a4301f2 100644 --- a/pkg/aiusechat/openai/openai-backend.go +++ b/pkg/aiusechat/openai/openai-backend.go @@ -818,8 +818,6 @@ func handleOpenAIEvent( if toolUseData.Approval == uctypes.ApprovalNeedsApproval && state.chatOpts.RegisterToolApproval != nil { state.chatOpts.RegisterToolApproval(st.toolCallID) } - log.Printf("AI data-tooluse %s\n", st.toolCallID) - _ = sse.AiMsgData("data-tooluse", st.toolCallID, *toolUseData) } return nil, nil diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index b0b1aa05ab..a7e54e27a9 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -310,6 +310,14 @@ func processToolCalls(stopReason *uctypes.WaveStopReason, chatOpts uctypes.WaveC defer activeToolMap.Delete(toolCall.ID) } + // Send all data-tooluse packets at the beginning + for _, toolCall := range stopReason.ToolCalls { + if toolCall.ToolUseData != nil { + log.Printf("AI data-tooluse %s\n", toolCall.ID) + _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, *toolCall.ToolUseData) + } + } + var toolResults []uctypes.AIToolResult for _, toolCall := range stopReason.ToolCalls { result := processToolCall(toolCall, chatOpts, sseHandler, metrics)