claude-web-chat/sessions.go
djuka 3283888738
All checks were successful
Tests / unit-tests (push) Successful in 51s
Inicijalna implementacija Claude Web Chat (Faza 1 - CLI mod)
- 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>
2026-02-18 05:03:40 +00:00

228 lines
4.8 KiB
Go

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