waveterm/pkg/aiusechat/tools_builder.go
Mike Sawka 347eef289d
more builder updates (#2553)
* load manifest metadata into the FE
* builder only edits draft/ apps (convert local => draft)
* gofmt app.go after saving (AI tools and manual user save)
* dont open duplicate builder windows
* remix app context menu in waveapp
* add icon/iconcolor in appmeta and implement in the wave block frame
2025-11-13 22:47:46 -08:00

306 lines
9.1 KiB
Go

// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package aiusechat
import (
"context"
"fmt"
"log"
"strings"
"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/waveapputil"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wstore"
)
const BuilderAppFileName = "app.go"
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{}
if input == nil {
return nil, fmt.Errorf("input is required")
}
if err := utilfn.ReUnmarshal(result, input); err != nil {
return nil, fmt.Errorf("invalid input format: %w", err)
}
if result.Contents == "" {
return nil, fmt.Errorf("missing contents parameter")
}
return result, nil
}
func GetBuilderWriteAppFileToolDefinition(appId string, builderId string) uctypes.ToolDefinition {
return uctypes.ToolDefinition{
Name: "builder_write_app_file",
DisplayName: "Write App File",
Description: fmt.Sprintf("Write the app.go file for app %s", appId),
ToolLogName: "builder:write_app",
Strict: false,
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"contents": map[string]any{
"type": "string",
"description": "The contents to write to app.go",
},
},
"required": []string{"contents"},
"additionalProperties": false,
},
ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {
params, err := parseBuilderWriteAppFileInput(input)
if err != nil {
if output != nil {
return "wrote app.go"
}
return "writing app.go"
}
lineCount := len(strings.Split(params.Contents, "\n"))
if output != nil {
return fmt.Sprintf("wrote app.go (+%d lines)", lineCount)
}
return fmt.Sprintf("writing app.go (+%d lines)", lineCount)
},
ToolProgressDesc: func(input any) ([]string, error) {
params, err := parseBuilderWriteAppFileInput(input)
if err != nil {
return nil, err
}
lineCount := len(strings.Split(params.Contents, "\n"))
return []string{fmt.Sprintf("writing app.go (+%d lines)", lineCount)}, nil
},
ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {
params, err := parseBuilderWriteAppFileInput(input)
if err != nil {
return nil, err
}
formattedContents := waveapputil.FormatGoCode([]byte(params.Contents))
err = waveappstore.WriteAppFile(appId, BuilderAppFileName, formattedContents)
if err != nil {
return nil, err
}
wps.Broker.Publish(wps.WaveEvent{
Event: wps.Event_WaveAppAppGoUpdated,
Scopes: []string{appId},
})
result := map[string]any{
"success": true,
"message": fmt.Sprintf("Successfully wrote %s", BuilderAppFileName),
}
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
},
}
}
type builderEditAppFileParams struct {
Edits []fileutil.EditSpec `json:"edits"`
}
func parseBuilderEditAppFileInput(input any) (*builderEditAppFileParams, error) {
result := &builderEditAppFileParams{}
if input == nil {
return nil, fmt.Errorf("input is required")
}
if err := utilfn.ReUnmarshal(result, input); err != nil {
return nil, fmt.Errorf("invalid input format: %w", err)
}
if len(result.Edits) == 0 {
return nil, fmt.Errorf("missing edits parameter")
}
return result, nil
}
func formatEditDescriptions(edits []fileutil.EditSpec) []string {
numEdits := len(edits)
editStr := "edits"
if numEdits == 1 {
editStr = "edit"
}
result := make([]string, len(edits)+1)
result[0] = fmt.Sprintf("editing app.go (%d %s)", numEdits, editStr)
for i, edit := range edits {
newLines := len(strings.Split(edit.NewStr, "\n"))
oldLines := len(strings.Split(edit.OldStr, "\n"))
desc := edit.Desc
if desc == "" {
desc = fmt.Sprintf("edit #%d", i+1)
}
result[i+1] = fmt.Sprintf("* %s (+%d -%d)", desc, newLines, oldLines)
}
return result
}
func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes.ToolDefinition {
return uctypes.ToolDefinition{
Name: "builder_edit_app_file",
DisplayName: "Edit App File",
Description: "Edit the app.go file for this app using precise search and replace. " +
"Each old_str must appear EXACTLY ONCE in the file or the edit will fail. " +
"Edits are applied sequentially - if an edit fails, all previous edits are kept and subsequent edits are skipped.",
ToolLogName: "builder:edit_app",
Strict: false,
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"edits": map[string]any{
"type": "array",
"description": "Array of edit specifications. Edits are applied sequentially - if one fails, previous edits are kept but remaining edits are skipped.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"old_str": map[string]any{
"type": "string",
"description": "The exact string to find and replace. MUST appear exactly once in the file - if it appears zero times or multiple times, this edit will fail.",
},
"new_str": map[string]any{
"type": "string",
"description": "The string to replace with",
},
"desc": map[string]any{
"type": "string",
"description": "Description of what this edit does (keep short, half a line of text max)",
},
},
"required": []string{"old_str", "new_str"},
},
},
},
"required": []string{"edits"},
"additionalProperties": false,
},
ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {
params, err := parseBuilderEditAppFileInput(input)
if err != nil {
return fmt.Sprintf("error parsing input: %v", err)
}
return strings.Join(formatEditDescriptions(params.Edits), "\n")
},
ToolProgressDesc: func(input any) ([]string, error) {
params, err := parseBuilderEditAppFileInput(input)
if err != nil {
return nil, err
}
return formatEditDescriptions(params.Edits), nil
},
ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {
params, err := parseBuilderEditAppFileInput(input)
if err != nil {
return nil, err
}
editResults, err := waveappstore.ReplaceInAppFilePartial(appId, BuilderAppFileName, params.Edits)
if err != nil {
return nil, err
}
// ignore format errors; gofmt can fail due to compilation errors which will be caught in the build step
waveappstore.FormatGoFile(appId, BuilderAppFileName)
wps.Broker.Publish(wps.WaveEvent{
Event: wps.Event_WaveAppAppGoUpdated,
Scopes: []string{appId},
})
result := map[string]any{
"edits": editResults,
}
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
},
}
}
func GetBuilderListFilesToolDefinition(appId string) uctypes.ToolDefinition {
return uctypes.ToolDefinition{
Name: "builder_list_files",
DisplayName: "List App Files",
Description: fmt.Sprintf("List all files in app %s", appId),
ToolLogName: "builder:list_files",
Strict: false,
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{},
"additionalProperties": false,
},
ToolCallDesc: func(input any, output any, toolUseData *uctypes.UIMessageDataToolUse) string {
return "listing files"
},
ToolAnyCallback: func(input any, toolUseData *uctypes.UIMessageDataToolUse) (any, error) {
result, err := waveappstore.ListAllAppFiles(appId)
if err != nil {
return nil, err
}
return result, nil
},
}
}