diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 7a2c7e3b11..8cb34eeea9 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -254,6 +254,10 @@ const AIPanelComponentInner = memo(() => { return false; }; + useEffect(() => { + globalStore.set(model.isAIStreaming, status == "streaming"); + }, [status]); + useEffect(() => { const keyHandler = keydownWrapper(handleKeyDown); document.addEventListener("keydown", keyHandler); diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 3f101ec618..40c884a85f 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -52,6 +52,7 @@ export class WaveAIModel { realMessage: AIMessage | null = null; orefContext: ORef; inBuilder: boolean = false; + isAIStreaming = jotai.atom(false); widgetAccessAtom!: jotai.Atom; droppedFiles: jotai.PrimitiveAtom = jotai.atom([]); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index a5a56cdb04..3925ddd5e4 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -472,6 +472,11 @@ class RpcApiType { return client.wshRpcCall("resolveids", data, opts); } + // command "restartbuilderandwait" [call] + RestartBuilderAndWaitCommand(client: WshClient, data: CommandRestartBuilderAndWaitData, opts?: RpcOpts): Promise { + return client.wshRpcCall("restartbuilderandwait", data, opts); + } + // command "routeannounce" [call] RouteAnnounceCommand(client: WshClient, opts?: RpcOpts): Promise { return client.wshRpcCall("routeannounce", null, opts); diff --git a/frontend/builder/store/builder-apppanel-model.ts b/frontend/builder/store/builder-apppanel-model.ts index 606cadb513..70a4ad2a30 100644 --- a/frontend/builder/store/builder-apppanel-model.ts +++ b/frontend/builder/store/builder-apppanel-model.ts @@ -106,7 +106,6 @@ export class BuilderAppPanelModel { scope: appId, handler: () => { this.loadAppFile(appId); - this.debouncedRestart(); }, }); } diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index ceca7273c1..b1f586679c 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -1,7 +1,9 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; +import { BuilderBuildPanelModel } from "@/builder/store/builder-buildpanel-model"; import { atoms } from "@/store/global"; import { useAtomValue } from "jotai"; import { memo, useState } from "react"; @@ -30,6 +32,29 @@ EmptyStateView.displayName = "EmptyStateView"; const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => { const displayMsg = errorMsg && errorMsg.trim() ? errorMsg : "Unknown Error"; + const waveAIModel = WaveAIModel.getInstance(); + const buildPanelModel = BuilderBuildPanelModel.getInstance(); + const outputLines = useAtomValue(buildPanelModel.outputLines); + const isStreaming = useAtomValue(waveAIModel.isAIStreaming); + + const getBuildContext = () => { + const filteredLines = outputLines.filter((line) => !line.startsWith("[debug]")); + const buildOutput = filteredLines.join("\n").trim(); + return `Build Error:\n\`\`\`\n${displayMsg}\n\`\`\`\n\nBuild Output:\n\`\`\`\n${buildOutput}\n\`\`\``; + }; + + const handleAddToContext = () => { + const context = getBuildContext(); + waveAIModel.appendText(context, true); + waveAIModel.focusInput(); + }; + + const handleAskAIToFix = async () => { + const context = getBuildContext(); + waveAIModel.appendText("Please help me fix this build error:\n\n" + context, true); + await waveAIModel.handleSubmit(); + }; + return (
@@ -38,6 +63,22 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => {
{displayMsg}
+ {!isStreaming && ( +
+ + +
+ )}
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index bc29a2e96d..981780744b 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -379,6 +379,11 @@ declare global { resolvedids: {[key: string]: ORef}; }; + // wshrpc.CommandRestartBuilderAndWaitData + type CommandRestartBuilderAndWaitData = { + builderid: string; + }; + // wshrpc.CommandSetMetaData type CommandSetMetaData = { oref: ORef; @@ -909,6 +914,13 @@ declare global { shell: string; }; + // wshrpc.RestartBuilderAndWaitResult + type RestartBuilderAndWaitResult = { + success: boolean; + errormessage?: string; + buildoutput: string; + }; + // wshutil.RpcMessage type RpcMessage = { command?: string; diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go index 5d5022d7c9..0fe117c6ec 100644 --- a/pkg/aiusechat/tools_builder.go +++ b/pkg/aiusechat/tools_builder.go @@ -4,13 +4,19 @@ package aiusechat import ( + "context" "fmt" + "log" + "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" + "github.com/wavetermdev/waveterm/pkg/buildercontroller" "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveappstore" + "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wstore" ) const BuilderAppFileName = "app.go" @@ -19,6 +25,35 @@ type builderWriteAppFileParams struct { Contents string `json:"contents"` } +func triggerBuildAndWait(builderId string, appId string) map[string]any { + bc := buildercontroller.GetOrCreateController(builderId) + rtInfo := wstore.GetRTInfo(waveobj.MakeORef(waveobj.OType_Builder, builderId)) + + var builderEnv map[string]string + if rtInfo != nil { + builderEnv = rtInfo.BuilderEnv + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + result, err := bc.RestartAndWaitForBuild(ctx, appId, builderEnv) + if err != nil { + log.Printf("Build failed for %s: %v", builderId, err) + return map[string]any{ + "build_success": false, + "build_error": err.Error(), + "build_output": "", + } + } + + return map[string]any{ + "build_success": result.Success, + "build_error": result.ErrorMessage, + "build_output": result.BuildOutput, + } +} + func parseBuilderWriteAppFileInput(input any) (*builderWriteAppFileParams, error) { result := &builderWriteAppFileParams{} @@ -37,7 +72,7 @@ func parseBuilderWriteAppFileInput(input any) (*builderWriteAppFileParams, error return result, nil } -func GetBuilderWriteAppFileToolDefinition(appId string) uctypes.ToolDefinition { +func GetBuilderWriteAppFileToolDefinition(appId string, builderId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "builder_write_app_file", DisplayName: "Write App File", @@ -74,10 +109,19 @@ func GetBuilderWriteAppFileToolDefinition(appId string) uctypes.ToolDefinition { Scopes: []string{appId}, }) - return map[string]any{ + result := map[string]any{ "success": true, "message": fmt.Sprintf("Successfully wrote %s", BuilderAppFileName), - }, nil + } + + if builderId != "" { + buildResult := triggerBuildAndWait(builderId, appId) + result["build_success"] = buildResult["build_success"] + result["build_error"] = buildResult["build_error"] + result["build_output"] = buildResult["build_output"] + } + + return result, nil }, } } @@ -104,7 +148,7 @@ func parseBuilderEditAppFileInput(input any) (*builderEditAppFileParams, error) return result, nil } -func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { +func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes.ToolDefinition { return uctypes.ToolDefinition{ Name: "builder_edit_app_file", DisplayName: "Edit App File", @@ -147,7 +191,12 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { if err != nil { return fmt.Sprintf("error parsing input: %v", err) } - return fmt.Sprintf("editing app.go for %s (%d edits)", appId, len(params.Edits)) + numEdits := len(params.Edits) + editStr := "edits" + if numEdits == 1 { + editStr = "edit" + } + return fmt.Sprintf("editing app.go for %s (%d %s)", appId, numEdits, editStr) }, ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) { params, err := parseBuilderEditAppFileInput(input) @@ -165,10 +214,19 @@ func GetBuilderEditAppFileToolDefinition(appId string) uctypes.ToolDefinition { Scopes: []string{appId}, }) - return map[string]any{ + result := map[string]any{ "success": true, "message": fmt.Sprintf("Successfully edited %s with %d changes", BuilderAppFileName, len(params.Edits)), - }, nil + } + + if builderId != "" { + buildResult := triggerBuildAndWait(builderId, appId) + result["build_success"] = buildResult["build_success"] + result["build_error"] = buildResult["build_error"] + result["build_output"] = buildResult["build_output"] + } + + return result, nil }, } } diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 70e07a1baa..1a5fa9a223 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -717,8 +717,8 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { if req.BuilderAppId != "" { chatOpts.Tools = append(chatOpts.Tools, - GetBuilderWriteAppFileToolDefinition(req.BuilderAppId), - GetBuilderEditAppFileToolDefinition(req.BuilderAppId), + GetBuilderWriteAppFileToolDefinition(req.BuilderAppId, req.BuilderId), + GetBuilderEditAppFileToolDefinition(req.BuilderAppId, req.BuilderId), GetBuilderListFilesToolDefinition(req.BuilderAppId), ) } diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index ba06057a5a..040167849d 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "sync" "time" @@ -41,6 +42,12 @@ type BuilderProcess struct { WaitRtn error } +type BuildResult struct { + Success bool `json:"success"` + ErrorMessage string `json:"errormessage,omitempty"` + BuildOutput string `json:"buildoutput"` +} + type BuilderController struct { lock sync.Mutex builderId string @@ -182,22 +189,22 @@ func (bc *BuilderController) Start(ctx context.Context, appId string, builderEnv defer func() { panichandler.PanicHandler(fmt.Sprintf("buildercontroller[%s].buildAndRun", bc.builderId), recover()) }() - bc.buildAndRun(buildCtx, appId, builderEnv) + bc.buildAndRun(buildCtx, appId, builderEnv, nil) }() return nil } -func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, builderEnv map[string]string) { +func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, builderEnv map[string]string, resultCh chan<- *BuildResult) { appNS, _, err := waveappstore.ParseAppId(appId) if err != nil { - bc.handleBuildError(fmt.Errorf("failed to parse app id: %w", err)) + bc.handleBuildError(fmt.Errorf("failed to parse app id: %w", err), resultCh) return } appPath, err := waveappstore.GetAppDir(appId) if err != nil { - bc.handleBuildError(fmt.Errorf("failed to get app directory: %w", err)) + bc.handleBuildError(fmt.Errorf("failed to get app directory: %w", err), resultCh) return } @@ -205,13 +212,13 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil cachePath, err := GetBuilderAppExecutablePath(bc.builderId, appName) if err != nil { - bc.handleBuildError(fmt.Errorf("failed to get builder executable path: %w", err)) + bc.handleBuildError(fmt.Errorf("failed to get builder executable path: %w", err), resultCh) return } nodePath := wavebase.GetWaveAppElectronExecPath() if nodePath == "" { - bc.handleBuildError(fmt.Errorf("electron executable path not set")) + bc.handleBuildError(fmt.Errorf("electron executable path not set"), resultCh) return } @@ -248,24 +255,39 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil } if err != nil { - bc.handleBuildError(fmt.Errorf("build failed: %w", err)) + bc.handleBuildError(fmt.Errorf("build failed: %w", err), resultCh) return } info, err := os.Stat(cachePath) if err != nil { - bc.handleBuildError(fmt.Errorf("build output not found: %w", err)) + bc.handleBuildError(fmt.Errorf("build output not found: %w", err), resultCh) return } if runtime.GOOS != "windows" && info.Mode()&0111 == 0 { - bc.handleBuildError(fmt.Errorf("build output is not executable")) + bc.handleBuildError(fmt.Errorf("build output is not executable"), resultCh) return } + if resultCh != nil { + buildOutput := "" + if bc.outputBuffer != nil { + lines := bc.outputBuffer.GetLines() + buildOutput = strings.Join(lines, "\n") + } + select { + case resultCh <- &BuildResult{ + Success: true, + BuildOutput: buildOutput, + }: + default: + } + } + process, err := bc.runBuilderApp(ctx, cachePath, builderEnv) if err != nil { - bc.handleBuildError(fmt.Errorf("failed to run app: %w", err)) + bc.handleBuildError(fmt.Errorf("failed to run app: %w", err), resultCh) return } @@ -371,10 +393,69 @@ func (bc *BuilderController) runBuilderApp(ctx context.Context, appBinPath strin } } -func (bc *BuilderController) handleBuildError(err error) { +func (bc *BuilderController) handleBuildError(err error, resultCh chan<- *BuildResult) { bc.lock.Lock() defer bc.lock.Unlock() bc.setStatus_nolock(BuilderStatus_Error, 0, 1, err.Error()) + + if resultCh != nil { + buildOutput := "" + if bc.outputBuffer != nil { + lines := bc.outputBuffer.GetLines() + buildOutput = strings.Join(lines, "\n") + } + select { + case resultCh <- &BuildResult{ + Success: false, + ErrorMessage: err.Error(), + BuildOutput: buildOutput, + }: + default: + } + } +} + +func (bc *BuilderController) RestartAndWaitForBuild(ctx context.Context, appId string, builderEnv map[string]string) (*BuildResult, error) { + if err := bc.waitForBuildDone(ctx); err != nil { + return nil, err + } + + resultCh := make(chan *BuildResult, 1) + + bc.lock.Lock() + if bc.appId != appId && bc.process != nil { + log.Printf("BuilderController: stopping previous app %s for builder %s", bc.appId, bc.builderId) + bc.stopProcess_nolock() + } + + bc.appId = appId + bc.outputBuffer = utilds.MakeMultiReaderLineBuffer(1000) + bc.setStatus_nolock(BuilderStatus_Building, 0, 0, "") + + bc.publishOutputLine("", true) + + bc.outputBuffer.SetLineCallback(func(line string) { + bc.publishOutputLine(line, false) + }) + bc.lock.Unlock() + + time.Sleep(500 * time.Millisecond) + + buildCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + go func() { + defer cancel() + defer func() { + panichandler.PanicHandler(fmt.Sprintf("buildercontroller[%s].buildAndRun", bc.builderId), recover()) + }() + bc.buildAndRun(buildCtx, appId, builderEnv, resultCh) + }() + + select { + case result := <-resultCh: + return result, nil + case <-ctx.Done(): + return nil, ctx.Err() + } } func (bc *BuilderController) Stop() error { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 92f4f9dd2c..c92a33055c 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -568,6 +568,12 @@ func ResolveIdsCommand(w *wshutil.WshRpc, data wshrpc.CommandResolveIdsData, opt return resp, err } +// command "restartbuilderandwait", wshserver.RestartBuilderAndWaitCommand +func RestartBuilderAndWaitCommand(w *wshutil.WshRpc, data wshrpc.CommandRestartBuilderAndWaitData, opts *wshrpc.RpcOpts) (*wshrpc.RestartBuilderAndWaitResult, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.RestartBuilderAndWaitResult](w, "restartbuilderandwait", data, opts) + return resp, err +} + // command "routeannounce", wshserver.RouteAnnounceCommand func RouteAnnounceCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "routeannounce", nil, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a0c8479533..e7d2e46653 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -157,17 +157,18 @@ const ( Command_TermGetScrollbackLines = "termgetscrollbacklines" // builder - Command_ListAllEditableApps = "listalleditableapps" - Command_ListAllAppFiles = "listallappfiles" - Command_ReadAppFile = "readappfile" - Command_WriteAppFile = "writeappfile" - Command_DeleteAppFile = "deleteappfile" - Command_RenameAppFile = "renameappfile" - Command_DeleteBuilder = "deletebuilder" - Command_StartBuilder = "startbuilder" - Command_GetBuilderStatus = "getbuilderstatus" - Command_GetBuilderOutput = "getbuilderoutput" - Command_CheckGoVersion = "checkgoversion" + Command_ListAllEditableApps = "listalleditableapps" + Command_ListAllAppFiles = "listallappfiles" + Command_ReadAppFile = "readappfile" + Command_WriteAppFile = "writeappfile" + Command_DeleteAppFile = "deleteappfile" + Command_RenameAppFile = "renameappfile" + Command_DeleteBuilder = "deletebuilder" + Command_StartBuilder = "startbuilder" + Command_RestartBuilderAndWait = "restartbuilderandwait" + Command_GetBuilderStatus = "getbuilderstatus" + Command_GetBuilderOutput = "getbuilderoutput" + Command_CheckGoVersion = "checkgoversion" // electron Command_ElectronEncrypt = "electronencrypt" @@ -334,6 +335,7 @@ type WshRpcInterface interface { RenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error DeleteBuilderCommand(ctx context.Context, builderId string) error StartBuilderCommand(ctx context.Context, data CommandStartBuilderData) error + RestartBuilderAndWaitCommand(ctx context.Context, data CommandRestartBuilderAndWaitData) (*RestartBuilderAndWaitResult, error) GetBuilderStatusCommand(ctx context.Context, builderId string) (*BuilderStatusData, error) GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) CheckGoVersionCommand(ctx context.Context) (*CommandCheckGoVersionRtnData, error) @@ -1017,6 +1019,16 @@ type CommandStartBuilderData struct { BuilderId string `json:"builderid"` } +type CommandRestartBuilderAndWaitData struct { + BuilderId string `json:"builderid"` +} + +type RestartBuilderAndWaitResult struct { + Success bool `json:"success"` + ErrorMessage string `json:"errormessage,omitempty"` + BuildOutput string `json:"buildoutput"` +} + type BuilderStatusData struct { Status string `json:"status"` Port int `json:"port,omitempty"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 2ce7a509eb..d493948e7e 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1051,6 +1051,35 @@ func (ws *WshServer) StartBuilderCommand(ctx context.Context, data wshrpc.Comman return bc.Start(ctx, appId, rtInfo.BuilderEnv) } +func (ws *WshServer) RestartBuilderAndWaitCommand(ctx context.Context, data wshrpc.CommandRestartBuilderAndWaitData) (*wshrpc.RestartBuilderAndWaitResult, error) { + if data.BuilderId == "" { + return nil, fmt.Errorf("must provide a builderId to RestartBuilderAndWaitCommand") + } + + bc := buildercontroller.GetOrCreateController(data.BuilderId) + rtInfo := wstore.GetRTInfo(waveobj.MakeORef("builder", data.BuilderId)) + if rtInfo == nil { + return nil, fmt.Errorf("builder rtinfo not found for builderid: %s", data.BuilderId) + } + + appId := rtInfo.BuilderAppId + if appId == "" { + return nil, fmt.Errorf("builder appid not set for builderid: %s", data.BuilderId) + } + + result, err := bc.RestartAndWaitForBuild(ctx, appId, rtInfo.BuilderEnv) + if err != nil { + return nil, err + } + + return &wshrpc.RestartBuilderAndWaitResult{ + Success: result.Success, + ErrorMessage: result.ErrorMessage, + BuildOutput: result.BuildOutput, + }, nil +} + + func (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId string) (*wshrpc.BuilderStatusData, error) { if builderId == "" { return nil, fmt.Errorf("must provide a builderId to GetBuilderStatusCommand")