feat(agent): add context usage ring indicator and /context command (#2537)
Add a context window usage indicator to the web chat UI and a /context slash command that works across all channels. Backend: - Add computeContextUsage() estimating history + system + tool tokens - Attach ContextUsage to outbound messages via the pico WebSocket protocol - Add /context command showing context stats as formatted text - Add EstimateSystemTokens() on ContextBuilder for system prompt estimation Frontend: - Add ContextUsageRing component (SVG ring + hover/tap popover) - Show usage percentage, token counts, and compression threshold - Hover on desktop (150ms leave delay), tap on mobile - "View Details" sends /context with 1s cooldown - i18n support (en/zh) for popover labels Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9c3dc0ee3a
commit
6ca7311273
19 changed files with 462 additions and 35 deletions
|
|
@ -527,10 +527,11 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
opts.Dispatch.ChatID(),
|
opts.Dispatch.ChatID(),
|
||||||
opts.Dispatch.ReplyToMessageID(),
|
opts.Dispatch.ReplyToMessageID(),
|
||||||
),
|
),
|
||||||
AgentID: agentID,
|
AgentID: agentID,
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
Content: result.finalContent,
|
Content: result.finalContent,
|
||||||
|
ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,24 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||||
rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) {
|
rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) {
|
||||||
return al.askSideQuestion(ctx, agent, opts, question)
|
return al.askSideQuestion(ctx, agent, opts, question)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rt.GetContextStats = func() *commands.ContextStats {
|
||||||
|
if opts == nil || agent.Sessions == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
usage := computeContextUsage(agent, opts.SessionKey)
|
||||||
|
if usage == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
history := agent.Sessions.GetHistory(opts.SessionKey)
|
||||||
|
return &commands.ContextStats{
|
||||||
|
UsedTokens: usage.UsedTokens,
|
||||||
|
TotalTokens: usage.TotalTokens,
|
||||||
|
CompressAtTokens: usage.CompressAtTokens,
|
||||||
|
UsedPercent: usage.UsedPercent,
|
||||||
|
MessageCount: len(history),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return rt
|
return rt
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,14 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
msg := bus.OutboundMessage{
|
||||||
Context: bus.NewOutboundContext(channel, chatID, ""),
|
Context: bus.NewOutboundContext(channel, chatID, ""),
|
||||||
Content: response,
|
Content: response,
|
||||||
})
|
}
|
||||||
|
if sessionKey != "" {
|
||||||
|
msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey)
|
||||||
|
}
|
||||||
|
al.bus.PublishOutbound(ctx, msg)
|
||||||
logger.InfoCF("agent", "Published outbound response",
|
logger.InfoCF("agent", "Published outbound response",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -210,6 +211,36 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
|
||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EstimateSystemTokens estimates the token count of the full system message
|
||||||
|
// that would be sent to the LLM, mirroring the composition logic in BuildMessages.
|
||||||
|
// It includes: static prompt, dynamic context, active skills, and summary with
|
||||||
|
// wrapping prefixes and separators. This avoids needing all per-request parameters
|
||||||
|
// that BuildMessages requires (media, channel, chatID, sender, etc.).
|
||||||
|
func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []string) int {
|
||||||
|
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
|
// Dynamic context is small and varies per request; use a representative estimate.
|
||||||
|
// Actual buildDynamicContext produces ~200-400 chars of time/runtime/session info.
|
||||||
|
const dynamicContextChars = 300
|
||||||
|
|
||||||
|
totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars
|
||||||
|
|
||||||
|
if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
|
||||||
|
totalChars += utf8.RuneCountInString(skillsText)
|
||||||
|
totalChars += 7 // separator \n\n---\n\n
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary != "" {
|
||||||
|
// Matches the CONTEXT_SUMMARY: prefix added in BuildMessages
|
||||||
|
const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " +
|
||||||
|
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n"
|
||||||
|
totalChars += utf8.RuneCountInString(summaryPrefix) + utf8.RuneCountInString(summary)
|
||||||
|
totalChars += 7 // separator
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalChars * 2 / 5 // same heuristic as tokenizer.EstimateMessageTokens
|
||||||
|
}
|
||||||
|
|
||||||
// InvalidateCache clears the cached system prompt.
|
// InvalidateCache clears the cached system prompt.
|
||||||
// Normally not needed because the cache auto-invalidates via mtime checks,
|
// Normally not needed because the cache auto-invalidates via mtime checks,
|
||||||
// but this is useful for tests or explicit reload commands.
|
// but this is useful for tests or explicit reload commands.
|
||||||
|
|
|
||||||
78
pkg/agent/context_usage.go
Normal file
78
pkg/agent/context_usage.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// computeContextUsage estimates current context window consumption for the
|
||||||
|
// given agent and session. Includes history, system prompt (with dynamic context,
|
||||||
|
// summary, and skills — mirroring BuildMessages composition), and tool definitions.
|
||||||
|
// The output reserve (MaxTokens) is not counted as "used" but reduces the
|
||||||
|
// effective budget, matching isOverContextBudget's compression trigger:
|
||||||
|
//
|
||||||
|
// compress when: history + system + tools + maxTokens > contextWindow
|
||||||
|
// equivalent to: history + system + tools > contextWindow - maxTokens
|
||||||
|
//
|
||||||
|
// Returns nil when the agent or session is unavailable.
|
||||||
|
func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUsage {
|
||||||
|
if agent == nil || agent.Sessions == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
contextWindow := agent.ContextWindow
|
||||||
|
if contextWindow <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// History tokens
|
||||||
|
history := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
historyTokens := 0
|
||||||
|
for _, m := range history {
|
||||||
|
historyTokens += EstimateMessageTokens(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// System message tokens: uses EstimateSystemTokens which mirrors
|
||||||
|
// the full system message composition in BuildMessages (static prompt,
|
||||||
|
// dynamic context, active skills, summary with wrapping prefix).
|
||||||
|
systemTokens := 0
|
||||||
|
if agent.ContextBuilder != nil {
|
||||||
|
summary := agent.Sessions.GetSummary(sessionKey)
|
||||||
|
// Pass nil for active skills: skills are only injected when the user
|
||||||
|
// explicitly activates them via /use, which is rare. Using nil matches
|
||||||
|
// the common case and avoids over-counting all installed skills.
|
||||||
|
systemTokens = agent.ContextBuilder.EstimateSystemTokens(summary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool definition tokens
|
||||||
|
toolTokens := 0
|
||||||
|
if agent.Tools != nil {
|
||||||
|
toolTokens = EstimateToolDefsTokens(agent.Tools.ToProviderDefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used = history + system (includes summary) + tools
|
||||||
|
usedTokens := historyTokens + systemTokens + toolTokens
|
||||||
|
|
||||||
|
// Effective budget = contextWindow minus output reserve (maxTokens)
|
||||||
|
effectiveWindow := contextWindow - agent.MaxTokens
|
||||||
|
if effectiveWindow < 0 {
|
||||||
|
effectiveWindow = contextWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
// compressAt = effectiveWindow: aligns with isOverContextBudget's
|
||||||
|
// proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow).
|
||||||
|
compressAt := effectiveWindow
|
||||||
|
|
||||||
|
usedPercent := 0
|
||||||
|
if compressAt > 0 {
|
||||||
|
usedPercent = usedTokens * 100 / compressAt
|
||||||
|
}
|
||||||
|
if usedPercent > 100 {
|
||||||
|
usedPercent = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return &bus.ContextUsage{
|
||||||
|
UsedTokens: usedTokens,
|
||||||
|
TotalTokens: contextWindow,
|
||||||
|
CompressAtTokens: compressAt,
|
||||||
|
UsedPercent: usedPercent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,15 @@ type OutboundScope struct {
|
||||||
Values map[string]string `json:"values,omitempty"`
|
Values map[string]string `json:"values,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContextUsage describes how much of the model's context window the current
|
||||||
|
// session consumes, and how far it is from triggering compression.
|
||||||
|
type ContextUsage struct {
|
||||||
|
UsedTokens int `json:"used_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"` // model context window
|
||||||
|
CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression
|
||||||
|
UsedPercent int `json:"used_percent"` // 0-100
|
||||||
|
}
|
||||||
|
|
||||||
type OutboundMessage struct {
|
type OutboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
|
|
@ -70,6 +79,7 @@ type OutboundMessage struct {
|
||||||
Scope *OutboundScope `json:"scope,omitempty"`
|
Scope *OutboundScope `json:"scope,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
|
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
|
||||||
|
ContextUsage *ContextUsage `json:"context_usage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaPart describes a single media attachment to send.
|
// MediaPart describes a single media attachment to send.
|
||||||
|
|
|
||||||
|
|
@ -262,10 +262,12 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
||||||
}
|
}
|
||||||
isThought := outboundMessageIsThought(msg)
|
isThought := outboundMessageIsThought(msg)
|
||||||
|
|
||||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
payload := map[string]any{
|
||||||
PayloadKeyContent: msg.Content,
|
PayloadKeyContent: msg.Content,
|
||||||
PayloadKeyThought: isThought,
|
PayloadKeyThought: isThought,
|
||||||
})
|
}
|
||||||
|
setContextUsagePayload(payload, msg.ContextUsage)
|
||||||
|
outMsg := newMessage(TypeMessageCreate, payload)
|
||||||
|
|
||||||
return nil, c.broadcastToSession(msg.ChatID, outMsg)
|
return nil, c.broadcastToSession(msg.ChatID, outMsg)
|
||||||
}
|
}
|
||||||
|
|
@ -716,3 +718,16 @@ func validateInlineImageDataURL(mediaURL string) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setContextUsagePayload adds context window usage stats to a pico payload.
|
||||||
|
func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload["context_usage"] = map[string]any{
|
||||||
|
"used_tokens": u.UsedTokens,
|
||||||
|
"total_tokens": u.TotalTokens,
|
||||||
|
"compress_at_tokens": u.CompressAtTokens,
|
||||||
|
"used_percent": u.UsedPercent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition {
|
||||||
switchCommand(),
|
switchCommand(),
|
||||||
checkCommand(),
|
checkCommand(),
|
||||||
clearCommand(),
|
clearCommand(),
|
||||||
|
contextCommand(),
|
||||||
subagentsCommand(),
|
subagentsCommand(),
|
||||||
reloadCommand(),
|
reloadCommand(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
pkg/commands/cmd_context.go
Normal file
42
pkg/commands/cmd_context.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func contextCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "context",
|
||||||
|
Description: "Show current session context and token usage",
|
||||||
|
Usage: "/context",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.GetContextStats == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
stats := rt.GetContextStats()
|
||||||
|
if stats == nil {
|
||||||
|
return req.Reply("No active session context.")
|
||||||
|
}
|
||||||
|
return req.Reply(formatContextStats(stats))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatContextStats(s *ContextStats) string {
|
||||||
|
remaining := s.CompressAtTokens - s.UsedTokens
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1)
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens",
|
||||||
|
s.MessageCount,
|
||||||
|
s.UsedTokens,
|
||||||
|
s.TotalTokens,
|
||||||
|
usedWindowPercent,
|
||||||
|
s.CompressAtTokens,
|
||||||
|
s.UsedPercent,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,15 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ContextStats describes current session context window usage.
|
||||||
|
type ContextStats struct {
|
||||||
|
UsedTokens int
|
||||||
|
TotalTokens int // model context window
|
||||||
|
CompressAtTokens int // compression threshold
|
||||||
|
UsedPercent int // 0-100
|
||||||
|
MessageCount int
|
||||||
|
}
|
||||||
|
|
||||||
// Runtime provides runtime dependencies to command handlers. It is constructed
|
// Runtime provides runtime dependencies to command handlers. It is constructed
|
||||||
// per-request by the agent loop so that per-request state (like session scope)
|
// per-request by the agent loop so that per-request state (like session scope)
|
||||||
// can coexist with long-lived callbacks (like GetModelInfo).
|
// can coexist with long-lived callbacks (like GetModelInfo).
|
||||||
|
|
@ -18,6 +27,7 @@ type Runtime struct {
|
||||||
ListSkillNames func() []string
|
ListSkillNames func() []string
|
||||||
GetEnabledChannels func() []string
|
GetEnabledChannels func() []string
|
||||||
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
|
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
|
||||||
|
GetContextStats func() *ContextStats
|
||||||
SwitchModel func(value string) (oldModel string, err error)
|
SwitchModel func(value string) (oldModel string, err error)
|
||||||
SwitchChannel func(value string) error
|
SwitchChannel func(value string) error
|
||||||
ClearHistory func() error
|
ClearHistory func() error
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { KeyboardEvent } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import TextareaAutosize from "react-textarea-autosize"
|
import TextareaAutosize from "react-textarea-autosize"
|
||||||
|
|
||||||
|
import { ContextUsageRing } from "@/components/chat/context-usage-ring"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|
@ -10,7 +11,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { ChatAttachment } from "@/store/chat"
|
import type { ChatAttachment, ContextUsage } from "@/store/chat"
|
||||||
|
|
||||||
export type ChatInputDisabledReason =
|
export type ChatInputDisabledReason =
|
||||||
| "gatewayUnknown"
|
| "gatewayUnknown"
|
||||||
|
|
@ -31,8 +32,10 @@ interface ChatComposerProps {
|
||||||
onAddImages: () => void
|
onAddImages: () => void
|
||||||
onRemoveAttachment: (index: number) => void
|
onRemoveAttachment: (index: number) => void
|
||||||
onSend: () => void
|
onSend: () => void
|
||||||
|
onContextDetail?: () => void
|
||||||
inputDisabledReason: ChatInputDisabledReason | null
|
inputDisabledReason: ChatInputDisabledReason | null
|
||||||
canSend: boolean
|
canSend: boolean
|
||||||
|
contextUsage?: ContextUsage
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatComposer({
|
export function ChatComposer({
|
||||||
|
|
@ -42,8 +45,10 @@ export function ChatComposer({
|
||||||
onAddImages,
|
onAddImages,
|
||||||
onRemoveAttachment,
|
onRemoveAttachment,
|
||||||
onSend,
|
onSend,
|
||||||
|
onContextDetail,
|
||||||
inputDisabledReason,
|
inputDisabledReason,
|
||||||
canSend,
|
canSend,
|
||||||
|
contextUsage,
|
||||||
}: ChatComposerProps) {
|
}: ChatComposerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const canInput = inputDisabledReason === null
|
const canInput = inputDisabledReason === null
|
||||||
|
|
@ -121,30 +126,35 @@ export function ChatComposer({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canInput ? (
|
<div className="flex items-center gap-1.5">
|
||||||
<Tooltip delayDuration={700}>
|
{contextUsage && (
|
||||||
<TooltipTrigger asChild>
|
<ContextUsageRing usage={contextUsage} onDetailClick={onContextDetail} />
|
||||||
<span tabIndex={!canSend ? 0 : undefined}>
|
)}
|
||||||
<Button
|
{canInput ? (
|
||||||
type="button"
|
<Tooltip delayDuration={700}>
|
||||||
size="icon"
|
<TooltipTrigger asChild>
|
||||||
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
|
<span tabIndex={!canSend ? 0 : undefined}>
|
||||||
onClick={onSend}
|
<Button
|
||||||
disabled={!canSend}
|
type="button"
|
||||||
aria-label={t("chat.sendMessage")}
|
size="icon"
|
||||||
>
|
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
|
||||||
<IconArrowUp className="size-4" />
|
onClick={onSend}
|
||||||
</Button>
|
disabled={!canSend}
|
||||||
</span>
|
aria-label={t("chat.sendMessage")}
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent
|
<IconArrowUp className="size-4" />
|
||||||
className="border-border/70 bg-muted text-foreground border text-center whitespace-pre-line shadow-lg shadow-black/10 dark:shadow-black/30"
|
</Button>
|
||||||
arrowClassName="bg-muted fill-muted"
|
</span>
|
||||||
>
|
</TooltipTrigger>
|
||||||
{t("chat.sendHint")}
|
<TooltipContent
|
||||||
</TooltipContent>
|
className="border-border/70 bg-muted text-foreground border text-center whitespace-pre-line shadow-lg shadow-black/10 dark:shadow-black/30"
|
||||||
</Tooltip>
|
arrowClassName="bg-muted fill-muted"
|
||||||
) : null}
|
>
|
||||||
|
{t("chat.sendHint")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ export function ChatPage() {
|
||||||
connectionState,
|
connectionState,
|
||||||
isTyping,
|
isTyping,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
|
contextUsage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
switchSession,
|
switchSession,
|
||||||
newChat,
|
newChat,
|
||||||
|
|
@ -341,8 +342,14 @@ export function ChatPage() {
|
||||||
onAddImages={handleAddImages}
|
onAddImages={handleAddImages}
|
||||||
onRemoveAttachment={handleRemoveAttachment}
|
onRemoveAttachment={handleRemoveAttachment}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
onContextDetail={() => {
|
||||||
|
if (sendMessage({ content: "/context", attachments: [] })) {
|
||||||
|
setInput("")
|
||||||
|
}
|
||||||
|
}}
|
||||||
inputDisabledReason={inputDisabledReason}
|
inputDisabledReason={inputDisabledReason}
|
||||||
canSend={canSubmit}
|
canSend={canSubmit}
|
||||||
|
contextUsage={contextUsage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
161
web/frontend/src/components/chat/context-usage-ring.tsx
Normal file
161
web/frontend/src/components/chat/context-usage-ring.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { IconArrowRight } from "@tabler/icons-react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import type { ContextUsage } from "@/store/chat"
|
||||||
|
|
||||||
|
interface ContextUsageRingProps {
|
||||||
|
usage: ContextUsage
|
||||||
|
onDetailClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextUsageRing({
|
||||||
|
usage,
|
||||||
|
onDetailClick,
|
||||||
|
}: ContextUsageRingProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [intent, setIntent] = useState(false) // user wants open
|
||||||
|
const [visible, setVisible] = useState(false) // DOM mounted
|
||||||
|
const [animated, setAnimated] = useState(false) // CSS target state
|
||||||
|
const [cooldown, setCooldown] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||||
|
const hoverIntent = useRef<ReturnType<typeof setTimeout>>(null)
|
||||||
|
const closeTimer = useRef<ReturnType<typeof setTimeout>>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (intent) {
|
||||||
|
// Mount first, animate in on next frame
|
||||||
|
if (closeTimer.current) clearTimeout(closeTimer.current)
|
||||||
|
setVisible(true)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => setAnimated(true))
|
||||||
|
})
|
||||||
|
} else if (visible) {
|
||||||
|
// Animate out, then unmount
|
||||||
|
setAnimated(false)
|
||||||
|
closeTimer.current = setTimeout(() => setVisible(false), 150)
|
||||||
|
}
|
||||||
|
}, [intent, visible])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
if (hoverIntent.current) clearTimeout(hoverIntent.current)
|
||||||
|
if (closeTimer.current) clearTimeout(closeTimer.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const percent = Math.min(usage.used_percent, 100)
|
||||||
|
const radius = 8
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const offset = circumference - (percent / 100) * circumference
|
||||||
|
const barPercent = Math.min(percent, 100)
|
||||||
|
|
||||||
|
const handleDetail = () => {
|
||||||
|
if (cooldown || !onDetailClick) return
|
||||||
|
setCooldown(true)
|
||||||
|
onDetailClick()
|
||||||
|
setIntent(false)
|
||||||
|
timerRef.current = setTimeout(() => setCooldown(false), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: hover to open, mouse leave to close (with small delay)
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (hoverIntent.current) clearTimeout(hoverIntent.current)
|
||||||
|
setIntent(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
hoverIntent.current = setTimeout(() => setIntent(false), 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: tap to toggle (preventDefault suppresses synthetic mouseenter)
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIntent((v) => !v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
className="relative flex h-6 w-6 cursor-pointer items-center justify-center transition-opacity hover:opacity-70"
|
||||||
|
>
|
||||||
|
<svg className="h-6 w-6 -rotate-90" viewBox="0 0 20 20">
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-muted-foreground/30"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-muted-foreground"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-muted-foreground absolute text-[8px] font-medium tabular-nums">
|
||||||
|
{percent}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{visible && (
|
||||||
|
<div
|
||||||
|
className={`bg-popover text-popover-foreground absolute right-0 bottom-full z-50 mb-3 w-[220px] rounded-xl border p-4 shadow-lg transition-all duration-150 ${
|
||||||
|
animated
|
||||||
|
? "scale-100 opacity-100"
|
||||||
|
: "pointer-events-none scale-95 opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="bg-popover absolute -bottom-1.5 right-3 h-3 w-3 rotate-45 border-r border-b" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{t("chat.contextTitle")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{formatTokens(usage.used_tokens)} /{" "}
|
||||||
|
{formatTokens(usage.compress_at_tokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted mt-1.5 h-1.5 w-full overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-violet-500 transition-all"
|
||||||
|
style={{ width: `${barPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDetail}
|
||||||
|
disabled={cooldown}
|
||||||
|
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-violet-600 transition-opacity hover:opacity-70 disabled:opacity-40 dark:text-violet-400"
|
||||||
|
>
|
||||||
|
{t("chat.contextDetail")}
|
||||||
|
<IconArrowRight className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -392,6 +392,7 @@ export async function switchChatSession(sessionId: string) {
|
||||||
messages: historyMessages,
|
messages: historyMessages,
|
||||||
isTyping: false,
|
isTyping: false,
|
||||||
hasHydratedActiveSession: true,
|
hasHydratedActiveSession: true,
|
||||||
|
contextUsage: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (store.get(gatewayAtom).status === "running") {
|
if (store.get(gatewayAtom).status === "running") {
|
||||||
|
|
@ -415,6 +416,7 @@ export async function newChatSession() {
|
||||||
messages: [],
|
messages: [],
|
||||||
isTyping: false,
|
isTyping: false,
|
||||||
hasHydratedActiveSession: true,
|
hasHydratedActiveSession: true,
|
||||||
|
contextUsage: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (store.get(gatewayAtom).status === "running") {
|
if (store.get(gatewayAtom).status === "running") {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
import { normalizeUnixTimestamp } from "@/features/chat/state"
|
||||||
import { type AssistantMessageKind, updateChatStore } from "@/store/chat"
|
import {
|
||||||
|
type AssistantMessageKind,
|
||||||
|
type ContextUsage,
|
||||||
|
updateChatStore,
|
||||||
|
} from "@/store/chat"
|
||||||
|
|
||||||
export interface PicoMessage {
|
export interface PicoMessage {
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -21,6 +25,24 @@ function hasAssistantKindPayload(payload: Record<string, unknown>): boolean {
|
||||||
return typeof payload.thought === "boolean"
|
return typeof payload.thought === "boolean"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseContextUsage(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): ContextUsage | undefined {
|
||||||
|
const raw = payload.context_usage
|
||||||
|
if (!raw || typeof raw !== "object") return undefined
|
||||||
|
const obj = raw as Record<string, unknown>
|
||||||
|
const used = Number(obj.used_tokens)
|
||||||
|
const total = Number(obj.total_tokens)
|
||||||
|
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0)
|
||||||
|
return undefined
|
||||||
|
return {
|
||||||
|
used_tokens: used,
|
||||||
|
total_tokens: total,
|
||||||
|
compress_at_tokens: Number(obj.compress_at_tokens) || 0,
|
||||||
|
used_percent: Number(obj.used_percent) || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function handlePicoMessage(
|
export function handlePicoMessage(
|
||||||
message: PicoMessage,
|
message: PicoMessage,
|
||||||
expectedSessionId: string,
|
expectedSessionId: string,
|
||||||
|
|
@ -36,6 +58,7 @@ export function handlePicoMessage(
|
||||||
const content = (payload.content as string) || ""
|
const content = (payload.content as string) || ""
|
||||||
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
||||||
const kind = parseAssistantMessageKind(payload)
|
const kind = parseAssistantMessageKind(payload)
|
||||||
|
const contextUsage = parseContextUsage(payload)
|
||||||
const timestamp =
|
const timestamp =
|
||||||
message.timestamp !== undefined &&
|
message.timestamp !== undefined &&
|
||||||
Number.isFinite(Number(message.timestamp))
|
Number.isFinite(Number(message.timestamp))
|
||||||
|
|
@ -54,6 +77,7 @@ export function handlePicoMessage(
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isTyping: false,
|
isTyping: false,
|
||||||
|
...(contextUsage ? { contextUsage } : {}),
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePicoChat() {
|
export function usePicoChat() {
|
||||||
const { messages, connectionState, isTyping, activeSessionId } =
|
const { messages, connectionState, isTyping, activeSessionId, contextUsage } =
|
||||||
useAtomValue(chatAtom)
|
useAtomValue(chatAtom)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -63,6 +63,7 @@ export function usePicoChat() {
|
||||||
connectionState,
|
connectionState,
|
||||||
isTyping,
|
isTyping,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
|
contextUsage,
|
||||||
sendMessage: sendChatMessage,
|
sendMessage: sendChatMessage,
|
||||||
switchSession: switchChatSession,
|
switchSession: switchChatSession,
|
||||||
newChat: newChatSession,
|
newChat: newChatSession,
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@
|
||||||
},
|
},
|
||||||
"sendMessage": "Send message",
|
"sendMessage": "Send message",
|
||||||
"sendHint": "Press Enter to send\nShift + Enter for a new line",
|
"sendHint": "Press Enter to send\nShift + Enter for a new line",
|
||||||
|
"contextTitle": "Context",
|
||||||
|
"contextDetail": "View Details",
|
||||||
"attachImage": "Add images",
|
"attachImage": "Add images",
|
||||||
"removeImage": "Remove image",
|
"removeImage": "Remove image",
|
||||||
"uploadedImage": "Uploaded image",
|
"uploadedImage": "Uploaded image",
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@
|
||||||
},
|
},
|
||||||
"sendMessage": "发送消息",
|
"sendMessage": "发送消息",
|
||||||
"sendHint": "按 Enter 发送\nShift + Enter 换行",
|
"sendHint": "按 Enter 发送\nShift + Enter 换行",
|
||||||
|
"contextTitle": "上下文",
|
||||||
|
"contextDetail": "查看详情",
|
||||||
"attachImage": "添加图片",
|
"attachImage": "添加图片",
|
||||||
"removeImage": "移除图片",
|
"removeImage": "移除图片",
|
||||||
"uploadedImage": "已上传图片",
|
"uploadedImage": "已上传图片",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ export interface ChatMessage {
|
||||||
attachments?: ChatAttachment[]
|
attachments?: ChatAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextUsage {
|
||||||
|
used_tokens: number
|
||||||
|
total_tokens: number
|
||||||
|
compress_at_tokens: number
|
||||||
|
used_percent: number
|
||||||
|
}
|
||||||
|
|
||||||
export type ConnectionState =
|
export type ConnectionState =
|
||||||
| "disconnected"
|
| "disconnected"
|
||||||
| "connecting"
|
| "connecting"
|
||||||
|
|
@ -34,6 +41,7 @@ export interface ChatStoreState {
|
||||||
isTyping: boolean
|
isTyping: boolean
|
||||||
activeSessionId: string
|
activeSessionId: string
|
||||||
hasHydratedActiveSession: boolean
|
hasHydratedActiveSession: boolean
|
||||||
|
contextUsage?: ContextUsage
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatStorePatch = Partial<ChatStoreState>
|
type ChatStorePatch = Partial<ChatStoreState>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue