Files
picoclaw/pkg/channels/tool_feedback_animator.go
T
lxowalle 451db2f5d8 Feat(channels): unify animated tool feedback across chat channels and Pico (#2622)
* feat(channels): unify tool feedback animation across discord telegram and feishu

* fix(tool-feedback): unify fallback and single-message delivery

* fix(channels): finalize tool feedback in place

* fix ci

* feat: improve tool feedback

* fix review blockers in pico token cache and tool feedback

fix(provider): preserve function thought signatures

fix(feishu): recover tool feedback after edit fallback

* * delete dead code

* fix(pico): clean up tool feedback progress state

* fix ci

* fix(web): preserve tool feedback line breaks in chat

* fix(channels): preserve tool feedback progress state

fix(pico): preserve context usage when finalizing tool feedback

chore: record branch review pass

fix: preserve tool feedback finalization state

fix(web): handle pico history update fallback

* fix ci
2026-04-23 10:35:50 +08:00

241 lines
5.9 KiB
Go

package channels
import (
"context"
"strings"
"sync"
"time"
)
const toolFeedbackAnimationInterval = 3 * time.Second
const initialToolFeedbackAnimationFrame = ""
var toolFeedbackAnimationFrames = []string{"..", "."}
// MaxToolFeedbackAnimationFrameLength returns the largest frame suffix length
// so callers can reserve room before sending messages to length-limited APIs.
func MaxToolFeedbackAnimationFrameLength() int {
maxLen := len([]rune(initialToolFeedbackAnimationFrame))
for _, frame := range toolFeedbackAnimationFrames {
if frameLen := len([]rune(frame)); frameLen > maxLen {
maxLen = frameLen
}
}
return maxLen
}
type toolFeedbackAnimationState struct {
messageID string
baseContent string
stop chan struct{}
done chan struct{}
}
type ToolFeedbackAnimator struct {
mu sync.Mutex
editFn func(ctx context.Context, chatID, messageID, content string) error
entries map[string]*toolFeedbackAnimationState
}
func NewToolFeedbackAnimator(
editFn func(ctx context.Context, chatID, messageID, content string) error,
) *ToolFeedbackAnimator {
return &ToolFeedbackAnimator{
editFn: editFn,
entries: make(map[string]*toolFeedbackAnimationState),
}
}
func (a *ToolFeedbackAnimator) Current(chatID string) (string, bool) {
if a == nil || strings.TrimSpace(chatID) == "" {
return "", false
}
a.mu.Lock()
defer a.mu.Unlock()
entry, ok := a.entries[chatID]
if !ok || strings.TrimSpace(entry.messageID) == "" {
return "", false
}
return entry.messageID, true
}
func (a *ToolFeedbackAnimator) Record(chatID, messageID, content string) {
if a == nil {
return
}
chatID = strings.TrimSpace(chatID)
messageID = strings.TrimSpace(messageID)
content = strings.TrimSpace(content)
if chatID == "" || messageID == "" || content == "" {
return
}
entry := &toolFeedbackAnimationState{
messageID: messageID,
baseContent: content,
stop: make(chan struct{}),
done: make(chan struct{}),
}
var previous *toolFeedbackAnimationState
a.mu.Lock()
if old, ok := a.entries[chatID]; ok {
previous = old
}
a.entries[chatID] = entry
a.mu.Unlock()
stopToolFeedbackAnimation(previous)
go a.run(chatID, entry)
}
func (a *ToolFeedbackAnimator) Clear(chatID string) {
if a == nil || strings.TrimSpace(chatID) == "" {
return
}
entry := a.detach(chatID)
stopToolFeedbackAnimation(entry)
}
func (a *ToolFeedbackAnimator) Take(chatID string) (string, string, bool) {
if a == nil || strings.TrimSpace(chatID) == "" {
return "", "", false
}
entry := a.detach(chatID)
if entry == nil || strings.TrimSpace(entry.messageID) == "" {
return "", "", false
}
stopToolFeedbackAnimation(entry)
return entry.messageID, entry.baseContent, true
}
// Update edits an existing tracked feedback message. If the edit fails, the
// previous feedback state is restored so callers can retry without orphaning
// the old progress message.
func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content string) (string, bool, error) {
if a == nil || a.editFn == nil {
return "", false, nil
}
msgID, baseContent, ok := a.Take(chatID)
if !ok {
return "", false, nil
}
animatedContent := InitialAnimatedToolFeedbackContent(content)
if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil {
a.Record(chatID, msgID, baseContent)
return "", true, err
}
a.Record(chatID, msgID, content)
return msgID, true, nil
}
func (a *ToolFeedbackAnimator) StopAll() {
if a == nil {
return
}
a.mu.Lock()
entries := make([]*toolFeedbackAnimationState, 0, len(a.entries))
for chatID, entry := range a.entries {
entries = append(entries, entry)
delete(a.entries, chatID)
}
a.mu.Unlock()
for _, entry := range entries {
stopToolFeedbackAnimation(entry)
}
}
func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState {
if a == nil || strings.TrimSpace(chatID) == "" {
return nil
}
a.mu.Lock()
defer a.mu.Unlock()
entry := a.entries[chatID]
delete(a.entries, chatID)
return entry
}
func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) {
defer close(entry.done)
ticker := time.NewTicker(toolFeedbackAnimationInterval)
defer ticker.Stop()
frameIdx := 1
for {
select {
case <-entry.stop:
return
case <-ticker.C:
if a.editFn == nil {
continue
}
frame := toolFeedbackAnimationFrames[frameIdx%len(toolFeedbackAnimationFrames)]
content := formatAnimatedToolFeedbackContent(entry.baseContent, frame)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = a.editFn(ctx, chatID, entry.messageID, content)
cancel()
frameIdx++
}
}
}
func InitialAnimatedToolFeedbackContent(baseContent string) string {
return formatAnimatedToolFeedbackContent(baseContent, initialToolFeedbackAnimationFrame)
}
func formatAnimatedToolFeedbackContent(baseContent, frame string) string {
baseContent = strings.TrimSpace(baseContent)
frame = strings.TrimSpace(frame)
if baseContent == "" {
return ""
}
if frame == "" {
return baseContent
}
lineBreak := strings.IndexByte(baseContent, '\n')
if lineBreak < 0 {
return appendToolFeedbackFrame(baseContent, frame)
}
return appendToolFeedbackFrame(baseContent[:lineBreak], frame) + baseContent[lineBreak:]
}
func appendToolFeedbackFrame(firstLine, frame string) string {
firstLine = strings.TrimSpace(firstLine)
frame = strings.TrimSpace(frame)
if firstLine == "" {
return ""
}
if frame == "" {
return firstLine
}
openTick := strings.IndexByte(firstLine, '`')
if openTick >= 0 {
if closeOffset := strings.IndexByte(firstLine[openTick+1:], '`'); closeOffset >= 0 {
closeTick := openTick + 1 + closeOffset
return firstLine[:closeTick] + frame + firstLine[closeTick:]
}
}
return firstLine + frame
}
func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) {
if entry == nil {
return
}
select {
case <-entry.stop:
default:
close(entry.stop)
}
<-entry.done
}