diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index a4a693aea5..a83a80b3b7 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -177,3 +177,8 @@ Also when adding content to the end of files prefer to use the new append_file t - **ALWAYS verify the current working directory before executing commands** - Either run "pwd" first to verify the directory, or do a "cd" to the correct absolute directory before running commands - When running tests, do not "cd" to the pkg directory and then run the test. This screws up the cwd and you never recover. run the test from the project root instead. + +### Testing / Compiling Go Code + +No need to run a `go build` or a `go run` to just check if the Go code compiles. VSCode's errors/problems cover this well. +If there are no Go errors in VSCode you can assume the code compiles fine. diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 2f2395e1ac..760faa48e4 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -129,7 +129,7 @@ func sendTelemetryWrapper() { defer func() { panichandler.PanicHandler("sendTelemetryWrapper", recover()) }() - ctx, cancelFn := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second) defer cancelFn() beforeSendActivityUpdate(ctx) client, err := wstore.DBGetSingleton[*waveobj.Client](ctx) @@ -137,7 +137,7 @@ func sendTelemetryWrapper() { log.Printf("[error] getting client data for telemetry: %v\n", err) return } - err = wcloud.SendAllTelemetry(ctx, client.OID) + err = wcloud.SendAllTelemetry(client.OID) if err != nil { log.Printf("[error] sending telemetry: %v\n", err) } diff --git a/frontend/app/aipanel/aimessage.tsx b/frontend/app/aipanel/aimessage.tsx index e426e9114a..95d3f9c28a 100644 --- a/frontend/app/aipanel/aimessage.tsx +++ b/frontend/app/aipanel/aimessage.tsx @@ -11,16 +11,41 @@ import { getFileIcon } from "./ai-utils"; import { WaveUIMessage, WaveUIMessagePart } from "./aitypes"; import { WaveAIModel } from "./waveai-model"; -const AIThinking = memo(({ message = "AI is thinking..." }: { message?: string }) => ( -
-
- - - +const AIThinking = memo(({ message = "AI is thinking...", reasoningText }: { message?: string; reasoningText?: string }) => { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current && reasoningText) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [reasoningText]); + + const displayText = reasoningText ? (() => { + const lastDoubleNewline = reasoningText.lastIndexOf("\n\n"); + return lastDoubleNewline !== -1 ? reasoningText.substring(lastDoubleNewline + 2) : reasoningText; + })() : ""; + + return ( +
+
+
+ + + +
+ {message && {message}} +
+ {displayText && ( +
+ {displayText} +
+ )}
- {message && {message}} -
-)); + ); +}); AIThinking.displayName = "AIThinking"; @@ -428,35 +453,31 @@ const groupMessageParts = (parts: WaveUIMessagePart[]): MessagePart[] => { return grouped; }; -const getThinkingMessage = (parts: WaveUIMessagePart[], isStreaming: boolean, role: string): string | null => { +const getThinkingMessage = (parts: WaveUIMessagePart[], isStreaming: boolean, role: string): { message: string; reasoningText?: 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..."; + return { message: "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..."; + const reasoningContent = lastPart.text || ""; + return { message: "AI is thinking...", reasoningText: reasoningContent }; } - // 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 ""; + return { message: "" }; }; export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { @@ -466,7 +487,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { (part): part is WaveUIMessagePart & { type: "data-userfile" } => part.type === "data-userfile" ); - const thinkingMessage = getThinkingMessage(parts, isStreaming, message.role); + const thinkingData = getThinkingMessage(parts, isStreaming, message.role); const groupedParts = groupMessageParts(displayParts); return ( @@ -477,7 +498,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => { message.role === "user" ? "py-2 bg-accent-800 text-white max-w-[calc(100%-20px)]" : null )} > - {displayParts.length === 0 && !isStreaming && !thinkingMessage ? ( + {displayParts.length === 0 && !isStreaming && !thinkingData ? (
(no text content)
) : ( <> @@ -490,9 +511,9 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
) )} - {thinkingMessage != null && ( + {thinkingData != null && (
- +
)} diff --git a/package-lock.json b/package-lock.json index 47927e13fa..6d547a9896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.12.0-beta.4", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.12.0-beta.4", + "version": "0.12.0", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/aiusechat/openai/openai-backend.go b/pkg/aiusechat/openai/openai-backend.go index a61b31f8d2..9ceaffb865 100644 --- a/pkg/aiusechat/openai/openai-backend.go +++ b/pkg/aiusechat/openai/openai-backend.go @@ -221,6 +221,47 @@ type openaiResponseFunctionCallArgumentsDoneEvent struct { Arguments string `json:"arguments"` } +type openaiResponseReasoningSummaryPartAddedEvent struct { + Type string `json:"type"` + SequenceNumber int `json:"sequence_number"` + ItemId string `json:"item_id"` + OutputIndex int `json:"output_index"` + SummaryIndex int `json:"summary_index"` + Part openaiReasoningSummaryPart `json:"part"` +} + +type openaiResponseReasoningSummaryPartDoneEvent struct { + Type string `json:"type"` + SequenceNumber int `json:"sequence_number"` + ItemId string `json:"item_id"` + OutputIndex int `json:"output_index"` + SummaryIndex int `json:"summary_index"` + Part openaiReasoningSummaryPart `json:"part"` +} + +type openaiReasoningSummaryPart struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type openaiResponseReasoningSummaryTextDeltaEvent struct { + Type string `json:"type"` + SequenceNumber int `json:"sequence_number"` + ItemId string `json:"item_id"` + OutputIndex int `json:"output_index"` + SummaryIndex int `json:"summary_index"` + Delta string `json:"delta"` +} + +type openaiResponseReasoningSummaryTextDoneEvent struct { + Type string `json:"type"` + SequenceNumber int `json:"sequence_number"` + ItemId string `json:"item_id"` + OutputIndex int `json:"output_index"` + SummaryIndex int `json:"summary_index"` + Text string `json:"text"` +} + // ---------- OpenAI Response Structure Types ---------- type openaiResponse struct { @@ -256,12 +297,12 @@ type openaiResponse struct { } type openaiOutputItem struct { - Id string `json:"id"` - Type string `json:"type"` - Status string `json:"status,omitempty"` - Content []OpenAIMessageContent `json:"content,omitempty"` - Role string `json:"role,omitempty"` - Summary []string `json:"summary,omitempty"` + Id string `json:"id"` + Type string `json:"type"` + Status string `json:"status,omitempty"` + Content []OpenAIMessageContent `json:"content,omitempty"` + Role string `json:"role,omitempty"` + Summary []openaiReasoningSummaryPart `json:"summary,omitempty"` // tools (type="function_call") Name string `json:"name,omitempty"` @@ -320,10 +361,11 @@ const ( ) type openaiBlockState struct { - kind openaiBlockKind - localID string // For SSE streaming to UI - toolCallID string // For function calls - toolName string // For function calls + kind openaiBlockKind + localID string // For SSE streaming to UI + toolCallID string // For function calls + toolName string // For function calls + summaryCount int // For reasoning: number of summary parts seen } type openaiStreamingState struct { @@ -635,11 +677,12 @@ func handleOpenAIEvent( switch ev.Item.Type { case "reasoning": - // Handle reasoning item for UI streaming + // Create reasoning block - emit start immediately id := uuid.New().String() state.blockMap[ev.Item.Id] = &openaiBlockState{ - kind: openaiBlockReasoning, - localID: id, + kind: openaiBlockReasoning, + localID: id, + summaryCount: 0, } _ = sse.AiMsgReasoningStart(id) case "message": @@ -836,6 +879,40 @@ func handleOpenAIEvent( case "response.output_text.annotation.added": return nil, nil + case "response.reasoning_summary_part.added": + var ev openaiResponseReasoningSummaryPartAddedEvent + if err := json.Unmarshal([]byte(data), &ev); err != nil { + _ = sse.AiMsgError(err.Error()) + return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil + } + + if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockReasoning { + if st.summaryCount > 0 { + // Not the first summary part, emit separator + _ = sse.AiMsgReasoningDelta(st.localID, "\n\n") + } + st.summaryCount++ + } + return nil, nil + + case "response.reasoning_summary_part.done": + return nil, nil + + case "response.reasoning_summary_text.delta": + var ev openaiResponseReasoningSummaryTextDeltaEvent + if err := json.Unmarshal([]byte(data), &ev); err != nil { + _ = sse.AiMsgError(err.Error()) + return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil + } + + if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockReasoning { + _ = sse.AiMsgReasoningDelta(st.localID, ev.Delta) + } + return nil, nil + + case "response.reasoning_summary_text.done": + return nil, nil + default: logutil.DevPrintf("OpenAI: unknown event: %s, data: %s", eventName, data) return nil, nil diff --git a/pkg/aiusechat/openai/openai-convertmessage.go b/pkg/aiusechat/openai/openai-convertmessage.go index 4a32c12af3..79ae202182 100644 --- a/pkg/aiusechat/openai/openai-convertmessage.go +++ b/pkg/aiusechat/openai/openai-convertmessage.go @@ -141,6 +141,7 @@ func debugPrintReq(req *OpenAIRequest, endpoint string) { if len(toolNames) > 0 { log.Printf("tools: %s\n", strings.Join(toolNames, ",")) } + // log.Printf("reasoning %v\n", req.Reasoning) log.Printf("inputs (%d):", len(req.Input)) for idx, input := range req.Input { @@ -234,6 +235,9 @@ func buildOpenAIHTTPRequest(ctx context.Context, inputs []any, chatOpts uctypes. reqBody.Reasoning = &ReasoningType{ Effort: opts.ThinkingLevel, // low, medium, high map directly } + if opts.Model == "gpt-5" { + reqBody.Reasoning.Summary = "auto" + } } // Set temperature if provided diff --git a/pkg/wcloud/wcloud.go b/pkg/wcloud/wcloud.go index dfed44098f..eb6a64d9f5 100644 --- a/pkg/wcloud/wcloud.go +++ b/pkg/wcloud/wcloud.go @@ -211,7 +211,7 @@ func sendTEvents(clientId string) (int, error) { return totalEvents, nil } -func SendAllTelemetry(ctx context.Context, clientId string) error { +func SendAllTelemetry(clientId string) error { defer func() { ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second) defer cancelFn() @@ -225,14 +225,16 @@ func SendAllTelemetry(ctx context.Context, clientId string) error { if err != nil { return err } - err = sendTelemetry(ctx, clientId) + err = sendTelemetry(clientId) if err != nil { return err } return nil } -func sendTelemetry(ctx context.Context, clientId string) error { +func sendTelemetry(clientId string) error { + ctx, cancelFn := context.WithTimeout(context.Background(), WCloudDefaultTimeout) + defer cancelFn() activity, err := telemetry.GetNonUploadedActivity(ctx) if err != nil { return fmt.Errorf("cannot get activity: %v", err) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 63a75a723a..a27a89a84e 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -899,7 +899,7 @@ func (ws WshServer) SendTelemetryCommand(ctx context.Context) error { if err != nil { return fmt.Errorf("getting client data for telemetry: %v", err) } - return wcloud.SendAllTelemetry(ctx, client.OID) + return wcloud.SendAllTelemetry(client.OID) } func (ws *WshServer) WaveAIEnableTelemetryCommand(ctx context.Context) error { @@ -936,7 +936,7 @@ func (ws *WshServer) WaveAIEnableTelemetryCommand(ctx context.Context) error { } // Immediately send telemetry to cloud - err = wcloud.SendAllTelemetry(ctx, client.OID) + err = wcloud.SendAllTelemetry(client.OID) if err != nil { log.Printf("error sending telemetry after enabling: %v", err) }