Inicijalna implementacija Claude Web Chat (Faza 1 - CLI mod)
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:
djuka 2026-02-18 05:03:40 +00:00
commit 3283888738
32 changed files with 3556 additions and 0 deletions

11
.gitea/workflows/test.yml Normal file
View 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
View File

@ -0,0 +1,6 @@
config.json
claude-web-chat
*.exe
*.test
.env
.claude/

55
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 &lt;world&gt;") {
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&lt;text&gt;") {
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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

465
static/style.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
})
}
}