- Project infrastructure: go.mod, Makefile, .gitignore, README - Configuration system with YAML parsing and env overrides - Structured logging with slog - AI provider interface with Ollama, OpenAI, Anthropic implementations - Provider manager with runtime switching - Agent orchestrator with ReAct loop (reason-act-observe) - Tool registry with JSON Schema descriptions - Terminal tool: shell command execution with safety controls - Filesystem tools: read, write, list with path access control - Conversation memory with session management - Web UI server with WebSocket streaming - Modern dark theme UI with glassmorphism, animations - Frontend: WebSocket client, markdown rendering, tool call display
215 lines
5.6 KiB
Go
215 lines
5.6 KiB
Go
package ui
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
"git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/agent"
|
|
"git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai"
|
|
"git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/config"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var staticFiles embed.FS
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
// Server is the HTTP/WebSocket server for the UI.
|
|
type Server struct {
|
|
cfg config.ServerConfig
|
|
aiManager *ai.Manager
|
|
agent *agent.Agent
|
|
memory *agent.Memory
|
|
log *slog.Logger
|
|
}
|
|
|
|
// NewServer creates a new UI server.
|
|
func NewServer(cfg config.ServerConfig, aiMgr *ai.Manager, ag *agent.Agent, mem *agent.Memory, log *slog.Logger) *Server {
|
|
return &Server{
|
|
cfg: cfg,
|
|
aiManager: aiMgr,
|
|
agent: ag,
|
|
memory: mem,
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// Start begins serving HTTP requests.
|
|
func (s *Server) Start() error {
|
|
mux := http.NewServeMux()
|
|
|
|
// Static files
|
|
mux.Handle("/", http.FileServer(http.FS(staticFiles)))
|
|
|
|
// API endpoints
|
|
mux.HandleFunc("/api/providers", s.handleProviders)
|
|
mux.HandleFunc("/api/provider", s.handleSwitchProvider)
|
|
mux.HandleFunc("/api/sessions", s.handleSessions)
|
|
mux.HandleFunc("/api/session/clear", s.handleClearSession)
|
|
|
|
// WebSocket for chat
|
|
mux.HandleFunc("/ws", s.handleWebSocket)
|
|
|
|
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
|
|
s.log.Info("starting UI server", "address", addr)
|
|
return http.ListenAndServe(addr, mux)
|
|
}
|
|
|
|
// --- REST Handlers ---
|
|
|
|
func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"providers": s.aiManager.List(),
|
|
"active": s.aiManager.ActiveName(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSwitchProvider(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut && r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct {
|
|
Provider string `json:"provider"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := s.aiManager.SetActive(req.Provider); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Update agent's provider
|
|
p, _ := s.aiManager.Active()
|
|
s.agent.SetProvider(p)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "active": req.Provider})
|
|
}
|
|
|
|
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"sessions": s.memory.ListSessions(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleClearSession(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req struct {
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.memory.Clear(req.SessionID)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// --- WebSocket Handler ---
|
|
|
|
type wsMessage struct {
|
|
Type string `json:"type"` // "message", "tool_call", "tool_result", "stream", "done", "error"
|
|
Content string `json:"content,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
ToolName string `json:"tool_name,omitempty"`
|
|
ToolArgs string `json:"tool_args,omitempty"`
|
|
ToolID string `json:"tool_id,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
s.log.Error("websocket upgrade failed", "error", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
var mu sync.Mutex
|
|
writeJSON := func(msg wsMessage) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
conn.WriteJSON(msg)
|
|
}
|
|
|
|
for {
|
|
var incoming wsMessage
|
|
if err := conn.ReadJSON(&incoming); err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
|
s.log.Error("websocket read error", "error", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if incoming.Type != "message" {
|
|
continue
|
|
}
|
|
|
|
sessionID := incoming.SessionID
|
|
if sessionID == "" {
|
|
sessionID = "default"
|
|
}
|
|
|
|
history := s.memory.Get(sessionID)
|
|
|
|
go func() {
|
|
callback := func(chunk string, toolCall *ai.ToolCall, toolResult *string, done bool) {
|
|
if done {
|
|
writeJSON(wsMessage{Type: "done", SessionID: sessionID})
|
|
return
|
|
}
|
|
if chunk != "" {
|
|
writeJSON(wsMessage{Type: "stream", Content: chunk, SessionID: sessionID})
|
|
}
|
|
if toolCall != nil && toolResult == nil {
|
|
writeJSON(wsMessage{
|
|
Type: "tool_call",
|
|
ToolName: toolCall.Function.Name,
|
|
ToolArgs: toolCall.Function.Arguments,
|
|
ToolID: toolCall.ID,
|
|
SessionID: sessionID,
|
|
})
|
|
}
|
|
if toolCall != nil && toolResult != nil {
|
|
writeJSON(wsMessage{
|
|
Type: "tool_result",
|
|
ToolName: toolCall.Function.Name,
|
|
Content: *toolResult,
|
|
ToolID: toolCall.ID,
|
|
SessionID: sessionID,
|
|
})
|
|
}
|
|
}
|
|
|
|
messages, err := s.agent.Run(r.Context(), incoming.Content, history, callback)
|
|
if err != nil {
|
|
writeJSON(wsMessage{Type: "error", Content: err.Error(), SessionID: sessionID})
|
|
return
|
|
}
|
|
|
|
// Save updated history (strip system prompt)
|
|
var filtered []ai.Message
|
|
for _, m := range messages {
|
|
if m.Role != "system" {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
s.memory.Set(sessionID, filtered)
|
|
}()
|
|
}
|
|
}
|