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>
228 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|