// Package server — submit.go handles task submission in two modes: // client (simple form) and operator (chat with Claude API). package server import ( "bufio" "bytes" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/dal/kaos/internal/supervisor" ) // chatState manages an operator chat session. type chatState struct { mu sync.Mutex id string messages []chatMessage response string done bool listeners map[chan string]bool } // chatMessage represents a single message in the chat. type chatMessage struct { Role string `json:"role"` Content string `json:"content"` } // nextTaskNumber finds the highest T{XX} number across all tasks and returns the next one. func nextTaskNumber(tasksDir string) (string, error) { tasks, err := supervisor.ScanTasks(tasksDir) if err != nil { return "", err } maxNum := 0 re := regexp.MustCompile(`^T(\d+)$`) for _, t := range tasks { if matches := re.FindStringSubmatch(t.ID); matches != nil { num, err := strconv.Atoi(matches[1]) if err != nil { continue } if num > maxNum { maxNum = num } } } return fmt.Sprintf("T%02d", maxNum+1), nil } // handleSubmitPage serves the submission page. func (s *Server) handleSubmitPage(c *gin.Context) { c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, renderSubmitPage()) } // handleSimpleSubmit creates a task in backlog/ from the client form. func (s *Server) handleSimpleSubmit(c *gin.Context) { title := strings.TrimSpace(c.PostForm("title")) desc := strings.TrimSpace(c.PostForm("description")) priority := strings.TrimSpace(c.PostForm("priority")) if title == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "naslov je obavezan"}) return } if priority == "" { priority = "Srednji" } taskID, err := nextTaskNumber(s.Config.TasksDir) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } now := time.Now().Format("2006-01-02 15:04") content := fmt.Sprintf(`# %s: %s **Kreirao:** klijent (prijava) **Datum:** %s **Agent:** — **Model:** — **Zavisi od:** — **Prioritet:** %s **Izvor:** klijent --- ## Opis %s ## Originalna prijava %s `, taskID, title, now, priority, desc, desc) path := filepath.Join(s.Config.TasksDir, "backlog", taskID+".md") if err := os.WriteFile(path, []byte(content), 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok", "task_id": taskID}) } // handleChatSubmit handles an operator chat message by calling the Claude API. func (s *Server) handleChatSubmit(c *gin.Context) { var req struct { Message string `json:"message"` ChatID string `json:"chat_id,omitempty"` } if err := c.BindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "neispravan zahtev"}) return } if strings.TrimSpace(req.Message) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "poruka je obavezna"}) return } apiKey := os.Getenv("ANTHROPIC_API_KEY") if apiKey == "" { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ANTHROPIC_API_KEY nije podešen"}) return } // Get or create chat session chatID := req.ChatID var chat *chatState s.chatMu.Lock() if chatID != "" { chat = s.chats[chatID] } if chat == nil { chatID = s.console.nextExecID() chat = &chatState{ id: chatID, listeners: make(map[chan string]bool), } s.chats[chatID] = chat } s.chatMu.Unlock() // Add user message and reset response state chat.mu.Lock() chat.messages = append(chat.messages, chatMessage{Role: "user", Content: req.Message}) chat.done = false chat.response = "" chat.mu.Unlock() // Build system prompt with task context tasks, _ := supervisor.ScanTasks(s.Config.TasksDir) context := buildTaskContext(tasks) projectRoot := filepath.Dir(s.Config.TasksDir) claudeMD, _ := os.ReadFile(filepath.Join(projectRoot, "CLAUDE.md")) systemPrompt := string(claudeMD) + "\n\n## Trenutno stanje taskova\n\n" + context + "\n\n## Tvoja uloga\n\nTi si KAOS mastermind. Operater ti govori šta treba. " + "Predloži task u markdown formatu ili odgovori na pitanje. " + "Ako operater kaže 'kreiraj task', generiši task markdown u standardnom KAOS formatu." go s.callClaudeAPI(chat, apiKey, systemPrompt) c.JSON(http.StatusOK, gin.H{"chat_id": chatID}) } // handleChatStream streams the Claude API response via SSE. func (s *Server) handleChatStream(c *gin.Context) { chatID := c.Param("id") s.chatMu.RLock() chat := s.chats[chatID] s.chatMu.RUnlock() if chat == nil { c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"}) return } c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") ch := make(chan string, 64) chat.mu.Lock() chat.listeners[ch] = true // Replay existing response if available if chat.response != "" { ch <- chat.response } isDone := chat.done chat.mu.Unlock() if isDone { c.SSEvent("done", "complete") return } c.Stream(func(w io.Writer) bool { select { case data, ok := <-ch: if !ok { c.SSEvent("done", "complete") return false } if data == "__DONE__" { c.SSEvent("done", "complete") chat.mu.Lock() delete(chat.listeners, ch) chat.mu.Unlock() return false } c.SSEvent("message", data) return true case <-c.Request.Context().Done(): chat.mu.Lock() delete(chat.listeners, ch) chat.mu.Unlock() return false } }) } // callClaudeAPI calls the Anthropic Messages API with streaming and relays text to listeners. func (s *Server) callClaudeAPI(chat *chatState, apiKey, systemPrompt string) { chat.mu.Lock() messages := make([]chatMessage, len(chat.messages)) copy(messages, chat.messages) chat.mu.Unlock() // Build API request body apiMessages := make([]map[string]string, len(messages)) for i, m := range messages { apiMessages[i] = map[string]string{"role": m.Role, "content": m.Content} } body := map[string]interface{}{ "model": "claude-sonnet-4-6", "max_tokens": 4096, "system": systemPrompt, "messages": apiMessages, "stream": true, } jsonBody, _ := json.Marshal(body) req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(jsonBody)) if err != nil { broadcastChatError(chat, "HTTP greška: "+err.Error()) return } req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", apiKey) req.Header.Set("anthropic-version", "2023-06-01") resp, err := http.DefaultClient.Do(req) if err != nil { broadcastChatError(chat, "API greška: "+err.Error()) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) broadcastChatError(chat, fmt.Sprintf("API %d: %s", resp.StatusCode, string(respBody))) return } // Parse SSE stream from Anthropic API scanner := bufio.NewScanner(resp.Body) var fullResponse strings.Builder for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } data := strings.TrimPrefix(line, "data: ") if data == "[DONE]" { break } var event map[string]interface{} if err := json.Unmarshal([]byte(data), &event); err != nil { continue } eventType, _ := event["type"].(string) if eventType == "content_block_delta" { delta, ok := event["delta"].(map[string]interface{}) if !ok { continue } text, _ := delta["text"].(string) if text != "" { fullResponse.WriteString(text) broadcastChatText(chat, fullResponse.String()) } } } // Finalize: save response and signal done chat.mu.Lock() chat.response = fullResponse.String() chat.messages = append(chat.messages, chatMessage{Role: "assistant", Content: chat.response}) chat.done = true for ch := range chat.listeners { select { case ch <- "__DONE__": default: } } chat.mu.Unlock() } // broadcastChatText sends the current accumulated text to all listeners. func broadcastChatText(chat *chatState, text string) { chat.mu.Lock() chat.response = text for ch := range chat.listeners { select { case ch <- text: default: } } chat.mu.Unlock() } // broadcastChatError sends an error message and signals done. func broadcastChatError(chat *chatState, errMsg string) { chat.mu.Lock() chat.done = true chat.response = "Greška: " + errMsg for ch := range chat.listeners { select { case ch <- "Greška: " + errMsg: default: } select { case ch <- "__DONE__": default: } } chat.mu.Unlock() } // buildTaskContext creates a text summary of current tasks for the system prompt. func buildTaskContext(tasks []supervisor.Task) string { var sb strings.Builder for _, status := range []string{"backlog", "ready", "active", "review", "done"} { sb.WriteString("### " + strings.ToUpper(status) + "\n") found := false for _, t := range tasks { if t.Status == status { sb.WriteString(fmt.Sprintf("- %s: %s (%s, %s)\n", t.ID, t.Title, t.Agent, t.Model)) found = true } } if !found { sb.WriteString("(prazno)\n") } sb.WriteString("\n") } return sb.String() }