KAOS/code/internal/server/submit.go
djuka b3645beea0 T22: Prijava — dva moda (klijent forma + operater chat sa Claude API)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:38:05 +00:00

386 lines
9.1 KiB
Go

// 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()
}