diff --git a/cmd/orca/main.go b/cmd/orca/main.go index 4a70e6c..4bf85cf 100644 --- a/cmd/orca/main.go +++ b/cmd/orca/main.go @@ -1,6 +1,7 @@ package main import ( + "flag" "fmt" "log" "os" @@ -8,10 +9,14 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/orca/orca/internal/config" "github.com/orca/orca/internal/tui" + "github.com/orca/orca/internal/web" "github.com/orca/orca/pkg/kernel" ) func main() { + webMode := flag.Bool("web", false, "Run in web mode on port 8081") + flag.Parse() + cfg, err := config.LoadConfig() if err != nil { log.Fatalf("Failed to load config: %v", err) @@ -26,12 +31,20 @@ func main() { log.Printf("Warning: failed to load skills: %v", err) } - m := tui.NewModel(k) - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + if *webMode { + server := web.NewServer(k, 8081) + fmt.Println("Starting web server on http://localhost:8081") + if err := server.Start(); err != nil { + log.Fatalf("Failed to start web server: %v", err) + } + } else { + m := tui.NewModel(k) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) - if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err) - os.Exit(1) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err) + os.Exit(1) + } } if err := k.Stop(); err != nil { diff --git a/internal/web/html.go b/internal/web/html.go new file mode 100644 index 0000000..b42e302 --- /dev/null +++ b/internal/web/html.go @@ -0,0 +1,365 @@ +package web + +const indexHTML = ` + + + + + orca.agent + + + +
+

orca.agent

+ v0.1.0 +
+
+
+
+
Processing...
+
+ + +
+
+
+
+

Statistics

+
+ Tools: + 0 +
+
+ Skills: + 0 +
+
+ Agents: + 0 +
+
+
+

Active Agents

+
+
+
+
+ + + +` diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..a75343b --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,203 @@ +package web + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/orca/orca/pkg/actor" + "github.com/orca/orca/pkg/kernel" +) + +type Server struct { + kernel *kernel.Kernel + port int + clients map[string]*SSEClient + clientsMu sync.RWMutex + msgCounter int +} + +type SSEClient struct { + ID string + Writer http.ResponseWriter + Flusher http.Flusher + Done chan bool +} + +type sseWriter struct { + client *SSEClient + mu sync.Mutex +} + +func (w *sseWriter) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + + select { + case <-w.client.Done: + return len(p), nil + default: + } + + data := strings.ReplaceAll(string(p), "\n", "") + if data == "" { + return len(p), nil + } + + fmt.Fprintf(w.client.Writer, "data: %s\n\n", data) + w.client.Flusher.Flush() + return len(p), nil +} + +func NewServer(k *kernel.Kernel, port int) *Server { + if port <= 0 { + port = 8081 + } + return &Server{ + kernel: k, + port: port, + clients: make(map[string]*SSEClient), + } +} + +func (s *Server) Start() error { + mux := http.NewServeMux() + + mux.HandleFunc("/api/stream", s.handleStream) + mux.HandleFunc("/api/chat", s.handleChat) + mux.HandleFunc("/api/stats", s.handleStats) + mux.HandleFunc("/api/agents", s.handleAgents) + mux.HandleFunc("/", s.handleIndex) + + addr := fmt.Sprintf(":%d", s.port) + return http.ListenAndServe(addr, mux) +} + +func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + return + } + + s.msgCounter++ + clientID := fmt.Sprintf("client-%d", s.msgCounter) + client := &SSEClient{ + ID: clientID, + Writer: w, + Flusher: flusher, + Done: make(chan bool), + } + + s.clientsMu.Lock() + s.clients[clientID] = client + s.clientsMu.Unlock() + + fmt.Fprintf(w, "event: connected\ndata: %s\n\n", clientID) + flusher.Flush() + + <-r.Context().Done() + + s.clientsMu.Lock() + delete(s.clients, clientID) + s.clientsMu.Unlock() + close(client.Done) +} + +func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Message string `json:"message"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + s.clientsMu.RLock() + var client *SSEClient + for _, c := range s.clients { + client = c + break + } + s.clientsMu.RUnlock() + + if client != nil { + writer := &sseWriter{client: client} + s.kernel.SetStreamWriter(writer) + } + + resp, err := s.kernel.SendMessage("user", "llm", req.Message) + if err != nil { + json.NewEncoder(w).Encode(map[string]string{ + "error": err.Error(), + }) + return + } + + json.NewEncoder(w).Encode(map[string]string{ + "response": resp, + }) +} + +func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + stats := map[string]interface{}{ + "tools": 0, + "skills": 0, + "agents": 0, + } + + if tm := s.kernel.ToolManager(); tm != nil { + stats["tools"] = tm.Count() + } + if sm := s.kernel.SkillManager(); sm != nil { + stats["skills"] = len(sm.ListSkills()) + } + if as := s.kernel.ActorSystem(); as != nil { + stats["agents"] = as.AgentCount() + } + + json.NewEncoder(w).Encode(stats) +} + +func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + var agents []map[string]string + if as := s.kernel.ActorSystem(); as != nil { + for _, info := range as.AgentInfos() { + status := "idle" + if info.Status == actor.StatusProcessing { + status = "running" + } + agents = append(agents, map[string]string{ + "id": info.ID, + "status": status, + }) + } + } + + json.NewEncoder(w).Encode(agents) +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(indexHTML)) +}