mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-27 10:27:29 +08:00
feat: harden jsonl runtime reliability
This commit is contained in:
@@ -47,7 +47,8 @@ type Server struct {
|
||||
workspacePath string
|
||||
logFilePath string
|
||||
onChat func(ctx context.Context, sessionKey, content string) (string, error)
|
||||
onChatHistory func(sessionKey string) []map[string]interface{}
|
||||
onChatHistory func(query ChatHistoryQuery) []map[string]interface{}
|
||||
onSessionSearch func(query SessionSearchQuery) []map[string]interface{}
|
||||
onConfigAfter func(forceRuntimeReload bool) error
|
||||
onCron func(action string, args map[string]interface{}) (interface{}, error)
|
||||
onToolsCatalog func() interface{}
|
||||
@@ -70,6 +71,22 @@ type channelDraftStore struct {
|
||||
weixinRuntime *channels.WeixinChannel
|
||||
}
|
||||
|
||||
type ChatHistoryQuery struct {
|
||||
Session string
|
||||
Around int
|
||||
Before int
|
||||
After int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type SessionSearchQuery struct {
|
||||
Query string
|
||||
Limit int
|
||||
Kinds []string
|
||||
ExcludeCurrent bool
|
||||
Session string
|
||||
}
|
||||
|
||||
func NewServer(host string, port int, token string) *Server {
|
||||
addr := strings.TrimSpace(host)
|
||||
if addr == "" {
|
||||
@@ -94,9 +111,12 @@ func (s *Server) SetToken(token string) { s.token = strings.TrimSpace(tok
|
||||
func (s *Server) SetChatHandler(fn func(ctx context.Context, sessionKey, content string) (string, error)) {
|
||||
s.onChat = fn
|
||||
}
|
||||
func (s *Server) SetChatHistoryHandler(fn func(sessionKey string) []map[string]interface{}) {
|
||||
func (s *Server) SetChatHistoryHandler(fn func(query ChatHistoryQuery) []map[string]interface{}) {
|
||||
s.onChatHistory = fn
|
||||
}
|
||||
func (s *Server) SetSessionSearchHandler(fn func(query SessionSearchQuery) []map[string]interface{}) {
|
||||
s.onSessionSearch = fn
|
||||
}
|
||||
func (s *Server) SetMessageBus(mb *bus.MessageBus) { s.messageBus = mb }
|
||||
func (s *Server) SetConfigAfterHook(fn func(forceRuntimeReload bool) error) { s.onConfigAfter = fn }
|
||||
func (s *Server) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) {
|
||||
@@ -611,6 +631,7 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
mux.HandleFunc("/api/cron", s.handleWebUICron)
|
||||
mux.HandleFunc("/api/skills", s.handleWebUISkills)
|
||||
mux.HandleFunc("/api/sessions", s.handleWebUISessions)
|
||||
mux.HandleFunc("/api/sessions/search", s.handleWebUISessionSearch)
|
||||
mux.HandleFunc("/api/memory", s.handleWebUIMemory)
|
||||
mux.HandleFunc("/api/workspace_file", s.handleWebUIWorkspaceFile)
|
||||
mux.HandleFunc("/api/workspace_docs", s.handleWebUIWorkspaceDocs)
|
||||
@@ -1516,7 +1537,14 @@ func (s *Server) handleWebUIChatHistory(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "session": session, "messages": []interface{}{}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "session": session, "messages": s.onChatHistory(session)})
|
||||
query := ChatHistoryQuery{
|
||||
Session: session,
|
||||
Around: queryBoundedPositiveInt(r, "around", 0, 1_000_000),
|
||||
Before: queryBoundedPositiveInt(r, "before", 0, 1_000_000),
|
||||
After: queryBoundedPositiveInt(r, "after", 0, 1_000_000),
|
||||
Limit: queryBoundedPositiveInt(r, "limit", 200, 2000),
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "session": session, "messages": s.onChatHistory(query)})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUIChatLive(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -3349,10 +3377,25 @@ func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, ".deleted.") {
|
||||
key := ""
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".meta.json"):
|
||||
key = strings.TrimSuffix(name, ".meta.json")
|
||||
case strings.HasSuffix(name, ".active.jsonl"):
|
||||
key = strings.TrimSuffix(name, ".active.jsonl")
|
||||
case strings.HasSuffix(name, ".jsonl") && !strings.Contains(name, ".deleted."):
|
||||
key = strings.TrimSuffix(name, ".jsonl")
|
||||
if strings.HasSuffix(key, ".active") {
|
||||
key = strings.TrimSuffix(key, ".active")
|
||||
}
|
||||
if idx := strings.LastIndex(key, "."); idx > 0 {
|
||||
if seqPart := key[idx+1:]; len(seqPart) == 4 && regexp.MustCompile(`^\d{4}$`).MatchString(seqPart) {
|
||||
key = key[:idx]
|
||||
}
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(name, ".jsonl")
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
@@ -3376,6 +3419,65 @@ func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "sessions": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUISessionSearch(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if s.onSessionSearch == nil {
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "results": []interface{}{}})
|
||||
return
|
||||
}
|
||||
queryText := strings.TrimSpace(r.URL.Query().Get("query"))
|
||||
if queryText == "" {
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "results": []interface{}{}})
|
||||
return
|
||||
}
|
||||
kinds := splitCSVQueryParam(r.URL.Query()["kinds"])
|
||||
if len(kinds) == 0 {
|
||||
kinds = splitCSVQueryParam([]string{r.URL.Query().Get("kind")})
|
||||
}
|
||||
excludeCurrent := false
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("exclude_current")); raw != "" {
|
||||
excludeCurrent = raw == "1" || strings.EqualFold(raw, "true") || strings.EqualFold(raw, "yes")
|
||||
}
|
||||
query := SessionSearchQuery{
|
||||
Query: queryText,
|
||||
Limit: queryBoundedPositiveInt(r, "limit", 5, 100),
|
||||
Kinds: kinds,
|
||||
ExcludeCurrent: excludeCurrent,
|
||||
Session: strings.TrimSpace(r.URL.Query().Get("session")),
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"query": query.Query,
|
||||
"results": s.onSessionSearch(query),
|
||||
})
|
||||
}
|
||||
|
||||
func splitCSVQueryParam(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isUserFacingSessionKey(key string) bool {
|
||||
k := strings.ToLower(strings.TrimSpace(key))
|
||||
if k == "" {
|
||||
|
||||
@@ -408,6 +408,73 @@ func TestHandleWebUISessionsHidesInternalSessionsByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebUIChatHistorySupportsWindowQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "")
|
||||
var got ChatHistoryQuery
|
||||
srv.SetChatHistoryHandler(func(query ChatHistoryQuery) []map[string]interface{} {
|
||||
got = query
|
||||
return []map[string]interface{}{{"role": "assistant", "content": "ok"}}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/chat/history?session=alpha&after=2&limit=3", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleWebUIChatHistory(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.Session != "alpha" || got.After != 2 || got.Limit != 3 {
|
||||
t.Fatalf("unexpected query: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebUISessionSearchReturnsResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "")
|
||||
var got SessionSearchQuery
|
||||
srv.SetSessionSearchHandler(func(query SessionSearchQuery) []map[string]interface{} {
|
||||
got = query
|
||||
return []map[string]interface{}{
|
||||
{
|
||||
"key": "main",
|
||||
"kind": "main",
|
||||
"updated_at": int64(123),
|
||||
"summary": "deploy notes",
|
||||
"score": 2,
|
||||
"snippets": []map[string]interface{}{{"seq": 3, "content": "deploy timeout"}},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/sessions/search?query=deploy&kinds=main,cron&exclude_current=1&session=current&limit=7", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleWebUISessionSearch(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.Query != "deploy" || got.Session != "current" || !got.ExcludeCurrent || got.Limit != 7 {
|
||||
t.Fatalf("unexpected search query: %+v", got)
|
||||
}
|
||||
if len(got.Kinds) != 2 || got.Kinds[0] != "main" || got.Kinds[1] != "cron" {
|
||||
t.Fatalf("unexpected kinds: %+v", got.Kinds)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
OK bool `json:"ok"`
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if !payload.OK || len(payload.Results) != 1 {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProviderConfigForcesRuntimeReload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user