T22: Prijava — dva moda (klijent forma + operater chat sa Claude API)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f137703f1b
commit
b3645beea0
78
TASKS/reports/T22-report.md
Normal file
78
TASKS/reports/T22-report.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# T22 Izveštaj: Prijava — dva moda (klijent i operater)
|
||||||
|
|
||||||
|
**Agent:** coder
|
||||||
|
**Model:** Opus
|
||||||
|
**Datum:** 2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Šta je urađeno
|
||||||
|
|
||||||
|
Nova sekcija "Prijava" sa dva moda: klijent (prosta forma) i operater (chat sa Claude API).
|
||||||
|
|
||||||
|
### Izmenjeni/kreirani fajlovi
|
||||||
|
|
||||||
|
| Fajl | Izmena |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/server/submit.go` | NOVO — handleri za oba moda, nextTaskNumber, Claude API streaming, chatState |
|
||||||
|
| `web/templates/submit.html` | NOVO — template sa toggle klijent/operater, forma, chat UI |
|
||||||
|
| `internal/server/server.go` | chatMu/chats polja, 4 nove rute, sync import |
|
||||||
|
| `internal/server/render.go` | renderSubmitPage(), registracija submit.html template |
|
||||||
|
| `internal/server/server_test.go` | 13 novih testova |
|
||||||
|
| `web/static/style.css` | CSS za submit, form, chat, priority |
|
||||||
|
| `web/templates/layout.html` | Prijava nav tab |
|
||||||
|
| `web/templates/console.html` | Prijava nav tab |
|
||||||
|
| `web/templates/docs-list.html` | Prijava nav tab |
|
||||||
|
| `web/templates/docs-view.html` | Prijava nav tab |
|
||||||
|
|
||||||
|
### Klijent mod
|
||||||
|
|
||||||
|
- Forma: naslov (obavezno), opis (opciono), prioritet (Nizak/Srednji/Visok)
|
||||||
|
- POST /submit/simple → kreira task u backlog/
|
||||||
|
- Auto-numeracija: skenira sve taskove, nađe max T{XX}, inkrementiraj
|
||||||
|
- Task format sa svim standardnim KAOS poljima + Prioritet + Izvor
|
||||||
|
- Vizuelna potvrda sa task ID-em
|
||||||
|
|
||||||
|
### Operater mod
|
||||||
|
|
||||||
|
- Chat interfejs sa Claude API (Sonnet model)
|
||||||
|
- System prompt: CLAUDE.md + trenutno stanje svih taskova
|
||||||
|
- SSE streaming odgovora (Anthropic streaming API → browser)
|
||||||
|
- Višestruke poruke u istoj sesiji (chat_id)
|
||||||
|
- Automatsko onemogućenje inputa dok Claude odgovara
|
||||||
|
|
||||||
|
### Endpointi
|
||||||
|
|
||||||
|
| Endpoint | Metod | Opis |
|
||||||
|
|----------|-------|------|
|
||||||
|
| /submit | GET | Stranica za prijavu |
|
||||||
|
| /submit/simple | POST | Klijent forma → backlog/ |
|
||||||
|
| /submit/chat | POST | Operater poruka → Claude API |
|
||||||
|
| /submit/chat/stream/:id | GET | SSE stream odgovora |
|
||||||
|
|
||||||
|
### Navigacija
|
||||||
|
|
||||||
|
Novi tab "Prijava" dodat u header svih stranica (Kanban, Dokumenti, Konzola, Prijava).
|
||||||
|
|
||||||
|
### Novi testovi — 13 PASS
|
||||||
|
|
||||||
|
```
|
||||||
|
TestSubmitPage PASS
|
||||||
|
TestSubmitPage_ClientModeIsDefault PASS
|
||||||
|
TestSimpleSubmit_CreatesTask PASS
|
||||||
|
TestSimpleSubmit_MissingTitle PASS
|
||||||
|
TestSimpleSubmit_AutoNumbering PASS
|
||||||
|
TestSimpleSubmit_DefaultPriority PASS
|
||||||
|
TestChatSubmit_NoAPIKey PASS
|
||||||
|
TestChatSubmit_EmptyMessage PASS
|
||||||
|
TestChatStream_NotFound PASS
|
||||||
|
TestNextTaskNumber PASS
|
||||||
|
TestBuildTaskContext PASS
|
||||||
|
TestSubmitPage_HasPrijavaNav PASS
|
||||||
|
TestDashboard_HasPrijavaNav PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ukupno projekat: 155 testova, svi prolaze
|
||||||
|
|
||||||
|
- `go vet ./...` — čist
|
||||||
|
- `go build ./...` — prolazi
|
||||||
@ -68,6 +68,7 @@ func init() {
|
|||||||
"templates/docs-list.html",
|
"templates/docs-list.html",
|
||||||
"templates/docs-view.html",
|
"templates/docs-view.html",
|
||||||
"templates/console.html",
|
"templates/console.html",
|
||||||
|
"templates/submit.html",
|
||||||
"templates/partials/column.html",
|
"templates/partials/column.html",
|
||||||
"templates/partials/task-card.html",
|
"templates/partials/task-card.html",
|
||||||
"templates/partials/task-detail.html",
|
"templates/partials/task-detail.html",
|
||||||
@ -184,6 +185,15 @@ func renderConsolePage() string {
|
|||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderSubmitPage generates the submit page HTML.
|
||||||
|
func renderSubmitPage() string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.ExecuteTemplate(&buf, "submit", nil); err != nil {
|
||||||
|
return "Greška pri renderovanju: " + err.Error()
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
// renderSearchResults generates the search results HTML fragment.
|
// renderSearchResults generates the search results HTML fragment.
|
||||||
func renderSearchResults(data searchResultsData) string {
|
func renderSearchResults(data searchResultsData) string {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@ -21,6 +22,8 @@ type Server struct {
|
|||||||
Router *gin.Engine
|
Router *gin.Engine
|
||||||
console *consoleManager
|
console *consoleManager
|
||||||
events *eventBroker
|
events *eventBroker
|
||||||
|
chatMu sync.RWMutex
|
||||||
|
chats map[string]*chatState
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskResponse is the JSON representation of a task.
|
// taskResponse is the JSON representation of a task.
|
||||||
@ -70,6 +73,7 @@ func New(cfg *config.Config) *Server {
|
|||||||
Router: router,
|
Router: router,
|
||||||
console: newConsoleManager(),
|
console: newConsoleManager(),
|
||||||
events: newEventBroker(cfg.TasksDir),
|
events: newEventBroker(cfg.TasksDir),
|
||||||
|
chats: make(map[string]*chatState),
|
||||||
}
|
}
|
||||||
|
|
||||||
// No caching for dynamic routes — disk is the source of truth.
|
// No caching for dynamic routes — disk is the source of truth.
|
||||||
@ -119,6 +123,12 @@ func (s *Server) setupRoutes() {
|
|||||||
// Docs routes
|
// Docs routes
|
||||||
s.Router.GET("/docs", s.handleDocsList)
|
s.Router.GET("/docs", s.handleDocsList)
|
||||||
s.Router.GET("/docs/*path", s.handleDocsView)
|
s.Router.GET("/docs/*path", s.handleDocsView)
|
||||||
|
|
||||||
|
// Submit routes
|
||||||
|
s.Router.GET("/submit", s.handleSubmitPage)
|
||||||
|
s.Router.POST("/submit/simple", s.handleSimpleSubmit)
|
||||||
|
s.Router.POST("/submit/chat", s.handleChatSubmit)
|
||||||
|
s.Router.GET("/submit/chat/stream/:id", s.handleChatStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiGetTasks returns all tasks as JSON.
|
// apiGetTasks returns all tasks as JSON.
|
||||||
|
|||||||
@ -1314,6 +1314,255 @@ func TestRewriteLinksSimple_NestedDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- T22: Submit tests ---
|
||||||
|
|
||||||
|
func TestSubmitPage(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, "Klijent") {
|
||||||
|
t.Error("expected 'Klijent' mode button")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "Operater") {
|
||||||
|
t.Error("expected 'Operater' mode button")
|
||||||
|
}
|
||||||
|
if !containsStr(body, "mode-client") {
|
||||||
|
t.Error("expected client mode section")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitPage_ClientModeIsDefault(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
// Operator mode should be hidden by default
|
||||||
|
if !containsStr(body, `id="mode-operator" class="submit-mode" style="display:none"`) {
|
||||||
|
t.Error("expected operator mode to be hidden by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleSubmit_CreatesTask(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
form := strings.NewReader("title=Test+prijava&description=Opis+testa&priority=Visok")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Errorf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, ok := resp["task_id"].(string)
|
||||||
|
if !ok || taskID == "" {
|
||||||
|
t.Fatal("expected non-empty task_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file was created in backlog
|
||||||
|
path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md")
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected task file in backlog: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !containsStr(string(content), "Test prijava") {
|
||||||
|
t.Error("expected title in task file")
|
||||||
|
}
|
||||||
|
if !containsStr(string(content), "Visok") {
|
||||||
|
t.Error("expected priority in task file")
|
||||||
|
}
|
||||||
|
if !containsStr(string(content), "klijent (prijava)") {
|
||||||
|
t.Error("expected 'klijent (prijava)' as creator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleSubmit_MissingTitle(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
form := strings.NewReader("description=Samo+opis")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for missing title, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleSubmit_AutoNumbering(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Existing tasks: T01 (done), T08 (backlog)
|
||||||
|
// Next should be T09
|
||||||
|
|
||||||
|
form := strings.NewReader("title=Novi+task&priority=Srednji")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
taskID, _ := resp["task_id"].(string)
|
||||||
|
if taskID != "T09" {
|
||||||
|
t.Errorf("expected T09 (next after T08), got %s", taskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimpleSubmit_DefaultPriority(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
form := strings.NewReader("title=Bez+prioriteta")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/simple", form)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
taskID, _ := resp["task_id"].(string)
|
||||||
|
path := filepath.Join(srv.Config.TasksDir, "backlog", taskID+".md")
|
||||||
|
content, _ := os.ReadFile(path)
|
||||||
|
|
||||||
|
if !containsStr(string(content), "Srednji") {
|
||||||
|
t.Error("expected default priority 'Srednji'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSubmit_NoAPIKey(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
// Ensure no API key is set
|
||||||
|
os.Unsetenv("ANTHROPIC_API_KEY")
|
||||||
|
|
||||||
|
body := `{"message":"test poruka"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("expected 503 without API key, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSubmit_EmptyMessage(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
os.Setenv("ANTHROPIC_API_KEY", "test-key")
|
||||||
|
defer os.Unsetenv("ANTHROPIC_API_KEY")
|
||||||
|
|
||||||
|
body := `{"message":""}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/submit/chat", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for empty message, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatStream_NotFound(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/submit/chat/stream/nonexistent", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextTaskNumber(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tasksDir := filepath.Join(dir, "TASKS")
|
||||||
|
for _, f := range []string{"backlog", "ready", "active", "review", "done"} {
|
||||||
|
os.MkdirAll(filepath.Join(tasksDir, f), 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.WriteFile(filepath.Join(tasksDir, "done", "T01.md"), []byte(testTask1), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tasksDir, "backlog", "T08.md"), []byte(testTask2), 0644)
|
||||||
|
|
||||||
|
num, err := nextTaskNumber(tasksDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if num != "T09" {
|
||||||
|
t.Errorf("expected T09, got %s", num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTaskContext(t *testing.T) {
|
||||||
|
tasks := []supervisor.Task{
|
||||||
|
{ID: "T01", Title: "Init", Status: "done", Agent: "coder", Model: "Sonnet"},
|
||||||
|
{ID: "T02", Title: "Server", Status: "active", Agent: "coder", Model: "Opus"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := buildTaskContext(tasks)
|
||||||
|
if !containsStr(ctx, "T01: Init") {
|
||||||
|
t.Error("expected T01 in context")
|
||||||
|
}
|
||||||
|
if !containsStr(ctx, "T02: Server") {
|
||||||
|
t.Error("expected T02 in context")
|
||||||
|
}
|
||||||
|
if !containsStr(ctx, "DONE") {
|
||||||
|
t.Error("expected DONE section")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitPage_HasPrijavaNav(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, `href="/submit"`) {
|
||||||
|
t.Error("expected Prijava nav link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboard_HasPrijavaNav(t *testing.T) {
|
||||||
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !containsStr(body, `href="/submit"`) {
|
||||||
|
t.Error("expected Prijava nav link in dashboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- T21: UI tests ---
|
||||||
|
|
||||||
func TestDashboard_DetailOpenClassInJS(t *testing.T) {
|
func TestDashboard_DetailOpenClassInJS(t *testing.T) {
|
||||||
srv := setupTestServer(t)
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
|
|||||||
385
code/internal/server/submit.go
Normal file
385
code/internal/server/submit.go
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
// 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()
|
||||||
|
}
|
||||||
@ -672,6 +672,149 @@ body.detail-open .board {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Submit / Prijava */
|
||||||
|
.submit-container {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mode {
|
||||||
|
border-color: #333;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mode.active {
|
||||||
|
border-color: #e94560;
|
||||||
|
color: #eee;
|
||||||
|
background: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-mode h2 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #eee;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-input {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-msg {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-success {
|
||||||
|
background: #4ecca3;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-error {
|
||||||
|
background: #e94560;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat (operator mode) */
|
||||||
|
#mode-operator {
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
background: #111;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-role {
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-user {
|
||||||
|
color: #6ec6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bot {
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.board { grid-template-columns: repeat(3, 1fr); }
|
.board { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
<a href="/" class="btn">Kanban</a>
|
<a href="/" class="btn">Kanban</a>
|
||||||
<a href="/docs" class="btn">Dokumenti</a>
|
<a href="/docs" class="btn">Dokumenti</a>
|
||||||
<a href="/console" class="btn btn-active">Konzola</a>
|
<a href="/console" class="btn btn-active">Konzola</a>
|
||||||
|
<a href="/submit" class="btn">Prijava</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
<a href="/" class="btn">Kanban</a>
|
<a href="/" class="btn">Kanban</a>
|
||||||
<a href="/docs" class="btn btn-active">Dokumenti</a>
|
<a href="/docs" class="btn btn-active">Dokumenti</a>
|
||||||
<a href="/console" class="btn">Konzola</a>
|
<a href="/console" class="btn">Konzola</a>
|
||||||
|
<a href="/submit" class="btn">Prijava</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="docs-container">
|
<div class="docs-container">
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
<a href="/" class="btn">Kanban</a>
|
<a href="/" class="btn">Kanban</a>
|
||||||
<a href="/docs" class="btn btn-active">Dokumenti</a>
|
<a href="/docs" class="btn btn-active">Dokumenti</a>
|
||||||
<a href="/console" class="btn">Konzola</a>
|
<a href="/console" class="btn">Konzola</a>
|
||||||
|
<a href="/submit" class="btn">Prijava</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="docs-container">
|
<div class="docs-container">
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
<a href="/" class="btn btn-active">Kanban</a>
|
<a href="/" class="btn btn-active">Kanban</a>
|
||||||
<a href="/docs" class="btn">Dokumenti</a>
|
<a href="/docs" class="btn">Dokumenti</a>
|
||||||
<a href="/console" class="btn">Konzola</a>
|
<a href="/console" class="btn">Konzola</a>
|
||||||
|
<a href="/submit" class="btn">Prijava</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
162
code/web/templates/submit.html
Normal file
162
code/web/templates/submit.html
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
{{define "submit"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KAOS — Prijava</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>🔧 KAOS Dashboard</h1>
|
||||||
|
<div class="header-right">
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="btn">Kanban</a>
|
||||||
|
<a href="/docs" class="btn">Dokumenti</a>
|
||||||
|
<a href="/console" class="btn">Konzola</a>
|
||||||
|
<a href="/submit" class="btn btn-active">Prijava</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="submit-container">
|
||||||
|
<div class="submit-mode-toggle">
|
||||||
|
<button class="btn btn-mode active" id="btn-client" onclick="switchMode('client')">👤 Klijent</button>
|
||||||
|
<button class="btn btn-mode" id="btn-operator" onclick="switchMode('operator')">🔧 Operater</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client mode -->
|
||||||
|
<div id="mode-client" class="submit-mode">
|
||||||
|
<h2>📝 Nova prijava</h2>
|
||||||
|
<form id="client-form" onsubmit="submitClientForm(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Naslov:</label>
|
||||||
|
<input type="text" name="title" id="submit-title" class="form-input" placeholder="Kratak opis problema ili ideje..." required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Opis (opciono):</label>
|
||||||
|
<textarea name="description" id="submit-desc" class="form-input" rows="6" placeholder="Detaljniji opis..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Prioritet:</label>
|
||||||
|
<div class="priority-group">
|
||||||
|
<label class="priority-label"><input type="radio" name="priority" value="Nizak"> Nizak</label>
|
||||||
|
<label class="priority-label"><input type="radio" name="priority" value="Srednji" checked> Srednji</label>
|
||||||
|
<label class="priority-label"><input type="radio" name="priority" value="Visok"> Visok</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success">Pošalji 📨</button>
|
||||||
|
</form>
|
||||||
|
<div id="client-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Operator mode -->
|
||||||
|
<div id="mode-operator" class="submit-mode" style="display:none">
|
||||||
|
<h2>🔧 Operater mod</h2>
|
||||||
|
<div class="chat-messages" id="chat-messages"></div>
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<input type="text" id="chat-input" class="console-input" placeholder="Piši..." onkeydown="if(event.key==='Enter')sendChat()" autocomplete="off">
|
||||||
|
<button class="btn btn-move" onclick="sendChat()">⏎</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var currentChatID = '';
|
||||||
|
|
||||||
|
function switchMode(mode) {
|
||||||
|
document.getElementById('mode-client').style.display = mode === 'client' ? 'block' : 'none';
|
||||||
|
document.getElementById('mode-operator').style.display = mode === 'operator' ? 'flex' : 'none';
|
||||||
|
document.getElementById('btn-client').classList.toggle('active', mode === 'client');
|
||||||
|
document.getElementById('btn-operator').classList.toggle('active', mode === 'operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitClientForm(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var form = document.getElementById('client-form');
|
||||||
|
var data = new FormData(form);
|
||||||
|
|
||||||
|
fetch('/submit/simple', {method: 'POST', body: data})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var el = document.getElementById('client-result');
|
||||||
|
if (data.error) {
|
||||||
|
el.innerHTML = '<div class="submit-msg submit-error">' + escapeHtml(data.error) + '</div>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '<div class="submit-msg submit-success">✅ ' + data.task_id + ' kreiran u backlog/</div>';
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendChat() {
|
||||||
|
var input = document.getElementById('chat-input');
|
||||||
|
var msg = input.value.trim();
|
||||||
|
if (!msg) return;
|
||||||
|
|
||||||
|
var messages = document.getElementById('chat-messages');
|
||||||
|
messages.innerHTML += '<div class="chat-msg chat-user"><span class="chat-role">👤</span> ' + escapeHtml(msg) + '</div>';
|
||||||
|
input.value = '';
|
||||||
|
input.disabled = true;
|
||||||
|
|
||||||
|
var botId = 'bot-' + Date.now();
|
||||||
|
messages.innerHTML += '<div class="chat-msg chat-bot" id="' + botId + '"><span class="chat-role">🤖</span> <span class="chat-text">...</span></div>';
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
|
||||||
|
fetch('/submit/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({message: msg, chat_id: currentChatID})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) {
|
||||||
|
document.getElementById(botId).querySelector('.chat-text').textContent = 'Greška: ' + data.error;
|
||||||
|
input.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentChatID = data.chat_id;
|
||||||
|
streamChatResponse(data.chat_id, botId);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
document.getElementById(botId).querySelector('.chat-text').textContent = 'Greška: ' + err.message;
|
||||||
|
input.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamChatResponse(chatID, botId) {
|
||||||
|
var source = new EventSource('/submit/chat/stream/' + chatID);
|
||||||
|
var el = document.getElementById(botId);
|
||||||
|
var input = document.getElementById('chat-input');
|
||||||
|
|
||||||
|
source.addEventListener('message', function(e) {
|
||||||
|
if (el) {
|
||||||
|
el.querySelector('.chat-text').textContent = e.data;
|
||||||
|
var messages = document.getElementById('chat-messages');
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
source.addEventListener('done', function() {
|
||||||
|
source.close();
|
||||||
|
input.disabled = false;
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
source.onerror = function() {
|
||||||
|
source.close();
|
||||||
|
input.disabled = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
Loading…
Reference in New Issue
Block a user