Refine session usage tracking
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { For, Show } from "solid-js"
|
import { For, Show, createMemo } from "solid-js"
|
||||||
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
|
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import MessagePart from "./message-part"
|
import MessagePart from "./message-part"
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
@@ -138,6 +139,41 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
isUser()
|
isUser()
|
||||||
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
||||||
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
||||||
|
|
||||||
|
const statChipClass =
|
||||||
|
"inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]"
|
||||||
|
const statLabelClass = "uppercase text-[9px] tracking-wide text-[var(--text-muted)]"
|
||||||
|
const statValueClass = "font-semibold text-[var(--text-primary)]"
|
||||||
|
|
||||||
|
const usageStats = createMemo(() => {
|
||||||
|
const info = props.messageInfo
|
||||||
|
if (!info || info.role !== "assistant" || !info.tokens) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = info.tokens
|
||||||
|
const input = tokens.input ?? 0
|
||||||
|
const output = tokens.output ?? 0
|
||||||
|
const reasoning = tokens.reasoning ?? 0
|
||||||
|
if (input === 0 && output === 0 && reasoning === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
reasoning,
|
||||||
|
cacheRead: tokens.cache?.read ?? 0,
|
||||||
|
cacheWrite: tokens.cache?.write ?? 0,
|
||||||
|
cost: info.cost ?? 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatCostValue = (value: number) => {
|
||||||
|
if (!value) return "$0.00"
|
||||||
|
if (value < 0.01) return `$${value.toPrecision(2)}`
|
||||||
|
return `$${value.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
const agentIdentifier = () => {
|
const agentIdentifier = () => {
|
||||||
if (isUser()) return ""
|
if (isUser()) return ""
|
||||||
@@ -193,6 +229,36 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
Fork
|
Fork
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={usageStats()}>
|
||||||
|
{(usage) => (
|
||||||
|
<div class="flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)]">
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Input</span>
|
||||||
|
<span class={statValueClass}>{formatTokenTotal(usage().input)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Output</span>
|
||||||
|
<span class={statValueClass}>{formatTokenTotal(usage().output)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Reasoning</span>
|
||||||
|
<span class={statValueClass}>{formatTokenTotal(usage().reasoning)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Cache Read</span>
|
||||||
|
<span class={statValueClass}>{formatTokenTotal(usage().cacheRead)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Cache Write</span>
|
||||||
|
<span class={statValueClass}>{formatTokenTotal(usage().cacheWrite)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Cost</span>
|
||||||
|
<span class={statValueClass}>{formatCostValue(usage().cost)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
|
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { sseManager } from "../lib/sse-manager"
|
|||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
|
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
|
||||||
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { setActiveInstanceId } from "../stores/instances"
|
||||||
import { showCommandPalette } from "../stores/command-palette"
|
import { showCommandPalette } from "../stores/command-palette"
|
||||||
|
|
||||||
@@ -72,26 +73,9 @@ function navigateToTaskSession(location: TaskSessionLocation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format tokens like TUI (e.g., "110K", "1.2M")
|
// Format tokens like session sidebar (comma-separated totals)
|
||||||
function formatTokens(tokens: number): string {
|
function formatTokens(tokens: number): string {
|
||||||
if (tokens >= 1000000) {
|
return formatTokenTotal(tokens)
|
||||||
return `${(tokens / 1000000).toFixed(1)}M`
|
|
||||||
} else if (tokens >= 1000) {
|
|
||||||
return `${(tokens / 1000).toFixed(0)}K`
|
|
||||||
}
|
|
||||||
return tokens.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format session info for the session view header
|
|
||||||
function formatSessionInfo(usageTokens: number, contextWindow: number, usagePercent: number | null): string {
|
|
||||||
if (contextWindow > 0) {
|
|
||||||
const windowStr = formatTokens(contextWindow)
|
|
||||||
const usageStr = formatTokens(usageTokens)
|
|
||||||
const percent = usagePercent ?? Math.min(100, Math.max(0, Math.round((usageTokens / contextWindow) * 100)))
|
|
||||||
return `${usageStr} of ${windowStr} (${percent}%)`
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatTokens(usageTokens)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageStreamProps {
|
interface MessageStreamProps {
|
||||||
@@ -206,18 +190,27 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
|
|
||||||
const sessionInfo = createMemo(() =>
|
const sessionInfo = createMemo(() =>
|
||||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||||
tokens: 0,
|
|
||||||
cost: 0,
|
cost: 0,
|
||||||
contextWindow: 0,
|
contextWindow: 0,
|
||||||
isSubscriptionModel: false,
|
isSubscriptionModel: false,
|
||||||
contextUsageTokens: 0,
|
inputTokens: 0,
|
||||||
contextUsagePercent: null,
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: 0,
|
||||||
|
contextAvailableTokens: null,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const formattedSessionInfo = createMemo(() => {
|
const tokenStats = createMemo(() => {
|
||||||
const info = sessionInfo()
|
const info = sessionInfo()
|
||||||
return formatSessionInfo(info.contextUsageTokens, info.contextWindow, info.contextUsagePercent)
|
return {
|
||||||
|
input: info.inputTokens ?? 0,
|
||||||
|
output: info.outputTokens ?? 0,
|
||||||
|
cost: info.cost ?? 0,
|
||||||
|
used: info.actualUsageTokens ?? 0,
|
||||||
|
avail: info.contextAvailableTokens,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
|
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
|
||||||
@@ -552,10 +545,20 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-stream-container">
|
<div class="message-stream-container">
|
||||||
<div class="connection-status">
|
<div class="connection-status">
|
||||||
<div class="connection-status-text connection-status-info flex items-center gap-2 text-sm font-medium">
|
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||||
<span>{formattedSessionInfo()}</span>
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
</div>
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||||
|
<span class="font-semibold text-primary">{formatTokens(sessionInfo().actualUsageTokens ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||||
|
<span class="font-semibold text-primary">
|
||||||
|
{sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="connection-status-text connection-status-shortcut">
|
<div class="connection-status-text connection-status-shortcut">
|
||||||
<div class="connection-status-shortcut-action">
|
<div class="connection-status-shortcut-action">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -7,54 +7,71 @@ interface ContextUsagePanelProps {
|
|||||||
sessionId: string
|
sessionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||||
|
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||||
|
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
|
||||||
|
|
||||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||||
const info = createMemo(
|
const info = createMemo(
|
||||||
() =>
|
() =>
|
||||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||||
tokens: 0,
|
|
||||||
cost: 0,
|
cost: 0,
|
||||||
contextWindow: 0,
|
contextWindow: 0,
|
||||||
isSubscriptionModel: false,
|
isSubscriptionModel: false,
|
||||||
contextUsageTokens: 0,
|
inputTokens: 0,
|
||||||
contextUsagePercent: null,
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: 0,
|
||||||
|
contextAvailableTokens: null,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const tokens = createMemo(() => info().tokens)
|
const inputTokens = createMemo(() => info().inputTokens ?? 0)
|
||||||
const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0)
|
const outputTokens = createMemo(() => info().outputTokens ?? 0)
|
||||||
const contextWindow = createMemo(() => info().contextWindow)
|
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
|
||||||
const contextUsagePercent = createMemo(() => info().contextUsagePercent)
|
const availableTokens = createMemo(() => info().contextAvailableTokens)
|
||||||
|
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
|
||||||
const costLabel = createMemo(() => {
|
const costValue = createMemo(() => {
|
||||||
if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan"
|
const value = info().isSubscriptionModel ? 0 : info().cost
|
||||||
return `$${info().cost.toFixed(2)} spent`
|
return value > 0 ? value : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const formatTokenValue = (value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined) return "--"
|
||||||
|
return formatTokenTotal(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="session-context-panel border-r border-base border-b px-3 py-3">
|
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||||
<div>
|
<div class={headingClass}>Tokens</div>
|
||||||
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Tokens (last call)</div>
|
<div class={chipClass}>
|
||||||
<div class="text-lg font-semibold text-primary">{formatTokenTotal(tokens())}</div>
|
<span class={chipLabelClass}>Input</span>
|
||||||
|
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-primary/70 text-right leading-tight">{costLabel()}</div>
|
<div class={chipClass}>
|
||||||
</div>
|
<span class={chipLabelClass}>Output</span>
|
||||||
<div class="mt-4">
|
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Context window usage</div>
|
|
||||||
<div class="text-sm font-medium text-primary">{contextUsagePercent() !== null ? `${contextUsagePercent()}%` : "--"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-primary/90">
|
<div class={chipClass}>
|
||||||
{contextWindow()
|
<span class={chipLabelClass}>Cost</span>
|
||||||
? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}`
|
<span class="font-semibold text-primary">{costDisplay()}</span>
|
||||||
: "Window size unavailable"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 h-1.5 rounded-full bg-base relative overflow-hidden">
|
|
||||||
<div
|
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||||
class="absolute inset-y-0 left-0 rounded-full bg-accent-primary transition-[width]"
|
<div class={headingClass}>Context</div>
|
||||||
style={{ width: contextUsagePercent() === null ? "0%" : `${contextUsagePercent()}%` }}
|
<div class={chipClass}>
|
||||||
/>
|
<span class={chipLabelClass}>Used</span>
|
||||||
|
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
|
||||||
|
</div>
|
||||||
|
<div class={chipClass}>
|
||||||
|
<span class={chipLabelClass}>Avail</span>
|
||||||
|
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export function formatTokenTotal(value: number): string {
|
export function formatTokenTotal(value: number): string {
|
||||||
|
if (value >= 1_000_000_000) {
|
||||||
|
return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||||
|
}
|
||||||
if (value >= 1_000_000) {
|
if (value >= 1_000_000) {
|
||||||
return `${(value / 1_000_000).toFixed(1)}M`
|
return `${(value / 1_000_000).toFixed(1)}M`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
loading,
|
loading,
|
||||||
setLoading,
|
setLoading,
|
||||||
} from "./session-state"
|
} from "./session-state"
|
||||||
import { getDefaultModel, isModelValid } from "./session-models"
|
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||||
import {
|
import {
|
||||||
computeDisplayParts,
|
computeDisplayParts,
|
||||||
clearSessionIndex,
|
clearSessionIndex,
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
initializePartVersion,
|
initializePartVersion,
|
||||||
normalizeMessagePart,
|
normalizeMessagePart,
|
||||||
rebuildSessionIndex,
|
rebuildSessionIndex,
|
||||||
|
rebuildSessionUsage,
|
||||||
updateSessionInfo,
|
updateSessionInfo,
|
||||||
} from "./session-messages"
|
} from "./session-messages"
|
||||||
|
|
||||||
@@ -212,18 +213,25 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
|||||||
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
||||||
const initialContextWindow = initialModel?.limit?.context ?? 0
|
const initialContextWindow = initialModel?.limit?.context ?? 0
|
||||||
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
||||||
const initialContextPercent = initialContextWindow > 0 ? 0 : null
|
const initialOutputLimit =
|
||||||
|
initialModel?.limit?.output && initialModel.limit.output > 0
|
||||||
|
? initialModel.limit.output
|
||||||
|
: DEFAULT_MODEL_OUTPUT_LIMIT
|
||||||
|
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
|
||||||
|
|
||||||
setSessionInfoByInstance((prev) => {
|
setSessionInfoByInstance((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const instanceInfo = new Map(prev.get(instanceId))
|
const instanceInfo = new Map(prev.get(instanceId))
|
||||||
instanceInfo.set(session.id, {
|
instanceInfo.set(session.id, {
|
||||||
tokens: 0,
|
|
||||||
cost: 0,
|
cost: 0,
|
||||||
contextWindow: initialContextWindow,
|
contextWindow: initialContextWindow,
|
||||||
isSubscriptionModel: Boolean(initialSubscriptionModel),
|
isSubscriptionModel: Boolean(initialSubscriptionModel),
|
||||||
contextUsageTokens: 0,
|
inputTokens: 0,
|
||||||
contextUsagePercent: initialContextPercent,
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: initialOutputLimit,
|
||||||
|
contextAvailableTokens: initialContextAvailable,
|
||||||
})
|
})
|
||||||
next.set(instanceId, instanceInfo)
|
next.set(instanceId, instanceInfo)
|
||||||
return next
|
return next
|
||||||
@@ -310,18 +318,23 @@ async function forkSession(
|
|||||||
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
||||||
const forkContextWindow = forkModel?.limit?.context ?? 0
|
const forkContextWindow = forkModel?.limit?.context ?? 0
|
||||||
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
||||||
const forkContextPercent = forkContextWindow > 0 ? 0 : null
|
const forkOutputLimit =
|
||||||
|
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
|
||||||
|
const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null
|
||||||
|
|
||||||
setSessionInfoByInstance((prev) => {
|
setSessionInfoByInstance((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const instanceInfo = new Map(prev.get(instanceId))
|
const instanceInfo = new Map(prev.get(instanceId))
|
||||||
instanceInfo.set(forkedSession.id, {
|
instanceInfo.set(forkedSession.id, {
|
||||||
tokens: 0,
|
|
||||||
cost: 0,
|
cost: 0,
|
||||||
contextWindow: forkContextWindow,
|
contextWindow: forkContextWindow,
|
||||||
isSubscriptionModel: Boolean(forkSubscriptionModel),
|
isSubscriptionModel: Boolean(forkSubscriptionModel),
|
||||||
contextUsageTokens: 0,
|
inputTokens: 0,
|
||||||
contextUsagePercent: forkContextPercent,
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: forkOutputLimit,
|
||||||
|
contextAvailableTokens: forkContextAvailable,
|
||||||
})
|
})
|
||||||
next.set(instanceId, instanceInfo)
|
next.set(instanceId, instanceInfo)
|
||||||
return next
|
return next
|
||||||
@@ -587,6 +600,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
})
|
})
|
||||||
|
|
||||||
rebuildSessionIndex(instanceId, sessionId, messages)
|
rebuildSessionIndex(instanceId, sessionId, messages)
|
||||||
|
rebuildSessionUsage(instanceId, sessionId, messagesInfo)
|
||||||
|
|
||||||
setMessagesLoaded((prev) => {
|
setMessagesLoaded((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
@@ -595,6 +609,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
next.set(instanceId, loadedSet)
|
next.set(instanceId, loadedSet)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load messages:", error)
|
console.error("Failed to load messages:", error)
|
||||||
throw error
|
throw error
|
||||||
@@ -608,17 +623,17 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSessionInfo(instanceId, sessionId)
|
|
||||||
refreshPermissionsForSession(instanceId, sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
updateSessionInfo(instanceId, sessionId)
|
||||||
|
refreshPermissionsForSession(instanceId, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
fetchAgents,
|
fetchAgents,
|
||||||
fetchProviders,
|
fetchProviders,
|
||||||
|
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
forkSession,
|
forkSession,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
normalizeMessagePart,
|
normalizeMessagePart,
|
||||||
rebuildSessionIndex,
|
rebuildSessionIndex,
|
||||||
updateSessionInfo,
|
updateSessionInfo,
|
||||||
|
updateUsageFromMessageInfo,
|
||||||
} from "./session-messages"
|
} from "./session-messages"
|
||||||
import { loadMessages } from "./session-api"
|
import { loadMessages } from "./session-api"
|
||||||
import { setSessionCompactionState } from "./session-compaction"
|
import { setSessionCompactionState } from "./session-compaction"
|
||||||
@@ -305,6 +306,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.messagesInfo.set(info.id, info)
|
session.messagesInfo.set(info.id, info)
|
||||||
|
updateUsageFromMessageInfo(instanceId, info.sessionID, info)
|
||||||
withSession(instanceId, info.sessionID, () => {
|
withSession(instanceId, info.sessionID, () => {
|
||||||
/* ensure reactivity */
|
/* ensure reactivity */
|
||||||
})
|
})
|
||||||
@@ -314,6 +316,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
||||||
const info = event.properties?.info
|
const info = event.properties?.info
|
||||||
if (!info) return
|
if (!info) return
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Message, MessageDisplayParts } from "../types/message"
|
import type { Message, MessageDisplayParts } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText, type MessageInfo } from "../types/message"
|
||||||
import type { Provider } from "../types/session"
|
import type { Provider } from "../types/session"
|
||||||
|
|
||||||
import { decodeHtmlEntities } from "../lib/markdown"
|
import { decodeHtmlEntities } from "../lib/markdown"
|
||||||
import { providers, sessions, setSessionInfoByInstance } from "./session-state"
|
import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance } from "./session-state"
|
||||||
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
|
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
|
||||||
|
|
||||||
interface SessionIndexCache {
|
interface SessionIndexCache {
|
||||||
@@ -11,7 +11,153 @@ interface SessionIndexCache {
|
|||||||
partIndex: Map<string, Map<string, number>>
|
partIndex: Map<string, Map<string, number>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AssistantUsageEntry {
|
||||||
|
info: MessageInfo
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
reasoningTokens: number
|
||||||
|
combinedTokens: number
|
||||||
|
cost: number
|
||||||
|
hasContextUsage: boolean
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionUsageState {
|
||||||
|
entries: Map<string, AssistantUsageEntry>
|
||||||
|
totalInputTokens: number
|
||||||
|
totalOutputTokens: number
|
||||||
|
totalReasoningTokens: number
|
||||||
|
totalCost: number
|
||||||
|
latestEntry: AssistantUsageEntry | null
|
||||||
|
}
|
||||||
|
|
||||||
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
|
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
|
||||||
|
const sessionUsageStates = new Map<string, Map<string, SessionUsageState>>()
|
||||||
|
|
||||||
|
function createEmptyUsageState(): SessionUsageState {
|
||||||
|
return {
|
||||||
|
entries: new Map(),
|
||||||
|
totalInputTokens: 0,
|
||||||
|
totalOutputTokens: 0,
|
||||||
|
totalReasoningTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
latestEntry: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageInstance(instanceId: string): Map<string, SessionUsageState> {
|
||||||
|
let usageMap = sessionUsageStates.get(instanceId)
|
||||||
|
if (!usageMap) {
|
||||||
|
usageMap = new Map()
|
||||||
|
sessionUsageStates.set(instanceId, usageMap)
|
||||||
|
}
|
||||||
|
return usageMap
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionUsageState(instanceId: string, sessionId: string): SessionUsageState {
|
||||||
|
const usageMap = getUsageInstance(instanceId)
|
||||||
|
let state = usageMap.get(sessionId)
|
||||||
|
if (!state) {
|
||||||
|
state = createEmptyUsageState()
|
||||||
|
usageMap.set(sessionId, state)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeLatestEntry(state: SessionUsageState) {
|
||||||
|
state.latestEntry = null
|
||||||
|
for (const entry of state.entries.values()) {
|
||||||
|
if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) {
|
||||||
|
state.latestEntry = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAssistantUsage(info: MessageInfo): AssistantUsageEntry | null {
|
||||||
|
if (!info || info.role !== "assistant") return null
|
||||||
|
if (!info.tokens) return null
|
||||||
|
const tokens = info.tokens
|
||||||
|
const inputTokens = tokens.input ?? 0
|
||||||
|
const outputTokens = tokens.output ?? 0
|
||||||
|
const reasoningTokens = tokens.reasoning ?? 0
|
||||||
|
if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const cacheReadTokens = tokens.cache?.read ?? 0
|
||||||
|
const cacheWriteTokens = tokens.cache?.write ?? 0
|
||||||
|
const combinedTokens = info.summary
|
||||||
|
? outputTokens
|
||||||
|
: inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
|
||||||
|
const cost = info.cost ?? 0
|
||||||
|
const hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
|
||||||
|
return {
|
||||||
|
info,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
reasoningTokens,
|
||||||
|
combinedTokens,
|
||||||
|
cost,
|
||||||
|
hasContextUsage,
|
||||||
|
timestamp: info.time?.created ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUsageEntry(state: SessionUsageState, messageId: string | undefined) {
|
||||||
|
if (!messageId) return
|
||||||
|
const existing = state.entries.get(messageId)
|
||||||
|
if (!existing) return
|
||||||
|
state.entries.delete(messageId)
|
||||||
|
state.totalInputTokens -= existing.inputTokens
|
||||||
|
state.totalOutputTokens -= existing.outputTokens
|
||||||
|
state.totalReasoningTokens -= existing.reasoningTokens
|
||||||
|
state.totalCost -= existing.cost
|
||||||
|
if (state.latestEntry?.info.id === messageId) {
|
||||||
|
recomputeLatestEntry(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUsageEntry(state: SessionUsageState, entry: AssistantUsageEntry) {
|
||||||
|
state.entries.set(entry.info.id, entry)
|
||||||
|
state.totalInputTokens += entry.inputTokens
|
||||||
|
state.totalOutputTokens += entry.outputTokens
|
||||||
|
state.totalReasoningTokens += entry.reasoningTokens
|
||||||
|
state.totalCost += entry.cost
|
||||||
|
if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) {
|
||||||
|
state.latestEntry = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUsageFromMessageInfo(instanceId: string, sessionId: string, info: MessageInfo) {
|
||||||
|
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||||
|
if (!messageId) return
|
||||||
|
const state = getSessionUsageState(instanceId, sessionId)
|
||||||
|
removeUsageEntry(state, messageId)
|
||||||
|
const entry = extractAssistantUsage(info)
|
||||||
|
if (entry) {
|
||||||
|
addUsageEntry(state, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildSessionUsage(instanceId: string, sessionId: string, messagesInfo: Map<string, MessageInfo>) {
|
||||||
|
const usageMap = getUsageInstance(instanceId)
|
||||||
|
const nextState = createEmptyUsageState()
|
||||||
|
for (const info of messagesInfo.values()) {
|
||||||
|
const entry = extractAssistantUsage(info)
|
||||||
|
if (entry) {
|
||||||
|
addUsageEntry(nextState, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usageMap.set(sessionId, nextState)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionUsage(instanceId: string, sessionId: string) {
|
||||||
|
const usageMap = sessionUsageStates.get(instanceId)
|
||||||
|
if (!usageMap) return
|
||||||
|
usageMap.delete(sessionId)
|
||||||
|
if (usageMap.size === 0) {
|
||||||
|
sessionUsageStates.delete(instanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function decodeTextSegment(segment: any): any {
|
function decodeTextSegment(segment: any): any {
|
||||||
if (typeof segment === "string") {
|
if (typeof segment === "string") {
|
||||||
@@ -163,10 +309,12 @@ function clearSessionIndex(instanceId: string, sessionId: string) {
|
|||||||
sessionIndexes.delete(instanceId)
|
sessionIndexes.delete(instanceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
clearSessionUsage(instanceId, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSessionIndexes(instanceId: string) {
|
function removeSessionIndexes(instanceId: string) {
|
||||||
sessionIndexes.delete(instanceId)
|
sessionIndexes.delete(instanceId)
|
||||||
|
sessionUsageStates.delete(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSessionInfo(instanceId: string, sessionId: string) {
|
function updateSessionInfo(instanceId: string, sessionId: string) {
|
||||||
@@ -176,52 +324,67 @@ function updateSessionInfo(instanceId: string, sessionId: string) {
|
|||||||
const session = instanceSessions.get(sessionId)
|
const session = instanceSessions.get(sessionId)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
let tokens = 0
|
|
||||||
let cost = 0
|
|
||||||
let contextWindow = 0
|
let contextWindow = 0
|
||||||
let isSubscriptionModel = false
|
let isSubscriptionModel = false
|
||||||
let modelID = ""
|
let modelID = ""
|
||||||
let providerID = ""
|
let providerID = ""
|
||||||
let actualUsageTokens = 0
|
let actualUsageTokens = 0
|
||||||
let contextUsagePercent: number | null = null
|
|
||||||
let hasContextUsage = false
|
|
||||||
|
|
||||||
if (session.messagesInfo.size > 0) {
|
const usageState = getSessionUsageState(instanceId, sessionId)
|
||||||
const messageArray = Array.from(session.messagesInfo.values()).reverse()
|
const hasUsageEntries = usageState.entries.size > 0
|
||||||
|
|
||||||
for (const info of messageArray) {
|
let totalInputTokens = hasUsageEntries ? usageState.totalInputTokens : 0
|
||||||
if (info.role === "assistant" && info.tokens) {
|
let totalOutputTokens = hasUsageEntries ? usageState.totalOutputTokens : 0
|
||||||
const usage = info.tokens
|
let totalReasoningTokens = hasUsageEntries ? usageState.totalReasoningTokens : 0
|
||||||
|
let totalCost = hasUsageEntries ? usageState.totalCost : 0
|
||||||
|
|
||||||
if (usage.output > 0) {
|
let latestAssistantInfo: MessageInfo | null = usageState.latestEntry?.info ?? null
|
||||||
const inputTokens = usage.input || 0
|
let latestHasContextUsage = usageState.latestEntry?.hasContextUsage ?? false
|
||||||
const reasoningTokens = usage.reasoning || 0
|
const previousInfo = sessionInfoByInstance().get(instanceId)?.get(sessionId)
|
||||||
const cacheReadTokens = usage.cache?.read || 0
|
let contextAvailableTokens: number | null = null
|
||||||
const cacheWriteTokens = usage.cache?.write || 0
|
let contextAvailableFromPrevious = false
|
||||||
const outputTokens = usage.output || 0
|
|
||||||
|
|
||||||
if (info.summary) {
|
if (latestAssistantInfo) {
|
||||||
tokens = outputTokens
|
const infoAny = latestAssistantInfo as any
|
||||||
} else {
|
actualUsageTokens = usageState.latestEntry?.combinedTokens ?? 0
|
||||||
tokens = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
|
modelID = infoAny.modelID || ""
|
||||||
}
|
providerID = infoAny.providerID || ""
|
||||||
|
} else if (previousInfo) {
|
||||||
|
totalInputTokens = previousInfo.inputTokens
|
||||||
|
totalOutputTokens = previousInfo.outputTokens
|
||||||
|
totalReasoningTokens = previousInfo.reasoningTokens
|
||||||
|
totalCost = previousInfo.cost
|
||||||
|
actualUsageTokens = previousInfo.actualUsageTokens
|
||||||
|
|
||||||
cost = info.cost || 0
|
const previousContextWindow = previousInfo.contextWindow
|
||||||
actualUsageTokens = tokens
|
const previousContextAvailable = previousInfo.contextAvailableTokens ?? null
|
||||||
hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
|
const previousHasContextUsage =
|
||||||
|
previousContextAvailable !== null && previousContextWindow > 0
|
||||||
|
? previousContextAvailable < previousContextWindow
|
||||||
|
: false
|
||||||
|
|
||||||
modelID = info.modelID || ""
|
if (contextWindow === 0) {
|
||||||
providerID = info.providerID || ""
|
contextWindow = previousContextWindow
|
||||||
isSubscriptionModel = cost === 0
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contextWindow !== previousContextWindow) {
|
||||||
|
contextAvailableTokens = null
|
||||||
|
contextAvailableFromPrevious = false
|
||||||
|
latestHasContextUsage = previousHasContextUsage
|
||||||
|
} else {
|
||||||
|
contextAvailableTokens = previousContextAvailable
|
||||||
|
contextAvailableFromPrevious = true
|
||||||
|
latestHasContextUsage = previousHasContextUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubscriptionModel = previousInfo.isSubscriptionModel
|
||||||
}
|
}
|
||||||
|
|
||||||
const instanceProviders = providers().get(instanceId) || []
|
const instanceProviders = providers().get(instanceId) || []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const sessionModel = session.model
|
const sessionModel = session.model
|
||||||
let selectedModel: Provider["models"][number] | undefined
|
let selectedModel: Provider["models"][number] | undefined
|
||||||
|
|
||||||
@@ -252,30 +415,32 @@ function updateSessionInfo(instanceId: string, sessionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
||||||
let contextUsageTokens = 0
|
|
||||||
|
|
||||||
if (hasContextUsage && actualUsageTokens > 0) {
|
if (!contextAvailableFromPrevious) {
|
||||||
contextUsageTokens = actualUsageTokens + outputBudget
|
|
||||||
if (contextWindow > 0) {
|
if (contextWindow > 0) {
|
||||||
const percent = Math.round((contextUsageTokens / contextWindow) * 100)
|
if (latestHasContextUsage && actualUsageTokens > 0) {
|
||||||
contextUsagePercent = Math.min(100, Math.max(0, percent))
|
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
|
||||||
|
} else {
|
||||||
|
contextAvailableTokens = contextWindow
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
contextUsagePercent = null
|
contextAvailableTokens = null
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
contextUsagePercent = contextWindow > 0 ? 0 : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionInfoByInstance((prev) => {
|
setSessionInfoByInstance((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const instanceInfo = new Map(prev.get(instanceId))
|
const instanceInfo = new Map(prev.get(instanceId))
|
||||||
instanceInfo.set(sessionId, {
|
instanceInfo.set(sessionId, {
|
||||||
tokens,
|
cost: totalCost,
|
||||||
cost,
|
|
||||||
contextWindow,
|
contextWindow,
|
||||||
isSubscriptionModel,
|
isSubscriptionModel,
|
||||||
contextUsageTokens,
|
inputTokens: totalInputTokens,
|
||||||
contextUsagePercent,
|
outputTokens: totalOutputTokens,
|
||||||
|
reasoningTokens: totalReasoningTokens,
|
||||||
|
actualUsageTokens,
|
||||||
|
modelOutputLimit,
|
||||||
|
contextAvailableTokens,
|
||||||
})
|
})
|
||||||
next.set(instanceId, instanceInfo)
|
next.set(instanceId, instanceInfo)
|
||||||
return next
|
return next
|
||||||
@@ -290,6 +455,8 @@ export {
|
|||||||
initializePartVersion,
|
initializePartVersion,
|
||||||
normalizeMessagePart,
|
normalizeMessagePart,
|
||||||
rebuildSessionIndex,
|
rebuildSessionIndex,
|
||||||
|
rebuildSessionUsage,
|
||||||
removeSessionIndexes,
|
removeSessionIndexes,
|
||||||
updateSessionInfo,
|
updateSessionInfo,
|
||||||
|
updateUsageFromMessageInfo,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { createSignal } from "solid-js"
|
|||||||
import type { Session, Agent, Provider } from "../types/session"
|
import type { Session, Agent, Provider } from "../types/session"
|
||||||
|
|
||||||
export interface SessionInfo {
|
export interface SessionInfo {
|
||||||
tokens: number
|
|
||||||
cost: number
|
cost: number
|
||||||
contextWindow: number
|
contextWindow: number
|
||||||
isSubscriptionModel: boolean
|
isSubscriptionModel: boolean
|
||||||
contextUsageTokens: number
|
inputTokens: number
|
||||||
contextUsagePercent: number | null
|
outputTokens: number
|
||||||
|
reasoningTokens: number
|
||||||
|
actualUsageTokens: number
|
||||||
|
modelOutputLimit: number
|
||||||
|
contextAvailableTokens: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
||||||
|
|||||||
37
packages/ui/vite.config.js
Normal file
37
packages/ui/vite.config.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from "vite"
|
||||||
|
import solid from "vite-plugin-solid"
|
||||||
|
import { dirname, resolve } from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: "./src/renderer",
|
||||||
|
plugins: [solid()],
|
||||||
|
css: {
|
||||||
|
postcss: "./postcss.config.js",
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ["lucide-solid"],
|
||||||
|
},
|
||||||
|
ssr: {
|
||||||
|
noExternal: ["lucide-solid"],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, "./src/renderer/index.html"),
|
||||||
|
loading: resolve(__dirname, "./src/renderer/loading.html"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user