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
|
|
@ -531,6 +531,7 @@ func (al *AgentLoop) runAgentLoop(
|
|||
SessionKey: sessionKey,
|
||||
Scope: scope,
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,10 +60,14 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
|||
return
|
||||
}
|
||||
|
||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||
msg := bus.OutboundMessage{
|
||||
Context: bus.NewOutboundContext(channel, chatID, ""),
|
||||
Content: response,
|
||||
})
|
||||
}
|
||||
if sessionKey != "" {
|
||||
msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey)
|
||||
}
|
||||
al.bus.PublishOutbound(ctx, msg)
|
||||
logger.InfoCF("agent", "Published outbound response",
|
||||
map[string]any{
|
||||
"channel": channel,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
|
|
@ -210,6 +211,36 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
|
|||
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.
|
||||
// Normally not needed because the cache auto-invalidates via mtime checks,
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
|
|
@ -70,6 +79,7 @@ type OutboundMessage struct {
|
|||
Scope *OutboundScope `json:"scope,omitempty"`
|
||||
Content string `json:"content"`
|
||||
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
|
||||
ContextUsage *ContextUsage `json:"context_usage,omitempty"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
outMsg := newMessage(TypeMessageCreate, map[string]any{
|
||||
payload := map[string]any{
|
||||
PayloadKeyContent: msg.Content,
|
||||
PayloadKeyThought: isThought,
|
||||
})
|
||||
}
|
||||
setContextUsagePayload(payload, msg.ContextUsage)
|
||||
outMsg := newMessage(TypeMessageCreate, payload)
|
||||
|
||||
return nil, c.broadcastToSession(msg.ChatID, outMsg)
|
||||
}
|
||||
|
|
@ -716,3 +718,16 @@ func validateInlineImageDataURL(mediaURL string) error {
|
|||
|
||||
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(),
|
||||
checkCommand(),
|
||||
clearCommand(),
|
||||
contextCommand(),
|
||||
subagentsCommand(),
|
||||
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"
|
||||
)
|
||||
|
||||
// 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
|
||||
// per-request by the agent loop so that per-request state (like session scope)
|
||||
// can coexist with long-lived callbacks (like GetModelInfo).
|
||||
|
|
@ -18,6 +27,7 @@ type Runtime struct {
|
|||
ListSkillNames func() []string
|
||||
GetEnabledChannels func() []string
|
||||
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
|
||||
GetContextStats func() *ContextStats
|
||||
SwitchModel func(value string) (oldModel string, err error)
|
||||
SwitchChannel func(value string) error
|
||||
ClearHistory func() error
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { KeyboardEvent } from "react"
|
|||
import { useTranslation } from "react-i18next"
|
||||
import TextareaAutosize from "react-textarea-autosize"
|
||||
|
||||
import { ContextUsageRing } from "@/components/chat/context-usage-ring"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -10,7 +11,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ChatAttachment } from "@/store/chat"
|
||||
import type { ChatAttachment, ContextUsage } from "@/store/chat"
|
||||
|
||||
export type ChatInputDisabledReason =
|
||||
| "gatewayUnknown"
|
||||
|
|
@ -31,8 +32,10 @@ interface ChatComposerProps {
|
|||
onAddImages: () => void
|
||||
onRemoveAttachment: (index: number) => void
|
||||
onSend: () => void
|
||||
onContextDetail?: () => void
|
||||
inputDisabledReason: ChatInputDisabledReason | null
|
||||
canSend: boolean
|
||||
contextUsage?: ContextUsage
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
|
|
@ -42,8 +45,10 @@ export function ChatComposer({
|
|||
onAddImages,
|
||||
onRemoveAttachment,
|
||||
onSend,
|
||||
onContextDetail,
|
||||
inputDisabledReason,
|
||||
canSend,
|
||||
contextUsage,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation()
|
||||
const canInput = inputDisabledReason === null
|
||||
|
|
@ -121,6 +126,10 @@ export function ChatComposer({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{contextUsage && (
|
||||
<ContextUsageRing usage={contextUsage} onDetailClick={onContextDetail} />
|
||||
)}
|
||||
{canInput ? (
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -148,5 +157,6 @@ export function ChatComposer({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export function ChatPage() {
|
|||
connectionState,
|
||||
isTyping,
|
||||
activeSessionId,
|
||||
contextUsage,
|
||||
sendMessage,
|
||||
switchSession,
|
||||
newChat,
|
||||
|
|
@ -341,8 +342,14 @@ export function ChatPage() {
|
|||
onAddImages={handleAddImages}
|
||||
onRemoveAttachment={handleRemoveAttachment}
|
||||
onSend={handleSend}
|
||||
onContextDetail={() => {
|
||||
if (sendMessage({ content: "/context", attachments: [] })) {
|
||||
setInput("")
|
||||
}
|
||||
}}
|
||||
inputDisabledReason={inputDisabledReason}
|
||||
canSend={canSubmit}
|
||||
contextUsage={contextUsage}
|
||||
/>
|
||||
</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,
|
||||
isTyping: false,
|
||||
hasHydratedActiveSession: true,
|
||||
contextUsage: undefined,
|
||||
})
|
||||
|
||||
if (store.get(gatewayAtom).status === "running") {
|
||||
|
|
@ -415,6 +416,7 @@ export async function newChatSession() {
|
|||
messages: [],
|
||||
isTyping: false,
|
||||
hasHydratedActiveSession: true,
|
||||
contextUsage: undefined,
|
||||
})
|
||||
|
||||
if (store.get(gatewayAtom).status === "running") {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { toast } from "sonner"
|
||||
|
||||
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 {
|
||||
type: string
|
||||
|
|
@ -21,6 +25,24 @@ function hasAssistantKindPayload(payload: Record<string, unknown>): 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(
|
||||
message: PicoMessage,
|
||||
expectedSessionId: string,
|
||||
|
|
@ -36,6 +58,7 @@ export function handlePicoMessage(
|
|||
const content = (payload.content as string) || ""
|
||||
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
|
||||
const kind = parseAssistantMessageKind(payload)
|
||||
const contextUsage = parseContextUsage(payload)
|
||||
const timestamp =
|
||||
message.timestamp !== undefined &&
|
||||
Number.isFinite(Number(message.timestamp))
|
||||
|
|
@ -54,6 +77,7 @@ export function handlePicoMessage(
|
|||
},
|
||||
],
|
||||
isTyping: false,
|
||||
...(contextUsage ? { contextUsage } : {}),
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
|
|||
}
|
||||
|
||||
export function usePicoChat() {
|
||||
const { messages, connectionState, isTyping, activeSessionId } =
|
||||
const { messages, connectionState, isTyping, activeSessionId, contextUsage } =
|
||||
useAtomValue(chatAtom)
|
||||
|
||||
return {
|
||||
|
|
@ -63,6 +63,7 @@ export function usePicoChat() {
|
|||
connectionState,
|
||||
isTyping,
|
||||
activeSessionId,
|
||||
contextUsage,
|
||||
sendMessage: sendChatMessage,
|
||||
switchSession: switchChatSession,
|
||||
newChat: newChatSession,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@
|
|||
},
|
||||
"sendMessage": "Send message",
|
||||
"sendHint": "Press Enter to send\nShift + Enter for a new line",
|
||||
"contextTitle": "Context",
|
||||
"contextDetail": "View Details",
|
||||
"attachImage": "Add images",
|
||||
"removeImage": "Remove image",
|
||||
"uploadedImage": "Uploaded image",
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@
|
|||
},
|
||||
"sendMessage": "发送消息",
|
||||
"sendHint": "按 Enter 发送\nShift + Enter 换行",
|
||||
"contextTitle": "上下文",
|
||||
"contextDetail": "查看详情",
|
||||
"attachImage": "添加图片",
|
||||
"removeImage": "移除图片",
|
||||
"uploadedImage": "已上传图片",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ export interface ChatMessage {
|
|||
attachments?: ChatAttachment[]
|
||||
}
|
||||
|
||||
export interface ContextUsage {
|
||||
used_tokens: number
|
||||
total_tokens: number
|
||||
compress_at_tokens: number
|
||||
used_percent: number
|
||||
}
|
||||
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
|
|
@ -34,6 +41,7 @@ export interface ChatStoreState {
|
|||
isTyping: boolean
|
||||
activeSessionId: string
|
||||
hasHydratedActiveSession: boolean
|
||||
contextUsage?: ContextUsage
|
||||
}
|
||||
|
||||
type ChatStorePatch = Partial<ChatStoreState>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue