mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
Improve Telegram status and stop commands
This commit is contained in:
parent
caac4915ed
commit
63694afce8
8 changed files with 306 additions and 43 deletions
|
|
@ -362,11 +362,15 @@ func botRun(rawTail []string, version string) error {
|
|||
}
|
||||
}
|
||||
|
||||
b := &telegram.Bot{
|
||||
Client: telegram.NewClient(cfg.BotToken),
|
||||
Agent: agent,
|
||||
Config: cfg,
|
||||
ZotHome: ZotHome(),
|
||||
var b *telegram.Bot
|
||||
b = &telegram.Bot{
|
||||
Client: telegram.NewClient(cfg.BotToken),
|
||||
Agent: agent,
|
||||
Config: cfg,
|
||||
ZotHome: ZotHome(),
|
||||
Provider: resolved.Provider,
|
||||
AuthMethod: resolved.AuthMethod,
|
||||
CWD: args.CWD,
|
||||
Save: func(c telegram.Config) error {
|
||||
return telegram.SaveConfig(ZotHome(), c)
|
||||
},
|
||||
|
|
@ -381,6 +385,9 @@ func botRun(rawTail []string, version string) error {
|
|||
}
|
||||
agent.Client = next.NewClient()
|
||||
agent.Model = next.Model
|
||||
b.Provider = next.Provider
|
||||
b.AuthMethod = next.AuthMethod
|
||||
b.CWD = next.CWD
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2131,6 +2131,7 @@ func (i *Interactive) CancelTurn() {
|
|||
i.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
i.confirmDialog.CancelAll("turn cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3856,6 +3857,35 @@ func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock
|
|||
|
||||
func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
|
||||
|
||||
func (h *telegramHost) Status() string {
|
||||
h.iv.mu.Lock()
|
||||
providerName := h.iv.cfg.Provider
|
||||
model := h.iv.cfg.Model
|
||||
cwd := h.iv.cfg.CWD
|
||||
usage := h.iv.cumUsage
|
||||
subscription := h.iv.cfg.AuthMethod == "oauth"
|
||||
ctxUsed := h.iv.lastCtxInput
|
||||
busy := h.iv.busy
|
||||
queued := len(h.iv.queued)
|
||||
h.iv.mu.Unlock()
|
||||
|
||||
ctxMax := 0
|
||||
if m, err := provider.FindModel(providerName, model); err == nil {
|
||||
ctxMax = m.ContextWindow
|
||||
}
|
||||
return telegram.FormatStatus(telegram.StatusSnapshot{
|
||||
Provider: providerName,
|
||||
Model: model,
|
||||
CWD: cwd,
|
||||
Usage: usage,
|
||||
Subscription: subscription,
|
||||
ContextUsed: ctxUsed,
|
||||
ContextMax: ctxMax,
|
||||
Busy: busy,
|
||||
Queued: queued,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *telegramHost) Notify(level, message string) {
|
||||
h.iv.mu.Lock()
|
||||
switch level {
|
||||
|
|
|
|||
|
|
@ -15,10 +15,13 @@ import (
|
|||
// the agent. It is a long-running goroutine; Run blocks until ctx
|
||||
// cancels.
|
||||
type Bot struct {
|
||||
Client *Client
|
||||
Agent *core.Agent
|
||||
Config Config
|
||||
ZotHome string
|
||||
Client *Client
|
||||
Agent *core.Agent
|
||||
Config Config
|
||||
ZotHome string
|
||||
Provider string
|
||||
AuthMethod string
|
||||
CWD string
|
||||
// Save persists cfg to bot.json. Called whenever the bot pairs
|
||||
// with a new allowed user or advances LastUpdateID.
|
||||
Save func(Config) error
|
||||
|
|
@ -28,10 +31,11 @@ type Bot struct {
|
|||
// agent.ResolveCredentialFull which auto-refreshes expired tokens.
|
||||
RefreshCreds func() error
|
||||
|
||||
mu sync.Mutex
|
||||
busy bool
|
||||
activeCtx context.CancelFunc
|
||||
queue []queuedTurn
|
||||
mu sync.Mutex
|
||||
busy bool
|
||||
activeCtx context.CancelFunc
|
||||
queue []queuedTurn
|
||||
lastCtxInput int
|
||||
}
|
||||
|
||||
// queuedTurn is an inbound DM waiting to become a prompt.
|
||||
|
|
@ -144,21 +148,17 @@ func (b *Bot) handleUpdate(ctx context.Context, u Update) error {
|
|||
switch text {
|
||||
case "/start", "/help":
|
||||
_ = b.Client.SendMessage(ctx, msg.Chat.ID,
|
||||
"send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop.",
|
||||
"send me any message and i'll forward it to zot. attach an image and i'll pass it to the model. commands: /status, /stop, or plain stop.",
|
||||
msg.MessageID)
|
||||
return nil
|
||||
case "/status":
|
||||
return b.sendStatus(ctx, msg.Chat.ID, msg.MessageID)
|
||||
case "/stop":
|
||||
b.mu.Lock()
|
||||
cancel := b.activeCtx
|
||||
b.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
_ = b.Client.SendMessage(ctx, msg.Chat.ID, "cancelled the current turn.", msg.MessageID)
|
||||
} else {
|
||||
_ = b.Client.SendMessage(ctx, msg.Chat.ID, "nothing running.", msg.MessageID)
|
||||
}
|
||||
b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID)
|
||||
return nil
|
||||
}
|
||||
if isStopCommand(text) {
|
||||
b.cancelActiveTurn(ctx, msg.Chat.ID, msg.MessageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +247,12 @@ func (b *Bot) runTurn(ctx context.Context, t queuedTurn) {
|
|||
switch e := ev.(type) {
|
||||
case core.EvTextDelta:
|
||||
replyBuilder.WriteString(e.Delta)
|
||||
case core.EvUsage:
|
||||
b.mu.Lock()
|
||||
if e.Usage.InputTokens > 0 {
|
||||
b.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens
|
||||
}
|
||||
b.mu.Unlock()
|
||||
case core.EvAssistantMessage:
|
||||
var sb strings.Builder
|
||||
for _, c := range e.Message.Content {
|
||||
|
|
@ -308,26 +314,45 @@ func (b *Bot) startTyping(ctx context.Context, chatID int64) func() {
|
|||
return cancel
|
||||
}
|
||||
|
||||
func (b *Bot) cancelActiveTurn(ctx context.Context, chatID int64, replyTo int) {
|
||||
b.mu.Lock()
|
||||
cancel := b.activeCtx
|
||||
b.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
_ = b.Client.SendMessage(ctx, chatID, "cancelled the current turn.", replyTo)
|
||||
} else {
|
||||
_ = b.Client.SendMessage(ctx, chatID, "nothing running.", replyTo)
|
||||
}
|
||||
}
|
||||
|
||||
// sendStatus describes agent state to the Telegram user.
|
||||
func (b *Bot) sendStatus(ctx context.Context, chatID int64, replyTo int) error {
|
||||
b.mu.Lock()
|
||||
busy := b.busy
|
||||
queued := len(b.queue)
|
||||
ctxUsed := b.lastCtxInput
|
||||
providerName := b.Provider
|
||||
authMethod := b.AuthMethod
|
||||
cwd := b.CWD
|
||||
b.mu.Unlock()
|
||||
|
||||
state := "idle"
|
||||
if busy {
|
||||
state = "working"
|
||||
model := b.Agent.Model
|
||||
ctxMax := 0
|
||||
if m, err := provider.FindModel(providerName, model); err == nil {
|
||||
ctxMax = m.ContextWindow
|
||||
}
|
||||
lines := []string{
|
||||
fmt.Sprintf("state: %s", state),
|
||||
fmt.Sprintf("queued: %d", queued),
|
||||
fmt.Sprintf("model: %s", b.Agent.Model),
|
||||
}
|
||||
cost := b.Agent.Cost()
|
||||
lines = append(lines, fmt.Sprintf("cost: $%.4f (%d in / %d out)",
|
||||
cost.CostUSD, cost.InputTokens, cost.OutputTokens))
|
||||
return b.Client.SendMessage(ctx, chatID, strings.Join(lines, "\n"), replyTo)
|
||||
return b.Client.SendMessage(ctx, chatID, FormatStatus(StatusSnapshot{
|
||||
Provider: providerName,
|
||||
Model: model,
|
||||
CWD: cwd,
|
||||
Usage: b.Agent.Cost(),
|
||||
Subscription: authMethod == "oauth",
|
||||
ContextUsed: ctxUsed,
|
||||
ContextMax: ctxMax,
|
||||
Busy: busy,
|
||||
Queued: queued,
|
||||
}), replyTo)
|
||||
}
|
||||
|
||||
// download fetches a file from Telegram and returns bytes + mime.
|
||||
|
|
|
|||
|
|
@ -19,9 +19,13 @@ type Host interface {
|
|||
SubmitOrQueue(prompt string, images []provider.ImageBlock)
|
||||
|
||||
// CancelTurn aborts the active turn (if any). Called when the
|
||||
// paired Telegram user sends /stop.
|
||||
// paired Telegram user sends /stop or plain "stop".
|
||||
CancelTurn()
|
||||
|
||||
// Status returns the current model, usage, context, and cwd summary
|
||||
// shown when the paired Telegram user sends /status.
|
||||
Status() string
|
||||
|
||||
// Notify pushes a one-shot status line into the chat. Used to
|
||||
// surface bridge events ("connected as @bot", "paired with
|
||||
// user X", etc.) in the user's local transcript.
|
||||
|
|
@ -272,9 +276,9 @@ func (b *Bridge) pollLoop(ctx context.Context) {
|
|||
|
||||
// handleUpdate applies pairing, gates on the allowed user, decodes
|
||||
// the interesting bits (text, caption, image attachments), and
|
||||
// forwards them to the Host. Built-in slash commands (/start,
|
||||
// /help, /status, /stop) are handled inline without touching the
|
||||
// agent.
|
||||
// forwards them to the Host. Built-in commands (/start, /help,
|
||||
// /status, /stop, and plain "stop") are handled inline without
|
||||
// touching the agent.
|
||||
func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
|
||||
msg := u.Message
|
||||
if msg == nil {
|
||||
|
|
@ -329,13 +333,11 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
|
|||
switch text {
|
||||
case "/start", "/help":
|
||||
_ = b.Client.SendMessage(ctx, msg.Chat.ID,
|
||||
"mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop.",
|
||||
"mirror is active. send me a message and it'll be forwarded to the zot tui. commands: /status, /stop, or plain stop.",
|
||||
msg.MessageID)
|
||||
return
|
||||
case "/status":
|
||||
_ = b.Client.SendMessage(ctx, msg.Chat.ID,
|
||||
fmt.Sprintf("mirror active. paired user: %d.", paired),
|
||||
msg.MessageID)
|
||||
_ = b.Client.SendMessage(ctx, msg.Chat.ID, b.Host.Status(), msg.MessageID)
|
||||
return
|
||||
case "/stop":
|
||||
b.Host.CancelTurn()
|
||||
|
|
@ -343,6 +345,12 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
|
|||
"cancelled the current turn.", msg.MessageID)
|
||||
return
|
||||
}
|
||||
if isStopCommand(text) {
|
||||
b.Host.CancelTurn()
|
||||
_ = b.Client.SendMessage(ctx, msg.Chat.ID,
|
||||
"cancelled the current turn.", msg.MessageID)
|
||||
return
|
||||
}
|
||||
|
||||
// Build the prompt: text + caption; download image attachments.
|
||||
prompt := strings.TrimSpace(msg.Text)
|
||||
|
|
|
|||
11
internal/agent/modes/telegram/commands.go
Normal file
11
internal/agent/modes/telegram/commands.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package telegram
|
||||
|
||||
import "strings"
|
||||
|
||||
// isStopCommand reports whether text should abort the active turn.
|
||||
// Telegram users often type plain "stop" rather than bot-style
|
||||
// "/stop"; keep this intentionally narrow so normal prompts like
|
||||
// "stop doing X" still go to the agent.
|
||||
func isStopCommand(text string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(text), "stop")
|
||||
}
|
||||
23
internal/agent/modes/telegram/commands_test.go
Normal file
23
internal/agent/modes/telegram/commands_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package telegram
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsStopCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"stop", true},
|
||||
{" STOP ", true},
|
||||
{"Stop", true},
|
||||
{"/stop", false}, // handled by the slash-command switch
|
||||
{"stop please", false},
|
||||
{"please stop", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isStopCommand(tt.text); got != tt.want {
|
||||
t.Fatalf("isStopCommand(%q) = %v, want %v", tt.text, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
120
internal/agent/modes/telegram/status.go
Normal file
120
internal/agent/modes/telegram/status.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/patriceckhart/zot/internal/provider"
|
||||
)
|
||||
|
||||
// StatusSnapshot is the small cross-host state bundle rendered for
|
||||
// Telegram /status replies.
|
||||
type StatusSnapshot struct {
|
||||
Provider string
|
||||
Model string
|
||||
CWD string
|
||||
Usage provider.Usage
|
||||
Subscription bool
|
||||
ContextUsed int
|
||||
ContextMax int
|
||||
Busy bool
|
||||
Queued int
|
||||
}
|
||||
|
||||
// FormatStatus renders the same compact model/usage/cost/context
|
||||
// information shown in the TUI status bar, plus the current directory.
|
||||
func FormatStatus(s StatusSnapshot) string {
|
||||
providerName := strings.TrimSpace(s.Provider)
|
||||
model := strings.TrimSpace(s.Model)
|
||||
if providerName == "" {
|
||||
providerName = "unknown"
|
||||
}
|
||||
if model == "" {
|
||||
model = "unknown"
|
||||
}
|
||||
|
||||
var stats []string
|
||||
if s.Usage.InputTokens > 0 {
|
||||
stats = append(stats, fmt.Sprintf("↑%s", formatTokens(s.Usage.InputTokens)))
|
||||
}
|
||||
if s.Usage.OutputTokens > 0 {
|
||||
stats = append(stats, fmt.Sprintf("↓%s", formatTokens(s.Usage.OutputTokens)))
|
||||
}
|
||||
if s.Usage.CacheReadTokens > 0 {
|
||||
stats = append(stats, fmt.Sprintf("R%s", formatTokens(s.Usage.CacheReadTokens)))
|
||||
}
|
||||
if s.Usage.CacheWriteTokens > 0 {
|
||||
stats = append(stats, fmt.Sprintf("W%s", formatTokens(s.Usage.CacheWriteTokens)))
|
||||
}
|
||||
if s.Usage.CostUSD > 0 || s.Subscription {
|
||||
cost := fmt.Sprintf("$%.3f", s.Usage.CostUSD)
|
||||
if s.Subscription {
|
||||
cost += " (sub)"
|
||||
}
|
||||
stats = append(stats, cost)
|
||||
}
|
||||
if ctx := contextUsage(s.ContextUsed, s.ContextMax); ctx != "" {
|
||||
stats = append(stats, ctx)
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("(%s) %s", providerName, model)
|
||||
if len(stats) > 0 {
|
||||
line += " " + strings.Join(stats, " ")
|
||||
}
|
||||
|
||||
state := "idle"
|
||||
if s.Busy {
|
||||
state = "working"
|
||||
}
|
||||
lines := []string{line, "state: " + state}
|
||||
if s.Queued > 0 {
|
||||
lines = append(lines, fmt.Sprintf("queued: %d", s.Queued))
|
||||
}
|
||||
if cwd := shortenHome(strings.TrimSpace(s.CWD)); cwd != "" {
|
||||
lines = append(lines, "cwd: "+cwd)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func contextUsage(used, max int) string {
|
||||
if max <= 0 {
|
||||
if used <= 0 {
|
||||
return ""
|
||||
}
|
||||
return formatTokens(used)
|
||||
}
|
||||
pct := float64(used) / float64(max) * 100
|
||||
return fmt.Sprintf("%.1f%%/%s", pct, formatTokens(max))
|
||||
}
|
||||
|
||||
func formatTokens(n int) string {
|
||||
switch {
|
||||
case n < 0:
|
||||
return "0"
|
||||
case n < 1000:
|
||||
return fmt.Sprintf("%d", n)
|
||||
case n < 10000:
|
||||
return fmt.Sprintf("%.1fk", float64(n)/1000)
|
||||
case n < 1_000_000:
|
||||
return fmt.Sprintf("%dk", (n+500)/1000)
|
||||
case n < 10_000_000:
|
||||
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
|
||||
default:
|
||||
return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
func shortenHome(path string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return path
|
||||
}
|
||||
if path == home {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(path, home+string(os.PathSeparator)) {
|
||||
return "~" + strings.TrimPrefix(path, home)
|
||||
}
|
||||
return path
|
||||
}
|
||||
39
internal/agent/modes/telegram/status_test.go
Normal file
39
internal/agent/modes/telegram/status_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package telegram
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/patriceckhart/zot/internal/provider"
|
||||
)
|
||||
|
||||
func TestFormatStatusIncludesModelUsageContextAndCWD(t *testing.T) {
|
||||
got := FormatStatus(StatusSnapshot{
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.5",
|
||||
CWD: "/tmp/zot",
|
||||
Usage: provider.Usage{InputTokens: 961_000, OutputTokens: 10_000, CacheReadTokens: 770_000, CostUSD: 2.749},
|
||||
Subscription: true,
|
||||
ContextUsed: 44_800,
|
||||
ContextMax: 400_000,
|
||||
Busy: true,
|
||||
Queued: 2,
|
||||
})
|
||||
|
||||
wants := []string{
|
||||
"(openai) gpt-5.5",
|
||||
"↑961k",
|
||||
"↓10k",
|
||||
"R770k",
|
||||
"$2.749 (sub)",
|
||||
"11.2%/400k",
|
||||
"state: working",
|
||||
"queued: 2",
|
||||
"cwd: /tmp/zot",
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("FormatStatus missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue