mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
451db2f5d8
* 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
241 lines
5.9 KiB
Go
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
|
|
}
|