From 07c0fead8446d54bd3161b0eef3f2b2b13a81eb8 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 27 Apr 2026 00:01:20 +0800 Subject: [PATCH 1/2] feat(web): add message edit retry and copy actions --- internal/session/session.go | 91 ++++++++++++++++++++ internal/web/server.go | 64 ++++++++++++++ web/src/App.vue | 10 ++- web/src/components/ChatMessage.vue | 131 +++++++++++++++++++++++++++-- web/src/composables/api.ts | 5 ++ web/src/stores/chat.ts | 75 +++++++++++++++++ 6 files changed, 367 insertions(+), 9 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index 16441ab..5090060 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -266,6 +266,97 @@ func (r *Recorder) RecordCompact(summary string, compactedN int) { _ = r.writeEntry(Entry{Type: EntryCompact, Summary: summary, CompactedN: compactedN}) } +// TruncateAtUserMessage rewrites the session file keeping only entries that +// appear before the (beforeCount)th user message (0-indexed). +// If beforeCount == 0, the file is truncated to the session_start header only. +// The recorder is reset to append mode on the (now shorter) file. +// This preserves the session UUID and index entry — no new session is created. +func (r *Recorder) TruncateAtUserMessage(beforeCount int) error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.agentID != "" { + return fmt.Errorf("TruncateAtUserMessage not supported for teammate recorders") + } + + // Close current file handle before rewriting. + if r.file != nil { + _ = r.file.Close() + r.file = nil + } + + dir, err := config.SessionsDir() + if err != nil { + return err + } + filePath := filepath.Join(dir, r.uuid+".json") + + // Load existing entries. + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + // Nothing to truncate. + return nil + } + return fmt.Errorf("read session file: %w", err) + } + + // Collect entries to keep: session_start always, then everything before + // the beforeCount-th user entry. When beforeCount == 0 we keep nothing + // except the session_start header. + var keep []string + userCount := 0 + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var e Entry + if err := json.Unmarshal([]byte(line), &e); err != nil { + continue + } + // Always keep the session_start header. + if e.Type == EntrySessionStart { + keep = append(keep, line) + continue + } + // Stop as soon as we reach the Nth user message. + if e.Type == EntryUser { + if userCount >= beforeCount { + break + } + userCount++ + } + // For beforeCount == 0 we must not keep any non-session_start entry. + if beforeCount == 0 { + break + } + keep = append(keep, line) + } + + // Atomically rewrite the file. + tmpPath := filePath + ".tmp" + content := strings.Join(keep, "\n") + if len(keep) > 0 { + content += "\n" + } + if err := os.WriteFile(tmpPath, []byte(content), 0644); err != nil { + return fmt.Errorf("write truncated session: %w", err) + } + if err := os.Rename(tmpPath, filePath); err != nil { + return fmt.Errorf("rename truncated session: %w", err) + } + + // Reopen for append so subsequent writes go to the correct file. + f, err := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("reopen session for append: %w", err) + } + r.file = f + r.resuming = true + return nil +} + // Close flushes and closes the underlying file. Safe to call multiple times. // If no messages were ever recorded the file is never created. func (r *Recorder) Close() { diff --git a/internal/web/server.go b/internal/web/server.go index fc6f0c9..945e5d3 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -250,6 +250,9 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("POST /api/providers", s.handleAddProvider) mux.HandleFunc("DELETE /api/providers/{id}", s.handleDeleteProvider) + // History management. + mux.HandleFunc("POST /api/history/truncate", s.handleTruncateHistory) + // Model state API — favorites & recent. mux.HandleFunc("GET /api/model-state", s.handleGetModelState) mux.HandleFunc("POST /api/model-state/favorite", s.handleToggleFavorite) @@ -621,6 +624,67 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } +func (s *Server) handleTruncateHistory(w http.ResponseWriter, r *http.Request) { + var req struct { + // BeforeUserMessage: keep all history entries that come before the + // Nth user message (0-indexed). Everything from that user message + // onward is discarded. Pass 0 to clear everything. + BeforeUserMessage int `json:"before_user_message"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + + s.mu.Lock() + + // Determine the truncation index in s.history. + truncAt := 0 + if req.BeforeUserMessage > 0 { + userCount := 0 + truncAt = len(s.history) // default: keep all + for i, msg := range s.history { + if msg.Role == schema.User { + if userCount == req.BeforeUserMessage { + truncAt = i + break + } + userCount++ + } + } + } + + // Truncate in-memory history. + if truncAt == 0 { + s.history = nil + } else { + s.history = s.history[:truncAt] + } + + // Capture the recorder reference while holding the lock, then release + // before doing file I/O so we don't block other goroutines (e.g. the + // SSE forwarder that also acquires s.mu). + rec := s.recorder + sessionID := "" + if rec != nil { + sessionID = rec.UUID() + } + s.mu.Unlock() + + // Rewrite the session file in-place (same UUID, same index entry) so the + // sidebar keeps the existing conversation and only the edited tail is removed. + if rec != nil { + if err := rec.TruncateAtUserMessage(req.BeforeUserMessage); err != nil { + config.Logger().Printf("[truncate] rewrite session file failed: %v", err) + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "session_id": sessionID, + }) +} + func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { // Parse optional request body for resume session ID. var req struct { diff --git a/web/src/App.vue b/web/src/App.vue index e3d1994..7b92a68 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -301,7 +301,15 @@ function startResize(e: MouseEvent) {
diff --git a/web/src/components/ChatMessage.vue b/web/src/components/ChatMessage.vue index 026f923..af94e63 100644 --- a/web/src/components/ChatMessage.vue +++ b/web/src/components/ChatMessage.vue @@ -1,15 +1,62 @@