when the AI edits a file it now triggers a rebuild and the AI gets the tool output (#2546)

This commit is contained in:
Mike Sawka 2025-11-11 18:08:56 -08:00 committed by GitHub
parent eb3ba64121
commit 0da0a6421e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 280 additions and 32 deletions

View file

@ -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);

View file

@ -52,6 +52,7 @@ export class WaveAIModel {
realMessage: AIMessage | null = null;
orefContext: ORef;
inBuilder: boolean = false;
isAIStreaming = jotai.atom(false);
widgetAccessAtom!: jotai.Atom<boolean>;
droppedFiles: jotai.PrimitiveAtom<DroppedFile[]> = jotai.atom([]);

View file

@ -472,6 +472,11 @@ class RpcApiType {
return client.wshRpcCall("resolveids", data, opts);
}
// command "restartbuilderandwait" [call]
RestartBuilderAndWaitCommand(client: WshClient, data: CommandRestartBuilderAndWaitData, opts?: RpcOpts): Promise<RestartBuilderAndWaitResult> {
return client.wshRpcCall("restartbuilderandwait", data, opts);
}
// command "routeannounce" [call]
RouteAnnounceCommand(client: WshClient, opts?: RpcOpts): Promise<void> {
return client.wshRpcCall("routeannounce", null, opts);

View file

@ -106,7 +106,6 @@ export class BuilderAppPanelModel {
scope: appId,
handler: () => {
this.loadAppFile(appId);
this.debouncedRestart();
},
});
}

View file

@ -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 (
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-6 max-w-2xl text-center px-8">
@ -38,6 +63,22 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => {
<div className="text-left bg-panel border border-error/30 rounded-lg p-4 max-h-96 overflow-auto">
<pre className="text-sm text-secondary whitespace-pre-wrap font-mono">{displayMsg}</pre>
</div>
{!isStreaming && (
<div className="flex gap-3 mt-2 justify-center">
<button
onClick={handleAddToContext}
className="px-4 py-2 bg-panel text-primary border border-border rounded hover:bg-panel/80 transition-colors cursor-pointer"
>
Add Error to AI Context
</button>
<button
onClick={handleAskAIToFix}
className="px-4 py-2 bg-accent text-primary font-semibold rounded hover:bg-accent/80 transition-colors cursor-pointer"
>
Ask AI to Fix
</button>
</div>
)}
</div>
</div>
</div>

View file

@ -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;

View file

@ -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
},
}
}

View file

@ -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),
)
}

View file

@ -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 {

View file

@ -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)

View file

@ -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"`

View file

@ -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")