mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
560 lines
16 KiB
Go
560 lines
16 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
progressStyleLegacy = "legacy"
|
|
progressStyleCompact = "compact"
|
|
progressStyleCard = "card"
|
|
|
|
// ProgressCardPayloadPrefix marks a structured payload for card-style progress.
|
|
ProgressCardPayloadPrefix = "__cc_connect_progress_card_v1__:"
|
|
|
|
// Keep a margin below platform hard limit for markdown wrappers/code fences.
|
|
compactProgressMaxChars = maxPlatformMessageLen - 200
|
|
|
|
// Bound each platform progress-card API call so a hung upstream request
|
|
// does not block the whole turn forever.
|
|
compactProgressAPITimeout = 15 * time.Second
|
|
)
|
|
|
|
type ProgressCardState string
|
|
|
|
const (
|
|
ProgressCardStateRunning ProgressCardState = "running"
|
|
ProgressCardStateCompleted ProgressCardState = "completed"
|
|
ProgressCardStateFailed ProgressCardState = "failed"
|
|
)
|
|
|
|
type ProgressCardEntryKind string
|
|
|
|
const (
|
|
ProgressEntryInfo ProgressCardEntryKind = "info"
|
|
ProgressEntryThinking ProgressCardEntryKind = "thinking"
|
|
ProgressEntryToolUse ProgressCardEntryKind = "tool_use"
|
|
ProgressEntryToolResult ProgressCardEntryKind = "tool_result"
|
|
ProgressEntryError ProgressCardEntryKind = "error"
|
|
)
|
|
|
|
type ProgressCardEntry struct {
|
|
Kind ProgressCardEntryKind `json:"kind"`
|
|
Text string `json:"text"`
|
|
Tool string `json:"tool,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
ExitCode *int `json:"exit_code,omitempty"`
|
|
Success *bool `json:"success,omitempty"`
|
|
}
|
|
|
|
// ProgressCardPayload carries structured progress entries for platforms that
|
|
// render custom progress cards.
|
|
type ProgressCardPayload struct {
|
|
Version int `json:"version,omitempty"`
|
|
Agent string `json:"agent,omitempty"`
|
|
Lang string `json:"lang,omitempty"`
|
|
State ProgressCardState `json:"state,omitempty"`
|
|
Entries []string `json:"entries,omitempty"` // legacy fallback
|
|
Items []ProgressCardEntry `json:"items,omitempty"` // ordered typed events
|
|
Truncated bool `json:"truncated"`
|
|
}
|
|
|
|
// BuildProgressCardPayload encodes progress entries into a transport string.
|
|
// This legacy builder keeps compatibility with old callers that only send text.
|
|
func BuildProgressCardPayload(entries []string, truncated bool) string {
|
|
cleaned := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry != "" {
|
|
cleaned = append(cleaned, entry)
|
|
}
|
|
}
|
|
if len(cleaned) == 0 {
|
|
return ""
|
|
}
|
|
payload := ProgressCardPayload{
|
|
Entries: cleaned,
|
|
Truncated: truncated,
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return ProgressCardPayloadPrefix + string(b)
|
|
}
|
|
|
|
// BuildProgressCardPayloadV2 encodes ordered typed progress events.
|
|
func BuildProgressCardPayloadV2(items []ProgressCardEntry, truncated bool, agent string, lang Language, state ProgressCardState) string {
|
|
cleaned := make([]ProgressCardEntry, 0, len(items))
|
|
for _, item := range items {
|
|
text := strings.TrimSpace(item.Text)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
kind := item.Kind
|
|
if kind == "" {
|
|
kind = ProgressEntryInfo
|
|
}
|
|
cleaned = append(cleaned, ProgressCardEntry{
|
|
Kind: kind,
|
|
Text: text,
|
|
Tool: strings.TrimSpace(item.Tool),
|
|
Status: strings.TrimSpace(item.Status),
|
|
ExitCode: item.ExitCode,
|
|
Success: item.Success,
|
|
})
|
|
}
|
|
if len(cleaned) == 0 {
|
|
return ""
|
|
}
|
|
if state == "" {
|
|
state = ProgressCardStateRunning
|
|
}
|
|
payload := ProgressCardPayload{
|
|
Version: 2,
|
|
Agent: strings.TrimSpace(agent),
|
|
Lang: string(lang),
|
|
State: state,
|
|
Items: cleaned,
|
|
Truncated: truncated,
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return ProgressCardPayloadPrefix + string(b)
|
|
}
|
|
|
|
// ParseProgressCardPayload decodes a structured progress payload.
|
|
func ParseProgressCardPayload(content string) (*ProgressCardPayload, bool) {
|
|
if !strings.HasPrefix(content, ProgressCardPayloadPrefix) {
|
|
return nil, false
|
|
}
|
|
raw := strings.TrimPrefix(content, ProgressCardPayloadPrefix)
|
|
var payload ProgressCardPayload
|
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
|
return nil, false
|
|
}
|
|
legacy := make([]string, 0, len(payload.Entries))
|
|
for _, entry := range payload.Entries {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry != "" {
|
|
legacy = append(legacy, entry)
|
|
}
|
|
}
|
|
items := make([]ProgressCardEntry, 0, len(payload.Items))
|
|
for _, item := range payload.Items {
|
|
item.Text = strings.TrimSpace(item.Text)
|
|
item.Tool = strings.TrimSpace(item.Tool)
|
|
item.Status = strings.TrimSpace(item.Status)
|
|
if item.Text == "" {
|
|
continue
|
|
}
|
|
if item.Kind == "" {
|
|
item.Kind = ProgressEntryInfo
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
if len(items) == 0 && len(legacy) > 0 {
|
|
for _, entry := range legacy {
|
|
items = append(items, ProgressCardEntry{
|
|
Kind: inferLegacyEntryKind(entry),
|
|
Text: entry,
|
|
})
|
|
}
|
|
}
|
|
if len(items) == 0 && len(legacy) == 0 {
|
|
return nil, false
|
|
}
|
|
if payload.State == "" {
|
|
payload.State = ProgressCardStateRunning
|
|
}
|
|
payload.Items = items
|
|
payload.Entries = legacy
|
|
if len(payload.Entries) == 0 && len(payload.Items) > 0 {
|
|
payload.Entries = make([]string, 0, len(payload.Items))
|
|
for _, item := range payload.Items {
|
|
payload.Entries = append(payload.Entries, item.Text)
|
|
}
|
|
}
|
|
return &payload, true
|
|
}
|
|
|
|
func inferLegacyEntryKind(entry string) ProgressCardEntryKind {
|
|
switch {
|
|
case strings.HasPrefix(entry, "💭"):
|
|
return ProgressEntryThinking
|
|
case strings.HasPrefix(entry, "🔧"), strings.Contains(entry, "**Tool #"):
|
|
return ProgressEntryToolUse
|
|
case strings.HasPrefix(entry, "🧾"):
|
|
return ProgressEntryToolResult
|
|
case strings.HasPrefix(entry, "❌"):
|
|
return ProgressEntryError
|
|
default:
|
|
return ProgressEntryInfo
|
|
}
|
|
}
|
|
|
|
// compactProgressWriter coalesces intermediate progress (thinking/tool-use)
|
|
// into one editable message for platforms that support message updates.
|
|
type compactProgressWriter struct {
|
|
ctx context.Context
|
|
platform Platform
|
|
replyCtx any
|
|
transform func(string) string
|
|
|
|
starter PreviewStarter
|
|
updater MessageUpdater
|
|
handle any
|
|
|
|
enabled bool
|
|
failed bool
|
|
style string
|
|
usePayload bool
|
|
|
|
content string
|
|
entries []string
|
|
items []ProgressCardEntry
|
|
state ProgressCardState
|
|
agentName string
|
|
lang Language
|
|
truncated bool
|
|
lastSent string
|
|
maxEntries int
|
|
|
|
// Throttle message edits to avoid platform rate limits (e.g. Discord ~5 edits/5s).
|
|
minUpdateInterval time.Duration
|
|
lastUpdateAt time.Time
|
|
}
|
|
|
|
func normalizeProgressStyle(style string) string {
|
|
switch strings.ToLower(strings.TrimSpace(style)) {
|
|
case "", progressStyleLegacy:
|
|
return progressStyleLegacy
|
|
case progressStyleCompact:
|
|
return progressStyleCompact
|
|
case progressStyleCard:
|
|
return progressStyleCard
|
|
default:
|
|
return progressStyleLegacy
|
|
}
|
|
}
|
|
|
|
func progressStyleForPlatform(p Platform) string {
|
|
ps := progressStyleLegacy
|
|
if sp, ok := p.(ProgressStyleProvider); ok {
|
|
ps = normalizeProgressStyle(sp.ProgressStyle())
|
|
}
|
|
return ps
|
|
}
|
|
|
|
type progressStyleHintProvider interface {
|
|
progressStyleHint() string
|
|
}
|
|
|
|
type progressCardPayloadHintProvider interface {
|
|
supportsProgressCardPayloadHint() bool
|
|
}
|
|
|
|
func progressStyleForTarget(p Platform, replyCtx any) string {
|
|
if hint, ok := replyCtx.(progressStyleHintProvider); ok {
|
|
return normalizeProgressStyle(hint.progressStyleHint())
|
|
}
|
|
return progressStyleForPlatform(p)
|
|
}
|
|
|
|
func progressCardPayloadForTarget(p Platform, replyCtx any) bool {
|
|
if hint, ok := replyCtx.(progressCardPayloadHintProvider); ok {
|
|
return hint.supportsProgressCardPayloadHint()
|
|
}
|
|
if cap, ok := p.(ProgressCardPayloadSupport); ok {
|
|
return cap.SupportsProgressCardPayload()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SuppressStandaloneToolResultEvent is true when a platform opts into progress
|
|
// styling (ProgressStyleProvider) but uses legacy mode. In that case tool_use
|
|
// lines are still shown, but a separate chat message for EventToolResult is
|
|
// skipped to avoid duplicate noise (e.g. Codex structured tool results on Feishu).
|
|
// Platforms without ProgressStyleProvider keep showing standalone tool results.
|
|
func SuppressStandaloneToolResultEvent(p Platform) bool {
|
|
_, ok := p.(ProgressStyleProvider)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return progressStyleForPlatform(p) == progressStyleLegacy
|
|
}
|
|
|
|
func newCompactProgressWriter(ctx context.Context, p Platform, replyCtx any, agentName string, lang Language, transform func(string) string) *compactProgressWriter {
|
|
w := &compactProgressWriter{
|
|
ctx: ctx,
|
|
platform: p,
|
|
replyCtx: replyCtx,
|
|
transform: transform,
|
|
style: progressStyleForTarget(p, replyCtx),
|
|
state: ProgressCardStateRunning,
|
|
agentName: normalizeProgressAgentLabel(agentName),
|
|
lang: lang,
|
|
maxEntries: 10,
|
|
}
|
|
if throttler, ok := p.(ProgressUpdateThrottler); ok {
|
|
w.minUpdateInterval = throttler.ProgressUpdateInterval()
|
|
}
|
|
if w.style != progressStyleCompact && w.style != progressStyleCard {
|
|
slog.Debug("progress writer disabled: unsupported style", "platform", p.Name(), "style", w.style)
|
|
return w
|
|
}
|
|
updater, ok := p.(MessageUpdater)
|
|
if !ok {
|
|
slog.Debug("progress writer disabled: platform has no MessageUpdater", "platform", p.Name(), "style", w.style)
|
|
return w
|
|
}
|
|
w.enabled = true
|
|
w.updater = updater
|
|
if starter, ok := p.(PreviewStarter); ok {
|
|
w.starter = starter
|
|
}
|
|
if w.style == progressStyleCard {
|
|
if progressCardPayloadForTarget(p, replyCtx) {
|
|
w.usePayload = true
|
|
}
|
|
}
|
|
slog.Debug("progress writer enabled", "platform", p.Name(), "style", w.style, "use_payload", w.usePayload)
|
|
return w
|
|
}
|
|
|
|
func normalizeProgressAgentLabel(name string) string {
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
case "", "agent":
|
|
return "Agent"
|
|
case "codex":
|
|
return "Codex"
|
|
case "claudecode", "claude-code", "cc":
|
|
return "CC"
|
|
case "gemini":
|
|
return "Gemini"
|
|
case "cursor":
|
|
return "Cursor"
|
|
case "qoder":
|
|
return "Qoder"
|
|
case "iflow":
|
|
return "iFlow"
|
|
case "opencode":
|
|
return "OpenCode"
|
|
case "pi":
|
|
return "PI"
|
|
default:
|
|
n := strings.TrimSpace(name)
|
|
if n == "" {
|
|
return "Agent"
|
|
}
|
|
return strings.ToUpper(n[:1]) + n[1:]
|
|
}
|
|
}
|
|
|
|
// Append appends one progress item and updates the in-place message.
|
|
// Returns true when compact rendering handled this item; false means caller
|
|
// should fallback to legacy per-event send.
|
|
func (w *compactProgressWriter) Append(item string) bool {
|
|
return w.AppendEvent(ProgressEntryInfo, item, "", item)
|
|
}
|
|
|
|
// AppendEvent appends one typed progress event and updates the in-place message.
|
|
// fallback is used for compact/plain rendering when style-specific rendering is not available.
|
|
func (w *compactProgressWriter) AppendEvent(kind ProgressCardEntryKind, text string, tool string, fallback string) bool {
|
|
return w.AppendStructured(ProgressCardEntry{
|
|
Kind: kind,
|
|
Text: text,
|
|
Tool: tool,
|
|
}, fallback)
|
|
}
|
|
|
|
// AppendStructured appends one structured progress event and updates the in-place message.
|
|
func (w *compactProgressWriter) AppendStructured(item ProgressCardEntry, fallback string) bool {
|
|
if !w.enabled || w.failed {
|
|
return false
|
|
}
|
|
text := strings.TrimSpace(item.Text)
|
|
fallback = strings.TrimSpace(fallback)
|
|
if text == "" && fallback == "" {
|
|
return true
|
|
}
|
|
if text == "" {
|
|
text = fallback
|
|
}
|
|
if fallback == "" {
|
|
fallback = text
|
|
}
|
|
switch item.Kind {
|
|
case ProgressEntryThinking, ProgressEntryError, ProgressEntryInfo:
|
|
if w.transform != nil {
|
|
text = w.transform(text)
|
|
fallback = w.transform(fallback)
|
|
}
|
|
}
|
|
kind := item.Kind
|
|
if kind == "" {
|
|
kind = ProgressEntryInfo
|
|
}
|
|
item.Kind = kind
|
|
item.Text = text
|
|
item.Tool = strings.TrimSpace(item.Tool)
|
|
item.Status = strings.TrimSpace(item.Status)
|
|
|
|
switch w.style {
|
|
case progressStyleCard:
|
|
w.items = append(w.items, item)
|
|
w.entries = append(w.entries, fallback)
|
|
truncated := false
|
|
if w.maxEntries > 0 && len(w.items) > w.maxEntries {
|
|
w.items = w.items[len(w.items)-w.maxEntries:]
|
|
if len(w.entries) > w.maxEntries {
|
|
w.entries = w.entries[len(w.entries)-w.maxEntries:]
|
|
}
|
|
truncated = true
|
|
} else if w.maxEntries > 0 && len(w.entries) > w.maxEntries {
|
|
w.entries = w.entries[len(w.entries)-w.maxEntries:]
|
|
truncated = true
|
|
}
|
|
w.truncated = truncated
|
|
if w.usePayload {
|
|
w.content = BuildProgressCardPayloadV2(w.items, w.truncated, w.agentName, w.lang, w.state)
|
|
if w.content == "" {
|
|
slog.Warn("progress writer: failed to build structured payload", "platform", w.platform.Name())
|
|
w.failed = true
|
|
return false
|
|
}
|
|
} else {
|
|
w.content = renderCardProgressMarkdownFallback(w.entries, truncated)
|
|
w.content = trimCompactProgressText(w.content, compactProgressMaxChars)
|
|
}
|
|
default:
|
|
if w.content == "" {
|
|
w.content = fallback
|
|
} else {
|
|
w.content += "\n\n" + fallback
|
|
}
|
|
w.content = trimCompactProgressText(w.content, compactProgressMaxChars)
|
|
}
|
|
|
|
if w.content == w.lastSent {
|
|
return true
|
|
}
|
|
|
|
if w.handle == nil {
|
|
if w.starter != nil {
|
|
callCtx, cancel := w.withAPITimeout()
|
|
handle, err := w.starter.SendPreviewStart(callCtx, w.replyCtx, w.content)
|
|
cancel()
|
|
if err != nil || handle == nil {
|
|
slog.Warn("progress writer: SendPreviewStart failed", "platform", w.platform.Name(), "style", w.style, "error", err, "handle_nil", handle == nil)
|
|
w.failed = true
|
|
return false
|
|
}
|
|
w.handle = handle
|
|
w.lastSent = w.content
|
|
w.lastUpdateAt = time.Now()
|
|
return true
|
|
}
|
|
callCtx, cancel := w.withAPITimeout()
|
|
err := w.platform.Send(callCtx, w.replyCtx, w.content)
|
|
cancel()
|
|
if err != nil {
|
|
slog.Warn("progress writer: initial Send failed", "platform", w.platform.Name(), "style", w.style, "error", err)
|
|
w.failed = true
|
|
return false
|
|
}
|
|
w.handle = w.replyCtx
|
|
w.lastSent = w.content
|
|
w.lastUpdateAt = time.Now()
|
|
return true
|
|
}
|
|
|
|
if w.minUpdateInterval > 0 && time.Since(w.lastUpdateAt) < w.minUpdateInterval {
|
|
return true
|
|
}
|
|
|
|
callCtx, cancel := w.withAPITimeout()
|
|
err := w.updater.UpdateMessage(callCtx, w.handle, w.content)
|
|
cancel()
|
|
if err != nil {
|
|
slog.Warn("progress writer: UpdateMessage failed", "platform", w.platform.Name(), "style", w.style, "error", err)
|
|
w.failed = true
|
|
return false
|
|
}
|
|
w.lastSent = w.content
|
|
w.lastUpdateAt = time.Now()
|
|
return true
|
|
}
|
|
|
|
// Finalize updates card progress state (running/completed/failed) without
|
|
// appending a new progress entry.
|
|
func (w *compactProgressWriter) Finalize(state ProgressCardState) bool {
|
|
if !w.enabled || w.failed || w.style != progressStyleCard || !w.usePayload || w.handle == nil {
|
|
return false
|
|
}
|
|
if state == "" {
|
|
state = ProgressCardStateCompleted
|
|
}
|
|
if w.state == state {
|
|
return true
|
|
}
|
|
w.state = state
|
|
w.content = BuildProgressCardPayloadV2(w.items, w.truncated, w.agentName, w.lang, w.state)
|
|
if w.content == "" || w.content == w.lastSent {
|
|
return w.content != ""
|
|
}
|
|
callCtx, cancel := w.withAPITimeout()
|
|
err := w.updater.UpdateMessage(callCtx, w.handle, w.content)
|
|
cancel()
|
|
if err != nil {
|
|
slog.Warn("progress writer: Finalize UpdateMessage failed", "platform", w.platform.Name(), "style", w.style, "error", err)
|
|
w.failed = true
|
|
return false
|
|
}
|
|
w.lastSent = w.content
|
|
return true
|
|
}
|
|
|
|
func (w *compactProgressWriter) withAPITimeout() (context.Context, context.CancelFunc) {
|
|
if _, hasDeadline := w.ctx.Deadline(); hasDeadline {
|
|
return w.ctx, func() {}
|
|
}
|
|
return context.WithTimeout(w.ctx, compactProgressAPITimeout)
|
|
}
|
|
|
|
func renderCardProgressMarkdownFallback(entries []string, truncated bool) string {
|
|
var b strings.Builder
|
|
b.WriteString("⏳ **Progress**\n")
|
|
if truncated {
|
|
b.WriteString("_Showing latest updates only._\n")
|
|
}
|
|
for i, entry := range entries {
|
|
b.WriteString("\n")
|
|
b.WriteString(strconv.Itoa(i + 1))
|
|
b.WriteString(". ")
|
|
b.WriteString(strings.ReplaceAll(entry, "\n", "\n "))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func trimCompactProgressText(s string, maxRunes int) string {
|
|
if maxRunes <= 0 {
|
|
return s
|
|
}
|
|
s = strings.TrimPrefix(s, "…\n")
|
|
if utf8.RuneCountInString(s) <= maxRunes {
|
|
return s
|
|
}
|
|
rs := []rune(s)
|
|
tail := strings.TrimLeft(string(rs[len(rs)-maxRunes:]), "\n")
|
|
return "…\n" + tail
|
|
}
|