mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 05:00:26 +08:00
* 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
306 lines
9.1 KiB
Go
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
|
|
},
|
|
}
|
|
}
|