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:
Guoguo 2026-04-21 16:30:02 +08:00 committed by GitHub
parent 9c3dc0ee3a
commit 6ca7311273
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 462 additions and 35 deletions

View file

@ -531,6 +531,7 @@ func (al *AgentLoop) runAgentLoop(
SessionKey: sessionKey, SessionKey: sessionKey,
Scope: scope, Scope: scope,
Content: result.finalContent, Content: result.finalContent,
ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey),
}) })
} }

View file

@ -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
} }

View file

@ -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,

View file

@ -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.

View 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,
}
}

View file

@ -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.

View file

@ -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,
}
}

View file

@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition {
switchCommand(), switchCommand(),
checkCommand(), checkCommand(),
clearCommand(), clearCommand(),
contextCommand(),
subagentsCommand(), subagentsCommand(),
reloadCommand(), reloadCommand(),
} }

View 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,
)
}

View file

@ -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

View file

@ -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,6 +126,10 @@ export function ChatComposer({
</Button> </Button>
</div> </div>
<div className="flex items-center gap-1.5">
{contextUsage && (
<ContextUsageRing usage={contextUsage} onDetailClick={onContextDetail} />
)}
{canInput ? ( {canInput ? (
<Tooltip delayDuration={700}> <Tooltip delayDuration={700}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -148,5 +157,6 @@ export function ChatComposer({
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }

View file

@ -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>
) )

View 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>
)
}

View file

@ -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") {

View file

@ -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
} }

View file

@ -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,

View file

@ -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",

View file

@ -75,6 +75,8 @@
}, },
"sendMessage": "发送消息", "sendMessage": "发送消息",
"sendHint": "按 Enter 发送\nShift + Enter 换行", "sendHint": "按 Enter 发送\nShift + Enter 换行",
"contextTitle": "上下文",
"contextDetail": "查看详情",
"attachImage": "添加图片", "attachImage": "添加图片",
"removeImage": "移除图片", "removeImage": "移除图片",
"uploadedImage": "已上传图片", "uploadedImage": "已上传图片",

View file

@ -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>