/ archive / go-tui-standalone / backend.go
backend.go
  1  package main
  2  
  3  import (
  4  	"fmt"
  5  	"os"
  6  	"os/exec"
  7  	"strings"
  8  	"time"
  9  
 10  	tea "github.com/charmbracelet/bubbletea"
 11  )
 12  
 13  // Message types for backend communication
 14  type backendResponseMsg string
 15  type backendStreamMsg Event
 16  type errMsg error
 17  
 18  // Event represents a structured event from the backend
 19  type Event struct {
 20  	Type    string  `json:"type"`    // thinking, tool, result, final
 21  	Content string  `json:"content"` // Event content
 22  	Tool    string  `json:"tool,omitempty"`
 23  	Timing  float64 `json:"timing,omitempty"`
 24  }
 25  
 26  // ConfigStatus holds parsed configuration from kamaji config
 27  type ConfigStatus struct {
 28  	Provider string
 29  	Model    string
 30  	BaseURL  string
 31  	ShellCwd string
 32  }
 33  
 34  // statusUpdateMsg is sent when status information is updated
 35  type statusUpdateMsg ConfigStatus
 36  
 37  // sendToPythonBackend sends a message to the Python backend via subprocess
 38  func sendToPythonBackend(message string) tea.Cmd {
 39  	return func() tea.Msg {
 40  		// Call installed kamaji command: kamaji ask "message"
 41  		cmd := exec.Command("kamaji", "ask", message)
 42  
 43  		// Capture output
 44  		output, err := cmd.CombinedOutput()
 45  		if err != nil {
 46  			// Return error with output for context
 47  			return errMsg(fmt.Errorf("%v: %s", err, string(output)))
 48  		}
 49  
 50  		// Clean up output (remove extra newlines)
 51  		response := strings.TrimSpace(string(output))
 52  
 53  		return backendResponseMsg(response)
 54  	}
 55  }
 56  
 57  // Alternative: Call kamaji interactively for agent mode
 58  func sendToAgentBackend(message string) tea.Cmd {
 59  	return func() tea.Msg {
 60  		// Use kamaji agent mode for full agent with tools
 61  		cmd := exec.Command("kamaji", "agent", message)
 62  
 63  		output, err := cmd.CombinedOutput()
 64  		if err != nil {
 65  			return errMsg(fmt.Errorf("agent error: %v", err))
 66  		}
 67  
 68  		return backendResponseMsg(strings.TrimSpace(string(output)))
 69  	}
 70  }
 71  
 72  // getProviderStatus retrieves current provider info from Python backend
 73  func getProviderStatus() tea.Cmd {
 74  	return func() tea.Msg {
 75  		// Call: kamaji config
 76  		cmd := exec.Command("kamaji", "config")
 77  
 78  		output, err := cmd.CombinedOutput()
 79  		if err != nil {
 80  			return nil // Silently fail for status updates
 81  		}
 82  
 83  		// Parse config output
 84  		// For now, just return nil - we'll implement proper parsing later
 85  		_ = output
 86  		return nil
 87  	}
 88  }
 89  
 90  // getShellStatus retrieves current shell CWD from Python backend
 91  func getShellStatus() tea.Cmd {
 92  	return func() tea.Msg {
 93  		// Read from Python's persistent shell state
 94  		// This requires accessing kamaji's internal state
 95  		// For now, just return nil
 96  		return nil
 97  	}
 98  }
 99  
100  // Future: HTTP API streaming implementation
101  // func streamFromAPI(message string) tea.Cmd {
102  //     return func() tea.Msg {
103  //         // Connect to HTTP API and stream events
104  //         // This will be implemented in Phase 2.2
105  //         return nil
106  //     }
107  // }
108  
109  // parseKamajiConfig parses output from 'kamaji config'
110  func parseKamajiConfig(output string) ConfigStatus {
111  	status := ConfigStatus{
112  		Provider: "ollama",
113  		Model:    "unknown",
114  		BaseURL:  "",
115  	}
116  
117  	lines := strings.Split(output, "\n")
118  	for _, line := range lines {
119  		// Parse lines like "provider: ollama"
120  		if strings.Contains(line, ":") {
121  			parts := strings.SplitN(line, ":", 2)
122  			if len(parts) != 2 {
123  				continue
124  			}
125  			key := strings.TrimSpace(parts[0])
126  			value := strings.TrimSpace(parts[1])
127  
128  			switch key {
129  			case "provider":
130  				status.Provider = value
131  			case "model":
132  				status.Model = value
133  			case "base_url":
134  				status.BaseURL = value
135  			}
136  		}
137  	}
138  
139  	return status
140  }
141  
142  // pollStatus retrieves current status from kamaji
143  func pollStatus() tea.Cmd {
144  	return func() tea.Msg {
145  		// Get config
146  		cmd := exec.Command("kamaji", "config")
147  		output, err := cmd.CombinedOutput()
148  		
149  		status := ConfigStatus{
150  			Provider: "ollama",
151  			Model:    "gpt-oss:120b",
152  		}
153  		
154  		if err == nil {
155  			status = parseKamajiConfig(string(output))
156  		}
157  
158  		// Get current working directory
159  		if cwd, err := os.Getwd(); err == nil {
160  			status.ShellCwd = cwd
161  		}
162  
163  		return statusUpdateMsg(status)
164  	}
165  }
166  
167  // startStatusPolling starts periodic status updates every 5 seconds
168  func startStatusPolling() tea.Cmd {
169  	return tea.Tick(time.Second*5, func(t time.Time) tea.Msg {
170  		return pollStatus()()
171  	})
172  }
173  
174  // AgentEvent represents a parsed event from agent output
175  type AgentEvent struct {
176  	Type     string  // thinking, action, observation, final
177  	Content  string  
178  	Tool     string  // For action events
179  	Input    string  // For action input
180  	Timing   float64 // Timing information
181  	StepNum  int     // Step number in sequence
182  }
183  
184  // agentEventsMsg is sent when agent events are parsed
185  type agentEventsMsg struct {
186  	events []AgentEvent
187  }
188  
189  // parseAgentOutput parses ReAct format output from kamaji agent
190  func parseAgentOutput(output string) []AgentEvent {
191  	events := []AgentEvent{}
192  	lines := strings.Split(output, "\n")
193  	
194  	stepNum := 0
195  	var currentAction string
196  	
197  	for _, line := range lines {
198  		line = strings.TrimSpace(line)
199  		if line == "" {
200  			continue
201  		}
202  		
203  		if strings.HasPrefix(line, "Thought:") {
204  			stepNum++
205  			content := strings.TrimSpace(strings.TrimPrefix(line, "Thought:"))
206  			events = append(events, AgentEvent{
207  				Type:    "thinking",
208  				Content: content,
209  				StepNum: stepNum,
210  				Timing:  1.0, // Approximate
211  			})
212  		} else if strings.HasPrefix(line, "Action:") {
213  			tool := strings.TrimSpace(strings.TrimPrefix(line, "Action:"))
214  			currentAction = tool
215  			events = append(events, AgentEvent{
216  				Type:    "action",
217  				Tool:    tool,
218  				StepNum: stepNum,
219  			})
220  		} else if strings.HasPrefix(line, "Action Input:") {
221  			input := strings.TrimSpace(strings.TrimPrefix(line, "Action Input:"))
222  			// Update last action event with input
223  			if len(events) > 0 && events[len(events)-1].Type == "action" {
224  				events[len(events)-1].Input = input
225  			}
226  		} else if strings.HasPrefix(line, "Observation:") {
227  			content := strings.TrimSpace(strings.TrimPrefix(line, "Observation:"))
228  			events = append(events, AgentEvent{
229  				Type:    "observation",
230  				Content: content,
231  				Tool:    currentAction,
232  				StepNum: stepNum,
233  				Timing:  2.0, // Approximate tool execution time
234  			})
235  		} else if strings.HasPrefix(line, "Final Answer:") {
236  			content := strings.TrimSpace(strings.TrimPrefix(line, "Final Answer:"))
237  			events = append(events, AgentEvent{
238  				Type:    "final",
239  				Content: content,
240  				StepNum: stepNum,
241  			})
242  		}
243  	}
244  	
245  	return events
246  }
247  
248  // sendToAgentMode sends a message using full agent mode with tools
249  func sendToAgentMode(message string) tea.Cmd {
250  	return func() tea.Msg {
251  		// Use kamaji agent for full agent with tools
252  		cmd := exec.Command("kamaji", "agent", message)
253  		output, err := cmd.CombinedOutput()
254  		
255  		if err != nil {
256  			// Return error but try to parse output anyway
257  			_ = err // We'll include it in the output
258  		}
259  		
260  		// Parse agent output for events
261  		events := parseAgentOutput(string(output))
262  		
263  		// If no events parsed, return simple response
264  		if len(events) == 0 {
265  			return backendResponseMsg(strings.TrimSpace(string(output)))
266  		}
267  		
268  		return agentEventsMsg{events: events}
269  	}
270  }