Inicijalna implementacija Claude Web Chat (Faza 1 - CLI mod)
All checks were successful
Tests / unit-tests (push) Successful in 51s
All checks were successful
Tests / unit-tests (push) Successful in 51s
- Login sa session cookie autentifikacijom - Lista projekata iz filesystem-a - Chat sa Claude CLI preko WebSocket-a - Streaming NDJSON parsiranje iz CLI stdout-a - Sesija zivi nezavisno od browsera (reconnect replay) - Sidebar sa .md fajlovima i markdown renderovanjem - Dark tema, htmx + Go templates - 47 unit testova Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
3283888738
11
.gitea/workflows/test.yml
Normal file
11
.gitea/workflows/test.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
name: Tests
|
||||||
|
on: [push]
|
||||||
|
jobs:
|
||||||
|
unit-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23'
|
||||||
|
- run: go test ./... -v -count=1
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
config.json
|
||||||
|
claude-web-chat
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
.env
|
||||||
|
.claude/
|
||||||
55
README.md
Normal file
55
README.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# Claude Web Chat
|
||||||
|
|
||||||
|
Web aplikacija za chat sa Claude CLI kroz browser. Go + htmx, bez JS frameworka.
|
||||||
|
|
||||||
|
## Pokretanje
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kopiraj config
|
||||||
|
cp config.json.example config.json
|
||||||
|
# Edituj config.json — podesi username, password, session_secret
|
||||||
|
|
||||||
|
# Build i pokreni
|
||||||
|
go build -o claude-web-chat && ./claude-web-chat
|
||||||
|
```
|
||||||
|
|
||||||
|
Aplikacija sluša na portu 9100 (podešava se u config.json).
|
||||||
|
|
||||||
|
## Konfiguracija
|
||||||
|
|
||||||
|
`config.json` (gitignored):
|
||||||
|
|
||||||
|
| Polje | Opis | Default |
|
||||||
|
|-------|------|---------|
|
||||||
|
| port | HTTP port | 9100 |
|
||||||
|
| mode | Mod rada: "cli" ili "api" | cli |
|
||||||
|
| projects_path | Putanja do projekata | /root/projects |
|
||||||
|
| username | Korisničko ime za login | (obavezno) |
|
||||||
|
| password | Lozinka za login | (obavezno) |
|
||||||
|
| session_secret | Secret za potpisivanje sesija | (obavezno) |
|
||||||
|
| api.key | Anthropic API ključ (za api mod) | |
|
||||||
|
| api.model | Model za API mod | claude-sonnet-4-20250514 |
|
||||||
|
|
||||||
|
## Funkcionalnosti (Faza 1 — CLI mod)
|
||||||
|
|
||||||
|
- Login sa session cookie auth
|
||||||
|
- Lista projekata iz filesystem-a
|
||||||
|
- Chat sa Claude CLI kroz WebSocket
|
||||||
|
- Streaming odgovori (NDJSON parsiranje)
|
||||||
|
- Sesija živi nezavisno od browser-a (reconnect replay)
|
||||||
|
- Sidebar sa .md fajlovima projekta
|
||||||
|
- Markdown renderovanje (goldmark)
|
||||||
|
- Dark tema
|
||||||
|
|
||||||
|
## Testovi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./... -v -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- Go 1.23+
|
||||||
|
- htmx 2.0.4 (vendored)
|
||||||
|
- gorilla/websocket
|
||||||
|
- goldmark (Markdown)
|
||||||
37
TESTING.md
Normal file
37
TESTING.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# TESTING.md — Claude Web Chat
|
||||||
|
|
||||||
|
## Login
|
||||||
|
- [ ] Otvaranje / → redirect na /login
|
||||||
|
- [ ] Login sa ispravnim kredencijalima → redirect na /projects
|
||||||
|
- [ ] Login sa pogrešnim kredencijalima → error poruka
|
||||||
|
- [ ] Već ulogovan → /login redirect na /projects
|
||||||
|
- [ ] Logout → sesija obrisana, redirect na /login
|
||||||
|
- [ ] Pristup /projects bez logina → redirect na /login
|
||||||
|
|
||||||
|
## Projekti
|
||||||
|
- [ ] Lista projekata se prikazuje posle logina
|
||||||
|
- [ ] Projekat sa README.md prikazuje opis
|
||||||
|
- [ ] Projekat bez README.md prikazuje "Bez opisa"
|
||||||
|
- [ ] Klik na projekat → otvara chat
|
||||||
|
|
||||||
|
## Chat
|
||||||
|
- [ ] Chat stranica se otvori sa WebSocket konekcijom
|
||||||
|
- [ ] Pošalji poruku → prikazuje se user poruka
|
||||||
|
- [ ] Claude odgovara → streaming tekst u realnom vremenu
|
||||||
|
- [ ] Tool use → prikazuje se kao posebna poruka
|
||||||
|
- [ ] Typing indicator → prikazuje se dok Claude radi
|
||||||
|
- [ ] Enter → šalje poruku, Shift+Enter → novi red
|
||||||
|
- [ ] Input se čisti posle slanja
|
||||||
|
|
||||||
|
## Sesija persistence
|
||||||
|
- [ ] Zatvori tab → otvori ponovo → sesija živa, poruke replayed
|
||||||
|
- [ ] Idle sesija se čisti posle 30 minuta
|
||||||
|
|
||||||
|
## File browser
|
||||||
|
- [ ] Sidebar prikazuje .md fajlove
|
||||||
|
- [ ] Klik na fajl → otvara viewer sa rendered markdown-om
|
||||||
|
- [ ] Escape zatvara viewer
|
||||||
|
- [ ] Path traversal pokušaj → blokiran
|
||||||
|
|
||||||
|
## Unit testovi
|
||||||
|
- [ ] `go test ./... -v -count=1` — svi prolaze
|
||||||
127
auth.go
Normal file
127
auth.go
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookieName = "cwc_session"
|
||||||
|
sessionMaxAge = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Token string
|
||||||
|
Username string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionManager struct {
|
||||||
|
sessions map[string]*Session
|
||||||
|
secret string
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionManager(secret string) *SessionManager {
|
||||||
|
return &SessionManager{
|
||||||
|
sessions: make(map[string]*Session),
|
||||||
|
secret: secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) Create(username string) *Session {
|
||||||
|
token := generateToken()
|
||||||
|
sig := sm.sign(token)
|
||||||
|
signedToken := token + "." + sig
|
||||||
|
|
||||||
|
sess := &Session{
|
||||||
|
Token: signedToken,
|
||||||
|
Username: username,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.mu.Lock()
|
||||||
|
sm.sessions[signedToken] = sess
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) Get(token string) *Session {
|
||||||
|
sm.mu.RLock()
|
||||||
|
sess, ok := sm.sessions[token]
|
||||||
|
sm.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Since(sess.CreatedAt) > sessionMaxAge {
|
||||||
|
sm.Delete(token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) Delete(token string) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
delete(sm.sessions, token)
|
||||||
|
sm.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SessionManager) sign(token string) string {
|
||||||
|
h := hmac.New(sha256.New, []byte(sm.secret))
|
||||||
|
h.Write([]byte(token))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionCookie sets the session cookie on the response.
|
||||||
|
func SetSessionCookie(w http.ResponseWriter, sess *Session) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: sess.Token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(sessionMaxAge.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSessionCookie removes the session cookie.
|
||||||
|
func ClearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware protects routes that require authentication.
|
||||||
|
func AuthMiddleware(sm *SessionManager, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess := sm.Get(cookie.Value)
|
||||||
|
if sess == nil {
|
||||||
|
ClearSessionCookie(w)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
148
auth_test.go
Normal file
148
auth_test.go
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionManager(t *testing.T) {
|
||||||
|
sm := NewSessionManager("test-secret")
|
||||||
|
|
||||||
|
t.Run("create and get session", func(t *testing.T) {
|
||||||
|
sess := sm.Create("admin")
|
||||||
|
if sess.Username != "admin" {
|
||||||
|
t.Errorf("username = %q, want admin", sess.Username)
|
||||||
|
}
|
||||||
|
if sess.Token == "" {
|
||||||
|
t.Fatal("token is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := sm.Get(sess.Token)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("session not found")
|
||||||
|
}
|
||||||
|
if got.Username != "admin" {
|
||||||
|
t.Errorf("username = %q, want admin", got.Username)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get nonexistent session", func(t *testing.T) {
|
||||||
|
got := sm.Get("nonexistent")
|
||||||
|
if got != nil {
|
||||||
|
t.Error("expected nil for nonexistent session")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("delete session", func(t *testing.T) {
|
||||||
|
sess := sm.Create("user1")
|
||||||
|
sm.Delete(sess.Token)
|
||||||
|
got := sm.Get(sess.Token)
|
||||||
|
if got != nil {
|
||||||
|
t.Error("expected nil after delete")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired session", func(t *testing.T) {
|
||||||
|
sess := sm.Create("user2")
|
||||||
|
// Manually set old creation time
|
||||||
|
sm.mu.Lock()
|
||||||
|
sm.sessions[sess.Token].CreatedAt = time.Now().Add(-25 * time.Hour)
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
got := sm.Get(sess.Token)
|
||||||
|
if got != nil {
|
||||||
|
t.Error("expected nil for expired session")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unique tokens", func(t *testing.T) {
|
||||||
|
s1 := sm.Create("a")
|
||||||
|
s2 := sm.Create("b")
|
||||||
|
if s1.Token == s2.Token {
|
||||||
|
t.Error("tokens should be unique")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware(t *testing.T) {
|
||||||
|
sm := NewSessionManager("test-secret")
|
||||||
|
sess := sm.Create("admin")
|
||||||
|
|
||||||
|
protected := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := AuthMiddleware(sm, protected)
|
||||||
|
|
||||||
|
t.Run("no cookie redirects to login", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/projects", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
loc := w.Header().Get("Location")
|
||||||
|
if loc != "/login" {
|
||||||
|
t.Errorf("location = %q, want /login", loc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid cookie redirects to login", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/projects", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "invalid"})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid cookie passes through", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/projects", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sess.Token})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSessionCookie(t *testing.T) {
|
||||||
|
sess := &Session{Token: "test-token", Username: "admin"}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
SetSessionCookie(w, sess)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if len(cookies) != 1 {
|
||||||
|
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||||
|
}
|
||||||
|
if cookies[0].Name != sessionCookieName {
|
||||||
|
t.Errorf("cookie name = %q", cookies[0].Name)
|
||||||
|
}
|
||||||
|
if cookies[0].Value != "test-token" {
|
||||||
|
t.Errorf("cookie value = %q", cookies[0].Value)
|
||||||
|
}
|
||||||
|
if !cookies[0].HttpOnly {
|
||||||
|
t.Error("expected HttpOnly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearSessionCookie(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ClearSessionCookie(w)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if len(cookies) != 1 {
|
||||||
|
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||||
|
}
|
||||||
|
if cookies[0].MaxAge != -1 {
|
||||||
|
t.Errorf("MaxAge = %d, want -1", cookies[0].MaxAge)
|
||||||
|
}
|
||||||
|
}
|
||||||
196
claude_cli.go
Normal file
196
claude_cli.go
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CLIEvent represents a parsed NDJSON event from claude CLI stdout.
|
||||||
|
type CLIEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Subtype string `json:"subtype,omitempty"`
|
||||||
|
|
||||||
|
// For assistant message events
|
||||||
|
Message *CLIMessage `json:"message,omitempty"`
|
||||||
|
|
||||||
|
// For content_block_delta
|
||||||
|
Index int `json:"index,omitempty"`
|
||||||
|
Delta *CLIDelta `json:"delta,omitempty"`
|
||||||
|
|
||||||
|
// For result events
|
||||||
|
Result *CLIResult `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CLIMessage struct {
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content []CLIContent `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CLIContent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Input any `json:"input,omitempty"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CLIDelta struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CLIResult struct {
|
||||||
|
Duration float64 `json:"duration_ms,omitempty"`
|
||||||
|
NumTurns int `json:"num_turns,omitempty"`
|
||||||
|
CostUSD float64 `json:"cost_usd,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIProcess manages a running claude CLI process.
|
||||||
|
type CLIProcess struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
stdout io.ReadCloser
|
||||||
|
stderr io.ReadCloser
|
||||||
|
|
||||||
|
Events chan CLIEvent
|
||||||
|
Errors chan error
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpawnCLI starts a new claude CLI process for the given project directory.
|
||||||
|
func SpawnCLI(projectDir string) (*CLIProcess, error) {
|
||||||
|
args := []string{
|
||||||
|
"-p",
|
||||||
|
"--output-format", "stream-json",
|
||||||
|
"--verbose",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("claude", args...)
|
||||||
|
cmd.Dir = projectDir
|
||||||
|
|
||||||
|
// Filter CLAUDECODE env var to prevent nested session detection
|
||||||
|
env := filterEnv(os.Environ(), "CLAUDECODE")
|
||||||
|
cmd.Env = env
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stderr pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("start claude: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cp := &CLIProcess{
|
||||||
|
cmd: cmd,
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: stdout,
|
||||||
|
stderr: stderr,
|
||||||
|
Events: make(chan CLIEvent, 100),
|
||||||
|
Errors: make(chan error, 10),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
go cp.readOutput()
|
||||||
|
go cp.readErrors()
|
||||||
|
|
||||||
|
return cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send writes a message to the claude CLI process stdin.
|
||||||
|
func (cp *CLIProcess) Send(message string) error {
|
||||||
|
cp.mu.Lock()
|
||||||
|
defer cp.mu.Unlock()
|
||||||
|
|
||||||
|
msg := strings.TrimSpace(message) + "\n"
|
||||||
|
_, err := io.WriteString(cp.stdin, msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the claude CLI process.
|
||||||
|
func (cp *CLIProcess) Close() error {
|
||||||
|
cp.mu.Lock()
|
||||||
|
defer cp.mu.Unlock()
|
||||||
|
|
||||||
|
cp.stdin.Close()
|
||||||
|
return cp.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done returns a channel that's closed when the process exits.
|
||||||
|
func (cp *CLIProcess) Done() <-chan struct{} {
|
||||||
|
return cp.done
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *CLIProcess) readOutput() {
|
||||||
|
defer close(cp.done)
|
||||||
|
defer close(cp.Events)
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(cp.stdout)
|
||||||
|
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB buffer
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var event CLIEvent
|
||||||
|
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||||
|
cp.Errors <- fmt.Errorf("parse event: %w (line: %s)", err, truncate(line, 200))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp.Events <- event
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
cp.Errors <- fmt.Errorf("scanner: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *CLIProcess) readErrors() {
|
||||||
|
scanner := bufio.NewScanner(cp.stderr)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line != "" {
|
||||||
|
cp.Errors <- fmt.Errorf("stderr: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterEnv returns a copy of env with the named variable removed.
|
||||||
|
func filterEnv(env []string, name string) []string {
|
||||||
|
prefix := name + "="
|
||||||
|
result := make([]string, 0, len(env))
|
||||||
|
for _, e := range env {
|
||||||
|
if !strings.HasPrefix(e, prefix) {
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
126
claude_cli_test.go
Normal file
126
claude_cli_test.go
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilterEnv(t *testing.T) {
|
||||||
|
env := []string{
|
||||||
|
"PATH=/usr/bin",
|
||||||
|
"HOME=/root",
|
||||||
|
"CLAUDECODE=1",
|
||||||
|
"OTHER=value",
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := filterEnv(env, "CLAUDECODE")
|
||||||
|
|
||||||
|
if len(filtered) != 3 {
|
||||||
|
t.Fatalf("got %d entries, want 3", len(filtered))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range filtered {
|
||||||
|
if e == "CLAUDECODE=1" {
|
||||||
|
t.Error("CLAUDECODE should be filtered out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterEnvNotPresent(t *testing.T) {
|
||||||
|
env := []string{"PATH=/usr/bin", "HOME=/root"}
|
||||||
|
filtered := filterEnv(env, "CLAUDECODE")
|
||||||
|
if len(filtered) != 2 {
|
||||||
|
t.Fatalf("got %d entries, want 2", len(filtered))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
n int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"hello", 10, "hello"},
|
||||||
|
{"hello world", 5, "hello..."},
|
||||||
|
{"", 5, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := truncate(tt.input, tt.n)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.n, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCLIEventParsing(t *testing.T) {
|
||||||
|
t.Run("assistant message", func(t *testing.T) {
|
||||||
|
raw := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello!"}]}}`
|
||||||
|
var event CLIEvent
|
||||||
|
if err := json.Unmarshal([]byte(raw), &event); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if event.Type != "assistant" {
|
||||||
|
t.Errorf("type = %q", event.Type)
|
||||||
|
}
|
||||||
|
if event.Message == nil {
|
||||||
|
t.Fatal("message is nil")
|
||||||
|
}
|
||||||
|
if len(event.Message.Content) != 1 {
|
||||||
|
t.Fatalf("content length = %d", len(event.Message.Content))
|
||||||
|
}
|
||||||
|
if event.Message.Content[0].Text != "Hello!" {
|
||||||
|
t.Errorf("text = %q", event.Message.Content[0].Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("content_block_delta", func(t *testing.T) {
|
||||||
|
raw := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"chunk"}}`
|
||||||
|
var event CLIEvent
|
||||||
|
if err := json.Unmarshal([]byte(raw), &event); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if event.Type != "content_block_delta" {
|
||||||
|
t.Errorf("type = %q", event.Type)
|
||||||
|
}
|
||||||
|
if event.Delta == nil {
|
||||||
|
t.Fatal("delta is nil")
|
||||||
|
}
|
||||||
|
if event.Delta.Text != "chunk" {
|
||||||
|
t.Errorf("text = %q", event.Delta.Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("result event", func(t *testing.T) {
|
||||||
|
raw := `{"type":"result","subtype":"success","result":{"duration_ms":1234.5,"num_turns":3,"cost_usd":0.05,"session_id":"abc123"}}`
|
||||||
|
var event CLIEvent
|
||||||
|
if err := json.Unmarshal([]byte(raw), &event); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if event.Type != "result" {
|
||||||
|
t.Errorf("type = %q", event.Type)
|
||||||
|
}
|
||||||
|
if event.Subtype != "success" {
|
||||||
|
t.Errorf("subtype = %q", event.Subtype)
|
||||||
|
}
|
||||||
|
if event.Result == nil {
|
||||||
|
t.Fatal("result is nil")
|
||||||
|
}
|
||||||
|
if event.Result.SessionID != "abc123" {
|
||||||
|
t.Errorf("session_id = %q", event.Result.SessionID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tool_use content", func(t *testing.T) {
|
||||||
|
raw := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","id":"tool1","input":{"file_path":"/tmp/test.go"}}]}}`
|
||||||
|
var event CLIEvent
|
||||||
|
if err := json.Unmarshal([]byte(raw), &event); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if event.Message.Content[0].Type != "tool_use" {
|
||||||
|
t.Errorf("content type = %q", event.Message.Content[0].Type)
|
||||||
|
}
|
||||||
|
if event.Message.Content[0].Name != "Read" {
|
||||||
|
t.Errorf("name = %q", event.Message.Content[0].Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
55
config.go
Normal file
55
config.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIConfig struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
ProjectsPath string `json:"projects_path"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
SessionSecret string `json:"session_secret"`
|
||||||
|
API APIConfig `json:"api"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = 9100
|
||||||
|
}
|
||||||
|
if cfg.Mode == "" {
|
||||||
|
cfg.Mode = "cli"
|
||||||
|
}
|
||||||
|
if cfg.ProjectsPath == "" {
|
||||||
|
cfg.ProjectsPath = "/root/projects"
|
||||||
|
}
|
||||||
|
if cfg.Username == "" {
|
||||||
|
return nil, fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
if cfg.Password == "" {
|
||||||
|
return nil, fmt.Errorf("password is required")
|
||||||
|
}
|
||||||
|
if cfg.SessionSecret == "" {
|
||||||
|
return nil, fmt.Errorf("session_secret is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
12
config.json.example
Normal file
12
config.json.example
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"port": 9100,
|
||||||
|
"mode": "cli",
|
||||||
|
"projects_path": "/root/projects",
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"session_secret": "",
|
||||||
|
"api": {
|
||||||
|
"key": "",
|
||||||
|
"model": "claude-sonnet-4-20250514"
|
||||||
|
}
|
||||||
|
}
|
||||||
131
config_test.go
Normal file
131
config_test.go
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
|
||||||
|
t.Run("valid config", func(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"port": 9100,
|
||||||
|
"mode": "cli",
|
||||||
|
"projects_path": "/tmp/projects",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret",
|
||||||
|
"session_secret": "abc123"
|
||||||
|
}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Port != 9100 {
|
||||||
|
t.Errorf("port = %d, want 9100", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.Mode != "cli" {
|
||||||
|
t.Errorf("mode = %q, want cli", cfg.Mode)
|
||||||
|
}
|
||||||
|
if cfg.ProjectsPath != "/tmp/projects" {
|
||||||
|
t.Errorf("projects_path = %q, want /tmp/projects", cfg.ProjectsPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("defaults applied", func(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret",
|
||||||
|
"session_secret": "abc123"
|
||||||
|
}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Port != 9100 {
|
||||||
|
t.Errorf("port = %d, want 9100", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.Mode != "cli" {
|
||||||
|
t.Errorf("mode = %q, want cli", cfg.Mode)
|
||||||
|
}
|
||||||
|
if cfg.ProjectsPath != "/root/projects" {
|
||||||
|
t.Errorf("projects_path = %q, want /root/projects", cfg.ProjectsPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing username", func(t *testing.T) {
|
||||||
|
data := `{"password": "secret", "session_secret": "abc"}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
_, err := LoadConfig(cfgPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing username")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing password", func(t *testing.T) {
|
||||||
|
data := `{"username": "admin", "session_secret": "abc"}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
_, err := LoadConfig(cfgPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing password")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing session_secret", func(t *testing.T) {
|
||||||
|
data := `{"username": "admin", "password": "secret"}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
_, err := LoadConfig(cfgPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing session_secret")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("file not found", func(t *testing.T) {
|
||||||
|
_, err := LoadConfig("/nonexistent/config.json")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid json", func(t *testing.T) {
|
||||||
|
os.WriteFile(cfgPath, []byte("{invalid"), 0644)
|
||||||
|
|
||||||
|
_, err := LoadConfig(cfgPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("api config", func(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret",
|
||||||
|
"session_secret": "abc123",
|
||||||
|
"api": {
|
||||||
|
"key": "sk-ant-xxx",
|
||||||
|
"model": "claude-sonnet-4-20250514"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.API.Key != "sk-ant-xxx" {
|
||||||
|
t.Errorf("api.key = %q, want sk-ant-xxx", cfg.API.Key)
|
||||||
|
}
|
||||||
|
if cfg.API.Model != "claude-sonnet-4-20250514" {
|
||||||
|
t.Errorf("api.model = %q", cfg.API.Model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
111
files.go
Normal file
111
files.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileInfo struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
RelPath string
|
||||||
|
IsDir bool
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMarkdownFiles returns all .md files in the project directory (non-recursive).
|
||||||
|
func ListMarkdownFiles(projectDir string) ([]FileInfo, error) {
|
||||||
|
entries, err := os.ReadDir(projectDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []FileInfo
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, FileInfo{
|
||||||
|
Name: e.Name(),
|
||||||
|
Path: filepath.Join(projectDir, e.Name()),
|
||||||
|
RelPath: e.Name(),
|
||||||
|
Size: info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check docs/ subdirectory
|
||||||
|
docsDir := filepath.Join(projectDir, "docs")
|
||||||
|
if docEntries, err := os.ReadDir(docsDir); err == nil {
|
||||||
|
for _, e := range docEntries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, FileInfo{
|
||||||
|
Name: "docs/" + e.Name(),
|
||||||
|
Path: filepath.Join(docsDir, e.Name()),
|
||||||
|
RelPath: "docs/" + e.Name(),
|
||||||
|
Size: info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
return files[i].Name < files[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFileContent reads a file and returns its content.
|
||||||
|
// It validates that the file is within the project directory (path traversal protection).
|
||||||
|
func ReadFileContent(projectDir, relPath string) (string, error) {
|
||||||
|
absPath := filepath.Join(projectDir, relPath)
|
||||||
|
absPath = filepath.Clean(absPath)
|
||||||
|
|
||||||
|
// Path traversal protection
|
||||||
|
if !strings.HasPrefix(absPath, filepath.Clean(projectDir)) {
|
||||||
|
return "", fmt.Errorf("path traversal detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderMarkdownFile reads a markdown file and returns rendered HTML.
|
||||||
|
func RenderMarkdownFile(projectDir, relPath string) (string, error) {
|
||||||
|
content, err := ReadFileContent(projectDir, relPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := goldmark.Convert([]byte(content), &buf); err != nil {
|
||||||
|
return "", fmt.Errorf("render markdown: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
125
files_test.go
Normal file
125
files_test.go
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListMarkdownFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Hello"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(dir, "SPEC.md"), []byte("# Spec"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0644)
|
||||||
|
|
||||||
|
// Create docs subdirectory
|
||||||
|
os.MkdirAll(filepath.Join(dir, "docs"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(dir, "docs", "ARCHITECTURE.md"), []byte("# Arch"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(dir, "docs", "notes.txt"), []byte("not md"), 0644)
|
||||||
|
|
||||||
|
t.Run("lists only .md files", func(t *testing.T) {
|
||||||
|
files, err := ListMarkdownFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListMarkdownFiles: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 3 {
|
||||||
|
t.Fatalf("got %d files, want 3", len(files))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("includes docs subdir", func(t *testing.T) {
|
||||||
|
files, err := ListMarkdownFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListMarkdownFiles: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, f := range files {
|
||||||
|
if f.Name == "docs/ARCHITECTURE.md" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("should include docs/ARCHITECTURE.md")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sorted", func(t *testing.T) {
|
||||||
|
files, err := ListMarkdownFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListMarkdownFiles: %v", err)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(files); i++ {
|
||||||
|
if files[i].Name < files[i-1].Name {
|
||||||
|
t.Errorf("not sorted: %q before %q", files[i-1].Name, files[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nonexistent dir", func(t *testing.T) {
|
||||||
|
_, err := ListMarkdownFiles("/nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileContent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(dir, "test.md"), []byte("hello world"), 0644)
|
||||||
|
|
||||||
|
t.Run("reads file", func(t *testing.T) {
|
||||||
|
content, err := ReadFileContent(dir, "test.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFileContent: %v", err)
|
||||||
|
}
|
||||||
|
if content != "hello world" {
|
||||||
|
t.Errorf("content = %q", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("path traversal blocked", func(t *testing.T) {
|
||||||
|
_, err := ReadFileContent(dir, "../../etc/passwd")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for path traversal")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "path traversal") {
|
||||||
|
t.Errorf("error = %v, want path traversal error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("file not found", func(t *testing.T) {
|
||||||
|
_, err := ReadFileContent(dir, "nonexistent.md")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent file")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdownFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.WriteFile(filepath.Join(dir, "test.md"), []byte("# Hello\n\nWorld"), 0644)
|
||||||
|
|
||||||
|
t.Run("renders markdown", func(t *testing.T) {
|
||||||
|
html, err := RenderMarkdownFile(dir, "test.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderMarkdownFile: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "<h1>Hello</h1>") {
|
||||||
|
t.Errorf("missing h1, got: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "<p>World</p>") {
|
||||||
|
t.Errorf("missing p, got: %s", html)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("path traversal blocked", func(t *testing.T) {
|
||||||
|
_, err := RenderMarkdownFile(dir, "../../../etc/passwd")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for path traversal")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
71
fragments.go
Normal file
71
fragments.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FragmentUserMessage returns an HTML fragment for a user message.
|
||||||
|
func FragmentUserMessage(text string) string {
|
||||||
|
escaped := html.EscapeString(text)
|
||||||
|
return fmt.Sprintf(`<div id="chat-messages" hx-swap-oob="beforeend"><div class="message message-user">%s</div></div>`, escaped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FragmentAssistantStart returns the opening tag for an assistant message with streaming.
|
||||||
|
func FragmentAssistantStart(msgID string) string {
|
||||||
|
return fmt.Sprintf(`<div id="chat-messages" hx-swap-oob="beforeend"><div class="message message-assistant" id="%s"><div class="content"></div></div></div>`, msgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FragmentAssistantChunk appends text to an existing assistant message.
|
||||||
|
func FragmentAssistantChunk(msgID, textChunk string) string {
|
||||||
|
escaped := html.EscapeString(textChunk)
|
||||||
|
return fmt.Sprintf(`<div id="%s" hx-swap-oob="beforeend:.content">%s</div>`, msgID, escaped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FragmentAssistantComplete replaces the content of an assistant message with final rendered content.
|
||||||
|
func FragmentAssistantComplete(msgID, htmlContent string) string {
|
||||||
|
return fmt.Sprintf(`<div id="%s" hx-swap-oob="innerHTML:.content">%s</div>`, msgID, htmlContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FragmentToolCall returns an HTML fragment for a tool use notification.
|
||||||
|
func FragmentToolCall(toolName string, toolInput string) string {
|
||||||
|
escapedName := html.EscapeString(toolName)
|
||||||
|
escapedInput := html.EscapeString(toolInput)
|
||||||
|
if len(escapedInput) > 200 {
|
||||||
|
escapedInput = escapedInput[:200] + "..."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`<div id="chat-messages" hx-swap-oob="beforeend"><div class="message message-tool"><div class="tool-name">%s</div><div>%s</div></div></div>`, escapedName, escapedInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FragmentSystemMessage returns an HTML fragment for a system message.
|
||||||
|
func FragmentSystemMessage(text string) string {
|
||||||
|
escaped := html.EscapeString(text)
|
||||||
|
return fmt.Sprintf(`<div id="chat-messages" hx-swap-oob="beforeend"><div class="message message-system">%s</div></div>`, escaped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FragmentTypingIndicator shows or hides the typing indicator.
|
||||||
|
func FragmentTypingIndicator(show bool) string {
|
||||||
|
if show {
|
||||||
|
return `<div id="typing-indicator" hx-swap-oob="innerHTML"><span class="typing-indicator">Claude razmišlja<span class="dots">...</span></span></div>`
|
||||||
|
}
|
||||||
|
return `<div id="typing-indicator" hx-swap-oob="innerHTML"></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FragmentStatus updates the connection status indicator.
|
||||||
|
func FragmentStatus(connected bool) string {
|
||||||
|
if connected {
|
||||||
|
return `<span id="ws-status" hx-swap-oob="innerHTML" class="status connected">Povezan</span>`
|
||||||
|
}
|
||||||
|
return `<span id="ws-status" hx-swap-oob="innerHTML" class="status">Nepovezan</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FragmentClearInput clears the message input field.
|
||||||
|
func FragmentClearInput() string {
|
||||||
|
return `<textarea id="message-input" hx-swap-oob="outerHTML" name="message" class="chat-input" placeholder="Pošalji poruku..." rows="1"></textarea>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FragmentCombine joins multiple fragments into a single response.
|
||||||
|
func FragmentCombine(fragments ...string) string {
|
||||||
|
return strings.Join(fragments, "\n")
|
||||||
|
}
|
||||||
127
fragments_test.go
Normal file
127
fragments_test.go
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFragmentUserMessage(t *testing.T) {
|
||||||
|
f := FragmentUserMessage("Hello <world>")
|
||||||
|
if !strings.Contains(f, "message-user") {
|
||||||
|
t.Error("missing message-user class")
|
||||||
|
}
|
||||||
|
if !strings.Contains(f, "Hello <world>") {
|
||||||
|
t.Error("should escape HTML")
|
||||||
|
}
|
||||||
|
if !strings.Contains(f, `hx-swap-oob="beforeend"`) {
|
||||||
|
t.Error("missing OOB swap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentAssistantStart(t *testing.T) {
|
||||||
|
f := FragmentAssistantStart("msg-1")
|
||||||
|
if !strings.Contains(f, `id="msg-1"`) {
|
||||||
|
t.Error("missing message ID")
|
||||||
|
}
|
||||||
|
if !strings.Contains(f, "message-assistant") {
|
||||||
|
t.Error("missing message-assistant class")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentAssistantChunk(t *testing.T) {
|
||||||
|
f := FragmentAssistantChunk("msg-1", "chunk<text>")
|
||||||
|
if !strings.Contains(f, `id="msg-1"`) {
|
||||||
|
t.Error("missing message ID")
|
||||||
|
}
|
||||||
|
if !strings.Contains(f, "chunk<text>") {
|
||||||
|
t.Error("should escape HTML")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentAssistantComplete(t *testing.T) {
|
||||||
|
f := FragmentAssistantComplete("msg-1", "<p>Hello</p>")
|
||||||
|
if !strings.Contains(f, `id="msg-1"`) {
|
||||||
|
t.Error("missing message ID")
|
||||||
|
}
|
||||||
|
if !strings.Contains(f, "<p>Hello</p>") {
|
||||||
|
t.Error("should include raw HTML content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentToolCall(t *testing.T) {
|
||||||
|
f := FragmentToolCall("Read", "/tmp/test.go")
|
||||||
|
if !strings.Contains(f, "message-tool") {
|
||||||
|
t.Error("missing message-tool class")
|
||||||
|
}
|
||||||
|
if !strings.Contains(f, "Read") {
|
||||||
|
t.Error("missing tool name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentToolCallTruncation(t *testing.T) {
|
||||||
|
longInput := strings.Repeat("x", 300)
|
||||||
|
f := FragmentToolCall("Write", longInput)
|
||||||
|
if !strings.Contains(f, "...") {
|
||||||
|
t.Error("should truncate long input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentSystemMessage(t *testing.T) {
|
||||||
|
f := FragmentSystemMessage("Connected")
|
||||||
|
if !strings.Contains(f, "message-system") {
|
||||||
|
t.Error("missing message-system class")
|
||||||
|
}
|
||||||
|
if !strings.Contains(f, "Connected") {
|
||||||
|
t.Error("missing text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentTypingIndicator(t *testing.T) {
|
||||||
|
show := FragmentTypingIndicator(true)
|
||||||
|
if !strings.Contains(show, "typing-indicator") {
|
||||||
|
t.Error("missing typing indicator")
|
||||||
|
}
|
||||||
|
if !strings.Contains(show, "razmišlja") {
|
||||||
|
t.Error("missing thinking text")
|
||||||
|
}
|
||||||
|
|
||||||
|
hide := FragmentTypingIndicator(false)
|
||||||
|
if !strings.Contains(hide, `id="typing-indicator"`) {
|
||||||
|
t.Error("missing ID")
|
||||||
|
}
|
||||||
|
if strings.Contains(hide, "razmišlja") {
|
||||||
|
t.Error("should not contain thinking text when hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentStatus(t *testing.T) {
|
||||||
|
connected := FragmentStatus(true)
|
||||||
|
if !strings.Contains(connected, "connected") {
|
||||||
|
t.Error("missing connected class")
|
||||||
|
}
|
||||||
|
if !strings.Contains(connected, "Povezan") {
|
||||||
|
t.Error("missing connected text")
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnected := FragmentStatus(false)
|
||||||
|
if strings.Contains(disconnected, "connected") {
|
||||||
|
t.Error("should not have connected class when disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentClearInput(t *testing.T) {
|
||||||
|
f := FragmentClearInput()
|
||||||
|
if !strings.Contains(f, `id="message-input"`) {
|
||||||
|
t.Error("missing input ID")
|
||||||
|
}
|
||||||
|
if !strings.Contains(f, "hx-swap-oob") {
|
||||||
|
t.Error("missing OOB swap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentCombine(t *testing.T) {
|
||||||
|
combined := FragmentCombine("a", "b", "c")
|
||||||
|
if combined != "a\nb\nc" {
|
||||||
|
t.Errorf("got %q", combined)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
go.mod
Normal file
8
go.mod
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
module claude-web-chat
|
||||||
|
|
||||||
|
go 1.23.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
)
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
156
main.go
Normal file
156
main.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfg *Config
|
||||||
|
templates *TemplateRenderer
|
||||||
|
sessionMgr *SessionManager
|
||||||
|
chatMgr *ChatSessionManager
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
cfg, err = LoadConfig("config.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
templates, err = NewTemplateRenderer("templates")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr = NewSessionManager(cfg.SessionSecret)
|
||||||
|
chatMgr = NewChatSessionManager()
|
||||||
|
defer chatMgr.Stop()
|
||||||
|
|
||||||
|
wsHandler := NewWSHandler(chatMgr)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
mux.HandleFunc("GET /login", handleLoginPage)
|
||||||
|
mux.HandleFunc("POST /login", handleLogin)
|
||||||
|
mux.HandleFunc("GET /logout", handleLogout)
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
mux.Handle("GET /projects", AuthMiddleware(sessionMgr, http.HandlerFunc(handleProjects)))
|
||||||
|
mux.Handle("GET /chat/{project}", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChat)))
|
||||||
|
mux.Handle("GET /ws", AuthMiddleware(sessionMgr, wsHandler))
|
||||||
|
mux.Handle("GET /api/file", AuthMiddleware(sessionMgr, http.HandlerFunc(handleFileAPI)))
|
||||||
|
|
||||||
|
// Root redirect (exact match only)
|
||||||
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/projects", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||||
|
log.Printf("Claude Web Chat pokrenut na http://0.0.0.0%s (mod: %s)", addr, cfg.Mode)
|
||||||
|
log.Fatal(http.ListenAndServe(addr, mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// If already logged in, redirect
|
||||||
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
|
if sessionMgr.Get(cookie.Value) != nil {
|
||||||
|
http.Redirect(w, r, "/projects", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templates.Render(w, "login.html", map[string]string{"Error": ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := r.FormValue("username")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
if username != cfg.Username || password != cfg.Password {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
templates.Render(w, "login.html", map[string]string{"Error": "Pogrešno korisničko ime ili lozinka"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionMgr.Create(username)
|
||||||
|
SetSessionCookie(w, sess)
|
||||||
|
http.Redirect(w, r, "/projects", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
|
sessionMgr.Delete(cookie.Value)
|
||||||
|
}
|
||||||
|
ClearSessionCookie(w)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projects, err := ListProjects(cfg.ProjectsPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ListProjects error: %v", err)
|
||||||
|
http.Error(w, "Greška pri čitanju projekata", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Projects": projects,
|
||||||
|
"ProjectsPath": cfg.ProjectsPath,
|
||||||
|
}
|
||||||
|
templates.Render(w, "projects.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
project := r.PathValue("project")
|
||||||
|
if project == "" {
|
||||||
|
http.Redirect(w, r, "/projects", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectDir := filepath.Join(cfg.ProjectsPath, project)
|
||||||
|
|
||||||
|
files, err := ListMarkdownFiles(projectDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ListMarkdownFiles error: %v", err)
|
||||||
|
files = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"Project": project,
|
||||||
|
"ProjectDir": projectDir,
|
||||||
|
"Files": files,
|
||||||
|
}
|
||||||
|
templates.Render(w, "chat.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFileAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
project := r.URL.Query().Get("project")
|
||||||
|
relPath := r.URL.Query().Get("path")
|
||||||
|
|
||||||
|
if project == "" || relPath == "" {
|
||||||
|
http.Error(w, "missing params", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectDir := filepath.Join(cfg.ProjectsPath, project)
|
||||||
|
htmlContent, err := RenderMarkdownFile(projectDir, relPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"name": relPath,
|
||||||
|
"html": htmlContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
64
projects.go
Normal file
64
projects.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
Description string
|
||||||
|
HasReadme bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProjects returns a sorted list of projects from the given directory.
|
||||||
|
// Only directories are considered projects. Hidden directories (starting with .)
|
||||||
|
// are excluded.
|
||||||
|
func ListProjects(projectsPath string) ([]Project, error) {
|
||||||
|
entries, err := os.ReadDir(projectsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var projects []Project
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
p := Project{
|
||||||
|
Name: name,
|
||||||
|
Path: filepath.Join(projectsPath, name),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read description from README.md first line
|
||||||
|
readmePath := filepath.Join(p.Path, "README.md")
|
||||||
|
if data, err := os.ReadFile(readmePath); err == nil {
|
||||||
|
p.HasReadme = true
|
||||||
|
lines := strings.SplitN(string(data), "\n", 3)
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
line = strings.TrimLeft(line, "# ")
|
||||||
|
if line != "" {
|
||||||
|
p.Description = line
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projects = append(projects, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(projects, func(i, j int) bool {
|
||||||
|
return projects[i].Name < projects[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
return projects, nil
|
||||||
|
}
|
||||||
99
projects_test.go
Normal file
99
projects_test.go
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListProjects(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Create test projects
|
||||||
|
os.MkdirAll(filepath.Join(dir, "alpha"), 0755)
|
||||||
|
os.MkdirAll(filepath.Join(dir, "beta"), 0755)
|
||||||
|
os.MkdirAll(filepath.Join(dir, ".hidden"), 0755)
|
||||||
|
os.WriteFile(filepath.Join(dir, "file.txt"), []byte("not a dir"), 0644)
|
||||||
|
|
||||||
|
// Add README to alpha
|
||||||
|
os.WriteFile(filepath.Join(dir, "alpha", "README.md"), []byte("# Alpha Project\nSome description"), 0644)
|
||||||
|
|
||||||
|
t.Run("lists directories only", func(t *testing.T) {
|
||||||
|
projects, err := ListProjects(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProjects: %v", err)
|
||||||
|
}
|
||||||
|
if len(projects) != 2 {
|
||||||
|
t.Fatalf("got %d projects, want 2", len(projects))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sorted alphabetically", func(t *testing.T) {
|
||||||
|
projects, err := ListProjects(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProjects: %v", err)
|
||||||
|
}
|
||||||
|
if projects[0].Name != "alpha" {
|
||||||
|
t.Errorf("first = %q, want alpha", projects[0].Name)
|
||||||
|
}
|
||||||
|
if projects[1].Name != "beta" {
|
||||||
|
t.Errorf("second = %q, want beta", projects[1].Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reads README description", func(t *testing.T) {
|
||||||
|
projects, err := ListProjects(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProjects: %v", err)
|
||||||
|
}
|
||||||
|
if !projects[0].HasReadme {
|
||||||
|
t.Error("alpha should have HasReadme=true")
|
||||||
|
}
|
||||||
|
if projects[0].Description != "Alpha Project" {
|
||||||
|
t.Errorf("description = %q, want 'Alpha Project'", projects[0].Description)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no README", func(t *testing.T) {
|
||||||
|
projects, err := ListProjects(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProjects: %v", err)
|
||||||
|
}
|
||||||
|
if projects[1].HasReadme {
|
||||||
|
t.Error("beta should have HasReadme=false")
|
||||||
|
}
|
||||||
|
if projects[1].Description != "" {
|
||||||
|
t.Errorf("description = %q, want empty", projects[1].Description)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("excludes hidden dirs", func(t *testing.T) {
|
||||||
|
projects, err := ListProjects(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProjects: %v", err)
|
||||||
|
}
|
||||||
|
for _, p := range projects {
|
||||||
|
if p.Name == ".hidden" {
|
||||||
|
t.Error("should not include hidden directories")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nonexistent directory", func(t *testing.T) {
|
||||||
|
_, err := ListProjects("/nonexistent/path")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent dir")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty directory", func(t *testing.T) {
|
||||||
|
emptyDir := t.TempDir()
|
||||||
|
projects, err := ListProjects(emptyDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProjects: %v", err)
|
||||||
|
}
|
||||||
|
if len(projects) != 0 {
|
||||||
|
t.Errorf("got %d projects, want 0", len(projects))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
227
sessions.go
Normal file
227
sessions.go
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxIdleTime = 30 * time.Minute
|
||||||
|
maxBufferSize = 500
|
||||||
|
cleanupInterval = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatMessage represents a single message in the chat history.
|
||||||
|
type ChatMessage struct {
|
||||||
|
Role string // "user", "assistant", "system", "tool"
|
||||||
|
Content string // HTML fragment
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriber receives broadcast messages via a channel.
|
||||||
|
type Subscriber struct {
|
||||||
|
Ch chan string
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatSession holds the state for a single chat with a project.
|
||||||
|
type ChatSession struct {
|
||||||
|
ID string
|
||||||
|
ProjectDir string
|
||||||
|
Process *CLIProcess
|
||||||
|
Buffer []ChatMessage
|
||||||
|
LastActive time.Time
|
||||||
|
Streaming bool // true while Claude is generating
|
||||||
|
|
||||||
|
subscribers map[string]*Subscriber
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage appends a message to the buffer and broadcasts to subscribers.
|
||||||
|
func (cs *ChatSession) AddMessage(msg ChatMessage) {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
cs.Buffer = append(cs.Buffer, msg)
|
||||||
|
if len(cs.Buffer) > maxBufferSize {
|
||||||
|
cs.Buffer = cs.Buffer[len(cs.Buffer)-maxBufferSize:]
|
||||||
|
}
|
||||||
|
cs.LastActive = time.Now()
|
||||||
|
|
||||||
|
// Broadcast to all subscribers
|
||||||
|
for id, sub := range cs.subscribers {
|
||||||
|
select {
|
||||||
|
case sub.Ch <- msg.Content:
|
||||||
|
default:
|
||||||
|
log.Printf("Subscriber %s buffer full, skipping", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBuffer returns a copy of the message buffer.
|
||||||
|
func (cs *ChatSession) GetBuffer() []ChatMessage {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
buf := make([]ChatMessage, len(cs.Buffer))
|
||||||
|
copy(buf, cs.Buffer)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe adds a new subscriber and returns it.
|
||||||
|
func (cs *ChatSession) Subscribe(id string) *Subscriber {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
sub := &Subscriber{
|
||||||
|
Ch: make(chan string, 100),
|
||||||
|
ID: id,
|
||||||
|
}
|
||||||
|
if cs.subscribers == nil {
|
||||||
|
cs.subscribers = make(map[string]*Subscriber)
|
||||||
|
}
|
||||||
|
cs.subscribers[id] = sub
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a subscriber.
|
||||||
|
func (cs *ChatSession) Unsubscribe(id string) {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
if sub, ok := cs.subscribers[id]; ok {
|
||||||
|
close(sub.Ch)
|
||||||
|
delete(cs.subscribers, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriberCount returns the number of active subscribers.
|
||||||
|
func (cs *ChatSession) SubscriberCount() int {
|
||||||
|
cs.mu.Lock()
|
||||||
|
defer cs.mu.Unlock()
|
||||||
|
return len(cs.subscribers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatSessionManager manages all active chat sessions.
|
||||||
|
type ChatSessionManager struct {
|
||||||
|
sessions map[string]*ChatSession
|
||||||
|
mu sync.RWMutex
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChatSessionManager() *ChatSessionManager {
|
||||||
|
csm := &ChatSessionManager{
|
||||||
|
sessions: make(map[string]*ChatSession),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go csm.cleanup()
|
||||||
|
return csm
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreate returns an existing session or creates a new one.
|
||||||
|
func (csm *ChatSessionManager) GetOrCreate(sessionID, projectDir string) (*ChatSession, bool, error) {
|
||||||
|
csm.mu.Lock()
|
||||||
|
defer csm.mu.Unlock()
|
||||||
|
|
||||||
|
if sess, ok := csm.sessions[sessionID]; ok {
|
||||||
|
sess.mu.Lock()
|
||||||
|
sess.LastActive = time.Now()
|
||||||
|
sess.mu.Unlock()
|
||||||
|
return sess, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn new CLI process
|
||||||
|
proc, err := SpawnCLI(projectDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("spawn CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := &ChatSession{
|
||||||
|
ID: sessionID,
|
||||||
|
ProjectDir: projectDir,
|
||||||
|
Process: proc,
|
||||||
|
Buffer: make([]ChatMessage, 0),
|
||||||
|
LastActive: time.Now(),
|
||||||
|
subscribers: make(map[string]*Subscriber),
|
||||||
|
}
|
||||||
|
|
||||||
|
csm.sessions[sessionID] = sess
|
||||||
|
return sess, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns an existing session or nil.
|
||||||
|
func (csm *ChatSessionManager) Get(sessionID string) *ChatSession {
|
||||||
|
csm.mu.RLock()
|
||||||
|
defer csm.mu.RUnlock()
|
||||||
|
return csm.sessions[sessionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove terminates and removes a session.
|
||||||
|
func (csm *ChatSessionManager) Remove(sessionID string) {
|
||||||
|
csm.mu.Lock()
|
||||||
|
sess, ok := csm.sessions[sessionID]
|
||||||
|
if ok {
|
||||||
|
delete(csm.sessions, sessionID)
|
||||||
|
}
|
||||||
|
csm.mu.Unlock()
|
||||||
|
|
||||||
|
if ok && sess.Process != nil {
|
||||||
|
sess.Process.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts down the manager and all sessions.
|
||||||
|
func (csm *ChatSessionManager) Stop() {
|
||||||
|
close(csm.stopCh)
|
||||||
|
|
||||||
|
csm.mu.Lock()
|
||||||
|
for id, sess := range csm.sessions {
|
||||||
|
if sess.Process != nil {
|
||||||
|
sess.Process.Close()
|
||||||
|
}
|
||||||
|
delete(csm.sessions, id)
|
||||||
|
}
|
||||||
|
csm.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of active sessions.
|
||||||
|
func (csm *ChatSessionManager) Count() int {
|
||||||
|
csm.mu.RLock()
|
||||||
|
defer csm.mu.RUnlock()
|
||||||
|
return len(csm.sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (csm *ChatSessionManager) cleanup() {
|
||||||
|
ticker := time.NewTicker(cleanupInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-csm.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
csm.cleanupIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (csm *ChatSessionManager) cleanupIdle() {
|
||||||
|
csm.mu.Lock()
|
||||||
|
var toRemove []string
|
||||||
|
for id, sess := range csm.sessions {
|
||||||
|
sess.mu.Lock()
|
||||||
|
idle := time.Since(sess.LastActive) > maxIdleTime
|
||||||
|
sess.mu.Unlock()
|
||||||
|
if idle {
|
||||||
|
toRemove = append(toRemove, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
csm.mu.Unlock()
|
||||||
|
|
||||||
|
for _, id := range toRemove {
|
||||||
|
log.Printf("Cleaning up idle session: %s", id)
|
||||||
|
csm.Remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
153
sessions_test.go
Normal file
153
sessions_test.go
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChatSession_AddMessage(t *testing.T) {
|
||||||
|
cs := &ChatSession{
|
||||||
|
Buffer: make([]ChatMessage, 0),
|
||||||
|
subscribers: make(map[string]*Subscriber),
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.AddMessage(ChatMessage{Role: "user", Content: "hello"})
|
||||||
|
cs.AddMessage(ChatMessage{Role: "assistant", Content: "hi"})
|
||||||
|
|
||||||
|
buf := cs.GetBuffer()
|
||||||
|
if len(buf) != 2 {
|
||||||
|
t.Fatalf("buffer len = %d, want 2", len(buf))
|
||||||
|
}
|
||||||
|
if buf[0].Role != "user" {
|
||||||
|
t.Errorf("buf[0].Role = %q", buf[0].Role)
|
||||||
|
}
|
||||||
|
if buf[1].Content != "hi" {
|
||||||
|
t.Errorf("buf[1].Content = %q", buf[1].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSession_BufferCap(t *testing.T) {
|
||||||
|
cs := &ChatSession{
|
||||||
|
Buffer: make([]ChatMessage, 0),
|
||||||
|
subscribers: make(map[string]*Subscriber),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < maxBufferSize+50; i++ {
|
||||||
|
cs.AddMessage(ChatMessage{Role: "user", Content: "msg"})
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := cs.GetBuffer()
|
||||||
|
if len(buf) != maxBufferSize {
|
||||||
|
t.Errorf("buffer len = %d, want %d", len(buf), maxBufferSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSession_GetBufferIsCopy(t *testing.T) {
|
||||||
|
cs := &ChatSession{
|
||||||
|
Buffer: make([]ChatMessage, 0),
|
||||||
|
subscribers: make(map[string]*Subscriber),
|
||||||
|
}
|
||||||
|
cs.AddMessage(ChatMessage{Role: "user", Content: "hello"})
|
||||||
|
|
||||||
|
buf := cs.GetBuffer()
|
||||||
|
buf[0].Content = "modified"
|
||||||
|
|
||||||
|
original := cs.GetBuffer()
|
||||||
|
if original[0].Content != "hello" {
|
||||||
|
t.Error("GetBuffer should return a copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSession_LastActiveUpdated(t *testing.T) {
|
||||||
|
cs := &ChatSession{
|
||||||
|
Buffer: make([]ChatMessage, 0),
|
||||||
|
LastActive: time.Now().Add(-1 * time.Hour),
|
||||||
|
subscribers: make(map[string]*Subscriber),
|
||||||
|
}
|
||||||
|
before := cs.LastActive
|
||||||
|
cs.AddMessage(ChatMessage{Role: "user", Content: "hello"})
|
||||||
|
if !cs.LastActive.After(before) {
|
||||||
|
t.Error("LastActive should be updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSession_Subscribe(t *testing.T) {
|
||||||
|
cs := &ChatSession{
|
||||||
|
Buffer: make([]ChatMessage, 0),
|
||||||
|
subscribers: make(map[string]*Subscriber),
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := cs.Subscribe("test-1")
|
||||||
|
if sub == nil {
|
||||||
|
t.Fatal("subscriber is nil")
|
||||||
|
}
|
||||||
|
if cs.SubscriberCount() != 1 {
|
||||||
|
t.Errorf("subscriber count = %d, want 1", cs.SubscriberCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message should be broadcast
|
||||||
|
go cs.AddMessage(ChatMessage{Role: "user", Content: "hello"})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-sub.Ch:
|
||||||
|
if msg != "hello" {
|
||||||
|
t.Errorf("got %q, want hello", msg)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timeout waiting for broadcast")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSession_Unsubscribe(t *testing.T) {
|
||||||
|
cs := &ChatSession{
|
||||||
|
Buffer: make([]ChatMessage, 0),
|
||||||
|
subscribers: make(map[string]*Subscriber),
|
||||||
|
}
|
||||||
|
|
||||||
|
cs.Subscribe("test-1")
|
||||||
|
cs.Unsubscribe("test-1")
|
||||||
|
|
||||||
|
if cs.SubscriberCount() != 0 {
|
||||||
|
t.Errorf("subscriber count = %d, want 0", cs.SubscriberCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSession_MultipleSubscribers(t *testing.T) {
|
||||||
|
cs := &ChatSession{
|
||||||
|
Buffer: make([]ChatMessage, 0),
|
||||||
|
subscribers: make(map[string]*Subscriber),
|
||||||
|
}
|
||||||
|
|
||||||
|
sub1 := cs.Subscribe("s1")
|
||||||
|
sub2 := cs.Subscribe("s2")
|
||||||
|
|
||||||
|
go cs.AddMessage(ChatMessage{Role: "user", Content: "broadcast"})
|
||||||
|
|
||||||
|
for _, sub := range []*Subscriber{sub1, sub2} {
|
||||||
|
select {
|
||||||
|
case msg := <-sub.Ch:
|
||||||
|
if msg != "broadcast" {
|
||||||
|
t.Errorf("got %q, want broadcast", msg)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timeout waiting for broadcast")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSessionManager_Count(t *testing.T) {
|
||||||
|
csm := NewChatSessionManager()
|
||||||
|
defer csm.Stop()
|
||||||
|
|
||||||
|
if csm.Count() != 0 {
|
||||||
|
t.Errorf("initial count = %d, want 0", csm.Count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSessionManager_Remove(t *testing.T) {
|
||||||
|
csm := NewChatSessionManager()
|
||||||
|
defer csm.Stop()
|
||||||
|
|
||||||
|
// Remove nonexistent should not panic
|
||||||
|
csm.Remove("nonexistent")
|
||||||
|
}
|
||||||
1
static/htmx.min.js
vendored
Normal file
1
static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
465
static/style.css
Normal file
465
static/style.css
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #1a1a2e;
|
||||||
|
--bg-secondary: #16213e;
|
||||||
|
--bg-tertiary: #0f3460;
|
||||||
|
--bg-input: #1e2a4a;
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--text-secondary: #a0a0b0;
|
||||||
|
--text-muted: #6c6c80;
|
||||||
|
--accent: #e94560;
|
||||||
|
--accent-hover: #ff6b81;
|
||||||
|
--border: #2a2a4a;
|
||||||
|
--success: #4caf50;
|
||||||
|
--warning: #ff9800;
|
||||||
|
--error: #f44336;
|
||||||
|
--code-bg: #0d1117;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 8px 32px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
background: rgba(244, 67, 54, 0.15);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Projects grid */
|
||||||
|
.projects-container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-container h1 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-header .btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, transform 0.1s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card h3 {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat layout */
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 300px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.active {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header .status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header .status.connected {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 85%;
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-assistant .content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-system {
|
||||||
|
align-self: center;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tool {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: rgba(15, 52, 96, 0.5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-tool .tool-name {
|
||||||
|
color: var(--warning);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator {
|
||||||
|
align-self: flex-start;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator .dots {
|
||||||
|
display: inline-block;
|
||||||
|
animation: blink 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 20% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
80%, 100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
font-family: inherit;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-form .btn {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks in messages */
|
||||||
|
.message pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message code {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message p code {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File viewer overlay */
|
||||||
|
.file-viewer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 50%;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: -4px 0 16px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-viewer-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-viewer-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-viewer-content h1,
|
||||||
|
.file-viewer-content h2,
|
||||||
|
.file-viewer-content h3 {
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-viewer-content pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-viewer-content code {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden */
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
471
static/ws.js
Normal file
471
static/ws.js
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
/*
|
||||||
|
WebSockets Extension
|
||||||
|
============================
|
||||||
|
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
/** @type {import("../htmx").HtmxInternalApi} */
|
||||||
|
var api
|
||||||
|
|
||||||
|
htmx.defineExtension('ws', {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* init is called once, when this extension is first registered.
|
||||||
|
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||||
|
*/
|
||||||
|
init: function(apiRef) {
|
||||||
|
// Store reference to internal API
|
||||||
|
api = apiRef
|
||||||
|
|
||||||
|
// Default function for creating new EventSource objects
|
||||||
|
if (!htmx.createWebSocket) {
|
||||||
|
htmx.createWebSocket = createWebSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default setting for reconnect delay
|
||||||
|
if (!htmx.config.wsReconnectDelay) {
|
||||||
|
htmx.config.wsReconnectDelay = 'full-jitter'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onEvent handles all events passed to this extension.
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {Event} evt
|
||||||
|
*/
|
||||||
|
onEvent: function(name, evt) {
|
||||||
|
var parent = evt.target || evt.detail.elt
|
||||||
|
switch (name) {
|
||||||
|
// Try to close the socket when elements are removed
|
||||||
|
case 'htmx:beforeCleanupElement':
|
||||||
|
|
||||||
|
var internalData = api.getInternalData(parent)
|
||||||
|
|
||||||
|
if (internalData.webSocket) {
|
||||||
|
internalData.webSocket.close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
// Try to create websockets when elements are processed
|
||||||
|
case 'htmx:beforeProcessNode':
|
||||||
|
|
||||||
|
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
|
||||||
|
ensureWebSocket(child)
|
||||||
|
})
|
||||||
|
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
|
||||||
|
ensureWebSocketSend(child)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function splitOnWhitespace(trigger) {
|
||||||
|
return trigger.trim().split(/\s+/)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyWebsocketURL(elt) {
|
||||||
|
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws')
|
||||||
|
if (legacySSEValue) {
|
||||||
|
var values = splitOnWhitespace(legacySSEValue)
|
||||||
|
for (var i = 0; i < values.length; i++) {
|
||||||
|
var value = values[i].split(/:(.+)/)
|
||||||
|
if (value[0] === 'connect') {
|
||||||
|
return value[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||||
|
* the element's "ws-connect" attribute.
|
||||||
|
* @param {HTMLElement} socketElt
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function ensureWebSocket(socketElt) {
|
||||||
|
// If the element containing the WebSocket connection no longer exists, then
|
||||||
|
// do not connect/reconnect the WebSocket.
|
||||||
|
if (!api.bodyContains(socketElt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the source straight from the element's value
|
||||||
|
var wssSource = api.getAttributeValue(socketElt, 'ws-connect')
|
||||||
|
|
||||||
|
if (wssSource == null || wssSource === '') {
|
||||||
|
var legacySource = getLegacyWebsocketURL(socketElt)
|
||||||
|
if (legacySource == null) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
wssSource = legacySource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee that the wssSource value is a fully qualified URL
|
||||||
|
if (wssSource.indexOf('/') === 0) {
|
||||||
|
var base_part = location.hostname + (location.port ? ':' + location.port : '')
|
||||||
|
if (location.protocol === 'https:') {
|
||||||
|
wssSource = 'wss://' + base_part + wssSource
|
||||||
|
} else if (location.protocol === 'http:') {
|
||||||
|
wssSource = 'ws://' + base_part + wssSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var socketWrapper = createWebsocketWrapper(socketElt, function() {
|
||||||
|
return htmx.createWebSocket(wssSource)
|
||||||
|
})
|
||||||
|
|
||||||
|
socketWrapper.addEventListener('message', function(event) {
|
||||||
|
if (maybeCloseWebSocketSource(socketElt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = event.data
|
||||||
|
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
|
||||||
|
message: response,
|
||||||
|
socketWrapper: socketWrapper.publicInterface
|
||||||
|
})) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.withExtensions(socketElt, function(extension) {
|
||||||
|
response = extension.transformResponse(response, null, socketElt)
|
||||||
|
})
|
||||||
|
|
||||||
|
var settleInfo = api.makeSettleInfo(socketElt)
|
||||||
|
var fragment = api.makeFragment(response)
|
||||||
|
|
||||||
|
if (fragment.children.length) {
|
||||||
|
var children = Array.from(fragment.children)
|
||||||
|
for (var i = 0; i < children.length; i++) {
|
||||||
|
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.settleImmediately(settleInfo.tasks)
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Put the WebSocket into the HTML Element's custom data.
|
||||||
|
api.getInternalData(socketElt).webSocket = socketWrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} WebSocketWrapper
|
||||||
|
* @property {WebSocket} socket
|
||||||
|
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||||
|
* @property {number} retryCount
|
||||||
|
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||||
|
* @property {(message: string, sendElt: Element) => void} send
|
||||||
|
* @property {(event: string, handler: Function) => void} addEventListener
|
||||||
|
* @property {() => void} handleQueuedMessages
|
||||||
|
* @property {() => void} init
|
||||||
|
* @property {() => void} close
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param socketElt
|
||||||
|
* @param socketFunc
|
||||||
|
* @returns {WebSocketWrapper}
|
||||||
|
*/
|
||||||
|
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||||
|
var wrapper = {
|
||||||
|
socket: null,
|
||||||
|
messageQueue: [],
|
||||||
|
retryCount: 0,
|
||||||
|
|
||||||
|
/** @type {Object<string, Function[]>} */
|
||||||
|
events: {},
|
||||||
|
|
||||||
|
addEventListener: function(event, handler) {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.addEventListener(event, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.events[event]) {
|
||||||
|
this.events[event] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events[event].push(handler)
|
||||||
|
},
|
||||||
|
|
||||||
|
sendImmediately: function(message, sendElt) {
|
||||||
|
if (!this.socket) {
|
||||||
|
api.triggerErrorEvent()
|
||||||
|
}
|
||||||
|
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||||
|
message,
|
||||||
|
socketWrapper: this.publicInterface
|
||||||
|
})) {
|
||||||
|
this.socket.send(message)
|
||||||
|
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||||
|
message,
|
||||||
|
socketWrapper: this.publicInterface
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
send: function(message, sendElt) {
|
||||||
|
if (this.socket.readyState !== this.socket.OPEN) {
|
||||||
|
this.messageQueue.push({ message, sendElt })
|
||||||
|
} else {
|
||||||
|
this.sendImmediately(message, sendElt)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleQueuedMessages: function() {
|
||||||
|
while (this.messageQueue.length > 0) {
|
||||||
|
var queuedItem = this.messageQueue[0]
|
||||||
|
if (this.socket.readyState === this.socket.OPEN) {
|
||||||
|
this.sendImmediately(queuedItem.message, queuedItem.sendElt)
|
||||||
|
this.messageQueue.shift()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||||
|
// Close discarded socket
|
||||||
|
this.socket.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new WebSocket and event handlers
|
||||||
|
/** @type {WebSocket} */
|
||||||
|
var socket = socketFunc()
|
||||||
|
|
||||||
|
// The event.type detail is added for interface conformance with the
|
||||||
|
// other two lifecycle events (open and close) so a single handler method
|
||||||
|
// can handle them polymorphically, if required.
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } })
|
||||||
|
|
||||||
|
this.socket = socket
|
||||||
|
|
||||||
|
socket.onopen = function(e) {
|
||||||
|
wrapper.retryCount = 0
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface })
|
||||||
|
wrapper.handleQueuedMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = function(e) {
|
||||||
|
// If socket should not be connected, stop further attempts to establish connection
|
||||||
|
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||||
|
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||||
|
var delay = getWebSocketReconnectDelay(wrapper.retryCount)
|
||||||
|
setTimeout(function() {
|
||||||
|
wrapper.retryCount += 1
|
||||||
|
wrapper.init()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||||
|
// to determine whether closure has been valid or abnormal
|
||||||
|
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface })
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = function(e) {
|
||||||
|
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper })
|
||||||
|
maybeCloseWebSocketSource(socketElt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var events = this.events
|
||||||
|
Object.keys(events).forEach(function(k) {
|
||||||
|
events[k].forEach(function(e) {
|
||||||
|
socket.addEventListener(k, e)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
close: function() {
|
||||||
|
this.socket.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.init()
|
||||||
|
|
||||||
|
wrapper.publicInterface = {
|
||||||
|
send: wrapper.send.bind(wrapper),
|
||||||
|
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||||
|
queue: wrapper.messageQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureWebSocketSend attaches trigger handles to elements with
|
||||||
|
* "ws-send" attribute
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
*/
|
||||||
|
function ensureWebSocketSend(elt) {
|
||||||
|
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws')
|
||||||
|
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||||
|
processWebSocketSend(webSocketParent, elt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hasWebSocket function checks if a node has webSocket instance attached
|
||||||
|
* @param {HTMLElement} node
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function hasWebSocket(node) {
|
||||||
|
return api.getInternalData(node).webSocket != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* processWebSocketSend adds event listeners to the <form> element so that
|
||||||
|
* messages can be sent to the WebSocket server when the form is submitted.
|
||||||
|
* @param {HTMLElement} socketElt
|
||||||
|
* @param {HTMLElement} sendElt
|
||||||
|
*/
|
||||||
|
function processWebSocketSend(socketElt, sendElt) {
|
||||||
|
var nodeData = api.getInternalData(sendElt)
|
||||||
|
var triggerSpecs = api.getTriggerSpecs(sendElt)
|
||||||
|
triggerSpecs.forEach(function(ts) {
|
||||||
|
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) {
|
||||||
|
if (maybeCloseWebSocketSource(socketElt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {WebSocketWrapper} */
|
||||||
|
var socketWrapper = api.getInternalData(socketElt).webSocket
|
||||||
|
var headers = api.getHeaders(sendElt, api.getTarget(sendElt))
|
||||||
|
var results = api.getInputValues(sendElt, 'post')
|
||||||
|
var errors = results.errors
|
||||||
|
var rawParameters = Object.assign({}, results.values)
|
||||||
|
var expressionVars = api.getExpressionVars(sendElt)
|
||||||
|
var allParameters = api.mergeObjects(rawParameters, expressionVars)
|
||||||
|
var filteredParameters = api.filterValues(allParameters, sendElt)
|
||||||
|
|
||||||
|
var sendConfig = {
|
||||||
|
parameters: filteredParameters,
|
||||||
|
unfilteredParameters: allParameters,
|
||||||
|
headers,
|
||||||
|
errors,
|
||||||
|
|
||||||
|
triggeringEvent: evt,
|
||||||
|
messageBody: undefined,
|
||||||
|
socketWrapper: socketWrapper.publicInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors && errors.length > 0) {
|
||||||
|
api.triggerEvent(elt, 'htmx:validation:halted', errors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = sendConfig.messageBody
|
||||||
|
if (body === undefined) {
|
||||||
|
var toSend = Object.assign({}, sendConfig.parameters)
|
||||||
|
if (sendConfig.headers) { toSend.HEADERS = headers }
|
||||||
|
body = JSON.stringify(toSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
socketWrapper.send(body, elt)
|
||||||
|
|
||||||
|
if (evt && api.shouldCancel(evt, elt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||||
|
* @param {number} retryCount // The number of retries that have already taken place
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function getWebSocketReconnectDelay(retryCount) {
|
||||||
|
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||||
|
var delay = htmx.config.wsReconnectDelay
|
||||||
|
if (typeof delay === 'function') {
|
||||||
|
return delay(retryCount)
|
||||||
|
}
|
||||||
|
if (delay === 'full-jitter') {
|
||||||
|
var exp = Math.min(retryCount, 6)
|
||||||
|
var maxDelay = 1000 * Math.pow(2, exp)
|
||||||
|
return maxDelay * Math.random()
|
||||||
|
}
|
||||||
|
|
||||||
|
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||||
|
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||||
|
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||||
|
* returns FALSE.
|
||||||
|
*
|
||||||
|
* @param {*} elt
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
function maybeCloseWebSocketSource(elt) {
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
var internalData = api.getInternalData(elt)
|
||||||
|
if (internalData.webSocket) {
|
||||||
|
internalData.webSocket.close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createWebSocket is the default method for creating new WebSocket objects.
|
||||||
|
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||||
|
*
|
||||||
|
* @param {string} url
|
||||||
|
* @returns WebSocket
|
||||||
|
*/
|
||||||
|
function createWebSocket(url) {
|
||||||
|
var sock = new WebSocket(url, [])
|
||||||
|
sock.binaryType = htmx.config.wsBinaryType
|
||||||
|
return sock
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {string} attributeName
|
||||||
|
*/
|
||||||
|
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||||
|
var result = []
|
||||||
|
|
||||||
|
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||||
|
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
|
||||||
|
result.push(elt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search all child nodes that match the requested attribute
|
||||||
|
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
|
||||||
|
result.push(node)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {T[]} arr
|
||||||
|
* @param {(T) => void} func
|
||||||
|
*/
|
||||||
|
function forEach(arr, func) {
|
||||||
|
if (arr) {
|
||||||
|
for (var i = 0; i < arr.length; i++) {
|
||||||
|
func(arr[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
55
templates.go
Normal file
55
templates.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TemplateRenderer struct {
|
||||||
|
dir string
|
||||||
|
templates map[string]*template.Template
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateRenderer(dir string) (*TemplateRenderer, error) {
|
||||||
|
tr := &TemplateRenderer{
|
||||||
|
dir: dir,
|
||||||
|
templates: make(map[string]*template.Template),
|
||||||
|
}
|
||||||
|
if err := tr.loadAll(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRenderer) loadAll() error {
|
||||||
|
entries, err := os.ReadDir(tr.dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || filepath.Ext(e.Name()) != ".html" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(tr.dir, e.Name())
|
||||||
|
tmpl, err := template.ParseFiles(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tr.templates[e.Name()] = tmpl
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRenderer) Render(w io.Writer, name string, data any) error {
|
||||||
|
tr.mu.RLock()
|
||||||
|
tmpl, ok := tr.templates[name]
|
||||||
|
tr.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return tmpl.Execute(w, data)
|
||||||
|
}
|
||||||
112
templates/chat.html
Normal file
112
templates/chat.html
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Claude Web Chat — {{.Project}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
<script src="/static/ws.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="chat-container">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3>Fajlovi</h3>
|
||||||
|
<a href="/projects" style="font-size:0.85rem;">← Projekti</a>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-content" id="file-list">
|
||||||
|
{{range .Files}}
|
||||||
|
<a class="file-item" href="#" onclick="loadFile('{{.RelPath}}'); return false;">{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
|
{{if not .Files}}
|
||||||
|
<div style="padding: 1rem; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Nema .md fajlova
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat main area -->
|
||||||
|
<div class="chat-main">
|
||||||
|
<div class="chat-header">
|
||||||
|
<h2>{{.Project}}</h2>
|
||||||
|
<span id="ws-status" class="status">Povezivanje...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-messages" id="chat-messages">
|
||||||
|
<!-- Messages will be appended here via OOB swap -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="typing-indicator"></div>
|
||||||
|
|
||||||
|
<div class="chat-input-area" hx-ext="ws" ws-connect="/ws?project={{.Project}}&project_dir={{.ProjectDir}}">
|
||||||
|
<form class="chat-input-form" ws-send>
|
||||||
|
<textarea id="message-input" name="message" class="chat-input" placeholder="Pošalji poruku..." rows="1"></textarea>
|
||||||
|
<button type="submit" class="btn">Pošalji</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File viewer overlay (hidden by default) -->
|
||||||
|
<div id="file-viewer" class="file-viewer hidden">
|
||||||
|
<div class="file-viewer-header">
|
||||||
|
<h3 id="file-viewer-title"></h3>
|
||||||
|
<button class="btn" onclick="closeFileViewer()">Zatvori</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-viewer-content" id="file-viewer-content">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-resize textarea
|
||||||
|
const textarea = document.getElementById('message-input');
|
||||||
|
if (textarea) {
|
||||||
|
textarea.addEventListener('input', function() {
|
||||||
|
this.style.height = 'auto';
|
||||||
|
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit on Enter (Shift+Enter for newline)
|
||||||
|
textarea.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.closest('form').dispatchEvent(new Event('submit', { bubbles: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll chat
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
const observer = new MutationObserver(function() {
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
});
|
||||||
|
observer.observe(chatMessages, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// File viewer
|
||||||
|
function loadFile(relPath) {
|
||||||
|
fetch('/api/file?project={{.Project}}&path=' + encodeURIComponent(relPath))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('file-viewer-title').textContent = data.name;
|
||||||
|
document.getElementById('file-viewer-content').innerHTML = data.html;
|
||||||
|
document.getElementById('file-viewer').classList.remove('hidden');
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Error loading file:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFileViewer() {
|
||||||
|
document.getElementById('file-viewer').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close file viewer with Escape
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeFileViewer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
templates/login.html
Normal file
30
templates/login.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Claude Web Chat — Login</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>Claude Web Chat</h1>
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error-msg">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Korisnik</label>
|
||||||
|
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Lozinka</label>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-full">Prijavi se</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
templates/projects.html
Normal file
33
templates/projects.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Claude Web Chat — Projekti</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="projects-container">
|
||||||
|
<div class="projects-header">
|
||||||
|
<h1>Projekti</h1>
|
||||||
|
<a href="/logout" class="btn">Odjavi se</a>
|
||||||
|
</div>
|
||||||
|
{{if .Projects}}
|
||||||
|
<div class="projects-grid">
|
||||||
|
{{range .Projects}}
|
||||||
|
<a href="/chat/{{.Name}}" class="project-card">
|
||||||
|
<h3>{{.Name}}</h3>
|
||||||
|
{{if .Description}}
|
||||||
|
<p>{{.Description}}</p>
|
||||||
|
{{else}}
|
||||||
|
<p>Bez opisa</p>
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p style="color: var(--text-secondary);">Nema projekata u {{.ProjectsPath}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
63
templates_test.go
Normal file
63
templates_test.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTemplateRenderer(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
os.WriteFile(filepath.Join(dir, "test.html"), []byte(`<h1>{{.Title}}</h1>`), 0644)
|
||||||
|
os.WriteFile(filepath.Join(dir, "plain.html"), []byte(`<p>Hello</p>`), 0644)
|
||||||
|
|
||||||
|
tr, err := NewTemplateRenderer(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTemplateRenderer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("render with data", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := tr.Render(&buf, "test.html", map[string]string{"Title": "World"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if buf.String() != "<h1>World</h1>" {
|
||||||
|
t.Errorf("got %q", buf.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("render plain", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := tr.Render(&buf, "plain.html", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if buf.String() != "<p>Hello</p>" {
|
||||||
|
t.Errorf("got %q", buf.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("template not found", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := tr.Render(&buf, "missing.html", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing template")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty dir", func(t *testing.T) {
|
||||||
|
emptyDir := t.TempDir()
|
||||||
|
tr2, err := NewTemplateRenderer(emptyDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewTemplateRenderer: %v", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = tr2.Render(&buf, "anything.html", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
248
ws.go
Normal file
248
ws.go
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
type wsMessage struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSHandler handles WebSocket connections for chat.
|
||||||
|
type WSHandler struct {
|
||||||
|
sessionMgr *ChatSessionManager
|
||||||
|
sessionsMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWSHandler(sessionMgr *ChatSessionManager) *WSHandler {
|
||||||
|
return &WSHandler{
|
||||||
|
sessionMgr: sessionMgr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wh *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
project := r.URL.Query().Get("project")
|
||||||
|
projectDir := r.URL.Query().Get("project_dir")
|
||||||
|
if project == "" || projectDir == "" {
|
||||||
|
http.Error(w, "missing project params", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WebSocket upgrade error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
sessionID := fmt.Sprintf("%s-%s", project, r.RemoteAddr)
|
||||||
|
subID := fmt.Sprintf("ws-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
sess, isNew, err := wh.sessionMgr.GetOrCreate(sessionID, projectDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Session create error: %v", err)
|
||||||
|
writeWSText(conn, FragmentSystemMessage(fmt.Sprintf("Greška pri pokretanju Claude-a: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send status
|
||||||
|
writeWSText(conn, FragmentStatus(true))
|
||||||
|
|
||||||
|
if isNew {
|
||||||
|
writeWSText(conn, FragmentSystemMessage("Claude sesija pokrenuta. Možeš da pišeš."))
|
||||||
|
} else {
|
||||||
|
// Replay buffer
|
||||||
|
buffer := sess.GetBuffer()
|
||||||
|
for _, msg := range buffer {
|
||||||
|
writeWSText(conn, msg.Content)
|
||||||
|
}
|
||||||
|
writeWSText(conn, FragmentSystemMessage("Ponovo povezan. Istorija učitana."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to session broadcasts
|
||||||
|
sub := sess.Subscribe(subID)
|
||||||
|
defer sess.Unsubscribe(subID)
|
||||||
|
|
||||||
|
// Start event listener if new session
|
||||||
|
if isNew {
|
||||||
|
go wh.listenEvents(sess)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write pump: forward broadcast messages to this WebSocket
|
||||||
|
wsDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(wsDone)
|
||||||
|
for fragment := range sub.Ch {
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(fragment)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Read pump: messages from browser
|
||||||
|
for {
|
||||||
|
_, raw, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
|
log.Printf("WebSocket read error: %v", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg wsMessage
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
log.Printf("Invalid WS message: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.TrimSpace(msg.Message)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user message to buffer (broadcasts to all subscribers including us)
|
||||||
|
userFragment := FragmentUserMessage(text)
|
||||||
|
sess.AddMessage(ChatMessage{
|
||||||
|
Role: "user",
|
||||||
|
Content: userFragment,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear input and show typing — send directly to this connection only
|
||||||
|
writeWSText(conn, FragmentCombine(FragmentClearInput(), FragmentTypingIndicator(true)))
|
||||||
|
|
||||||
|
// Send to claude CLI
|
||||||
|
if err := sess.Process.Send(text); err != nil {
|
||||||
|
log.Printf("Send to CLI error: %v", err)
|
||||||
|
writeWSText(conn, FragmentSystemMessage("Greška pri slanju poruke"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't close session — it stays alive for reconnect
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenEvents reads events from the CLI process and broadcasts via AddMessage.
|
||||||
|
func (wh *WSHandler) listenEvents(sess *ChatSession) {
|
||||||
|
var currentMsgID string
|
||||||
|
var currentText strings.Builder
|
||||||
|
msgCounter := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, ok := <-sess.Process.Events:
|
||||||
|
if !ok {
|
||||||
|
sess.AddMessage(ChatMessage{
|
||||||
|
Role: "system",
|
||||||
|
Content: FragmentSystemMessage("Claude sesija završena."),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.Type {
|
||||||
|
case "assistant":
|
||||||
|
if event.Message != nil {
|
||||||
|
for _, c := range event.Message.Content {
|
||||||
|
if c.Type == "tool_use" {
|
||||||
|
inputStr := ""
|
||||||
|
if c.Input != nil {
|
||||||
|
if b, err := json.Marshal(c.Input); err == nil {
|
||||||
|
inputStr = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fragment := FragmentToolCall(c.Name, inputStr)
|
||||||
|
sess.AddMessage(ChatMessage{
|
||||||
|
Role: "tool",
|
||||||
|
Content: fragment,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "content_block_start":
|
||||||
|
msgCounter++
|
||||||
|
currentMsgID = fmt.Sprintf("msg-%d-%d", time.Now().UnixMilli(), msgCounter)
|
||||||
|
currentText.Reset()
|
||||||
|
fragment := FragmentAssistantStart(currentMsgID)
|
||||||
|
sess.AddMessage(ChatMessage{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: fragment,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
case "content_block_delta":
|
||||||
|
if event.Delta != nil && event.Delta.Text != "" {
|
||||||
|
currentText.WriteString(event.Delta.Text)
|
||||||
|
fragment := FragmentAssistantChunk(currentMsgID, event.Delta.Text)
|
||||||
|
sess.AddMessage(ChatMessage{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: fragment,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case "content_block_stop":
|
||||||
|
if currentText.Len() > 0 && currentMsgID != "" {
|
||||||
|
rendered := renderMarkdown(currentText.String())
|
||||||
|
fragment := FragmentAssistantComplete(currentMsgID, rendered)
|
||||||
|
sess.AddMessage(ChatMessage{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: fragment,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case "result":
|
||||||
|
fragment := FragmentTypingIndicator(false)
|
||||||
|
sess.AddMessage(ChatMessage{
|
||||||
|
Role: "system",
|
||||||
|
Content: fragment,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
if event.Result != nil && event.Result.CostUSD > 0 {
|
||||||
|
costMsg := FragmentSystemMessage(fmt.Sprintf("Gotovo (%.4f USD)", event.Result.CostUSD))
|
||||||
|
sess.AddMessage(ChatMessage{
|
||||||
|
Role: "system",
|
||||||
|
Content: costMsg,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case err, ok := <-sess.Process.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("CLI error [%s]: %v", sess.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMarkdown(text string) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := goldmark.Convert([]byte(text), &buf); err != nil {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWSText(conn *websocket.Conn, text string) {
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte(text))
|
||||||
|
}
|
||||||
29
ws_test.go
Normal file
29
ws_test.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderMarkdown(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{"plain text", "Hello world", "<p>Hello world</p>"},
|
||||||
|
{"bold", "**bold**", "<strong>bold</strong>"},
|
||||||
|
{"code block", "```\ncode\n```", "<code>code"},
|
||||||
|
{"inline code", "`inline`", "<code>inline</code>"},
|
||||||
|
{"heading", "# Title", "<h1>Title</h1>"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := renderMarkdown(tt.input)
|
||||||
|
if !strings.Contains(result, tt.contains) {
|
||||||
|
t.Errorf("renderMarkdown(%q) = %q, want to contain %q", tt.input, result, tt.contains)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user