Skip to content

Commit 6b534e6

Browse files
feat: add chat history persistence and agent web search
- Add file-based conversation storage under ~/.csghub-lite/conversations/ with REST API for CRUD operations - Add automatic web search agent: LLM decides when to search, backend injects tool and executes DuckDuckGo/Searxng queries transparently - Rewrite Chat.tsx with conversation sidebar, backend-backed sessions, and searching indicator - No new Go dependencies added Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 697f856 commit 6b534e6

11 files changed

Lines changed: 1395 additions & 98 deletions

File tree

internal/chathistory/store.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package chathistory
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"sort"
9+
"strings"
10+
"sync"
11+
"time"
12+
13+
"github.com/opencsgs/csghub-lite/pkg/api"
14+
)
15+
16+
const conversationsDir = "conversations"
17+
18+
type Store struct {
19+
dir string
20+
mu sync.RWMutex
21+
}
22+
23+
func NewStore(appHome string) *Store {
24+
dir := filepath.Join(appHome, conversationsDir)
25+
os.MkdirAll(dir, 0o755)
26+
return &Store{dir: dir}
27+
}
28+
29+
func (s *Store) List() ([]api.ConversationMeta, error) {
30+
s.mu.RLock()
31+
defer s.mu.RUnlock()
32+
33+
entries, err := os.ReadDir(s.dir)
34+
if err != nil {
35+
if os.IsNotExist(err) {
36+
return nil, nil
37+
}
38+
return nil, fmt.Errorf("reading conversations dir: %w", err)
39+
}
40+
41+
var metas []api.ConversationMeta
42+
for _, e := range entries {
43+
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
44+
continue
45+
}
46+
data, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
47+
if err != nil {
48+
continue
49+
}
50+
var conv api.Conversation
51+
if err := json.Unmarshal(data, &conv); err != nil {
52+
continue
53+
}
54+
metas = append(metas, api.ConversationMeta{
55+
ID: conv.ID,
56+
Title: conv.Title,
57+
Model: conv.Model,
58+
CreatedAt: conv.CreatedAt,
59+
UpdatedAt: conv.UpdatedAt,
60+
MsgCount: len(conv.Messages),
61+
})
62+
}
63+
64+
sort.Slice(metas, func(i, j int) bool {
65+
return metas[i].UpdatedAt.After(metas[j].UpdatedAt)
66+
})
67+
return metas, nil
68+
}
69+
70+
func (s *Store) Get(id string) (*api.Conversation, error) {
71+
if !isValidID(id) {
72+
return nil, fmt.Errorf("invalid conversation id")
73+
}
74+
75+
s.mu.RLock()
76+
defer s.mu.RUnlock()
77+
78+
data, err := os.ReadFile(s.filePath(id))
79+
if err != nil {
80+
if os.IsNotExist(err) {
81+
return nil, fmt.Errorf("conversation %q not found", id)
82+
}
83+
return nil, err
84+
}
85+
var conv api.Conversation
86+
if err := json.Unmarshal(data, &conv); err != nil {
87+
return nil, fmt.Errorf("decoding conversation: %w", err)
88+
}
89+
return &conv, nil
90+
}
91+
92+
func (s *Store) Save(conv *api.Conversation) error {
93+
if conv.ID == "" || !isValidID(conv.ID) {
94+
return fmt.Errorf("invalid conversation id")
95+
}
96+
97+
s.mu.Lock()
98+
defer s.mu.Unlock()
99+
100+
data, err := json.MarshalIndent(conv, "", " ")
101+
if err != nil {
102+
return fmt.Errorf("encoding conversation: %w", err)
103+
}
104+
105+
if err := os.MkdirAll(s.dir, 0o755); err != nil {
106+
return err
107+
}
108+
109+
tmpFile := s.filePath(conv.ID) + ".tmp"
110+
if err := os.WriteFile(tmpFile, data, 0o644); err != nil {
111+
return err
112+
}
113+
return os.Rename(tmpFile, s.filePath(conv.ID))
114+
}
115+
116+
func (s *Store) Delete(id string) error {
117+
if !isValidID(id) {
118+
return fmt.Errorf("invalid conversation id")
119+
}
120+
121+
s.mu.Lock()
122+
defer s.mu.Unlock()
123+
124+
err := os.Remove(s.filePath(id))
125+
if err != nil && !os.IsNotExist(err) {
126+
return err
127+
}
128+
return nil
129+
}
130+
131+
func (s *Store) filePath(id string) string {
132+
return filepath.Join(s.dir, id+".json")
133+
}
134+
135+
func isValidID(id string) bool {
136+
if id == "" || len(id) > 128 {
137+
return false
138+
}
139+
for _, c := range id {
140+
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
141+
return false
142+
}
143+
}
144+
return true
145+
}
146+
147+
func NewConversation() *api.Conversation {
148+
now := time.Now()
149+
return &api.Conversation{
150+
ID: generateID(),
151+
Title: "New Chat",
152+
CreatedAt: now,
153+
UpdatedAt: now,
154+
Messages: []api.Message{},
155+
}
156+
}
157+
158+
func generateID() string {
159+
return fmt.Sprintf("%d-%s", time.Now().UnixMilli(), randomHex(8))
160+
}
161+
162+
func randomHex(n int) string {
163+
b := make([]byte, n)
164+
f, err := os.Open("/dev/urandom")
165+
if err != nil {
166+
for i := range b {
167+
b[i] = byte(time.Now().UnixNano() >> (i * 8))
168+
}
169+
} else {
170+
f.Read(b)
171+
f.Close()
172+
}
173+
const hex = "0123456789abcdef"
174+
out := make([]byte, n*2)
175+
for i, v := range b {
176+
out[i*2] = hex[v>>4]
177+
out[i*2+1] = hex[v&0x0f]
178+
}
179+
return string(out)
180+
}

internal/server/handlers.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,11 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
495495
return
496496
}
497497

498+
if s.shouldInjectWebSearch(eng) {
499+
s.handleChatWithWebSearch(w, r, req, eng, opts, stream)
500+
return
501+
}
502+
498503
if stream {
499504
if requestWantsSSE(r) {
500505
w.Header().Set("Content-Type", "text/event-stream")

0 commit comments

Comments
 (0)