<div class="info my-6 grid gap-4">
{#if displayedModel()}
- <span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
+ <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
{#if isRouter}
<ModelsSelector
currentModel={displayedModel()}
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
<ChatMessageStatistics
+ promptTokens={message.timings.prompt_n}
+ promptMs={message.timings.prompt_ms}
predictedTokens={message.timings.predicted_n}
predictedMs={message.timings.predicted_ms}
/>
{/if}
- </span>
+ </div>
{/if}
{#if config().showToolCalls}
<script lang="ts">
- import { Clock, Gauge, WholeWord } from '@lucide/svelte';
+ import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
import { BadgeChatStatistic } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { ChatMessageStatsView } from '$lib/enums';
interface Props {
predictedTokens: number;
predictedMs: number;
+ promptTokens?: number;
+ promptMs?: number;
}
- let { predictedTokens, predictedMs }: Props = $props();
+ let { predictedTokens, predictedMs, promptTokens, promptMs }: Props = $props();
+
+ let activeView: ChatMessageStatsView = $state(ChatMessageStatsView.GENERATION);
let tokensPerSecond = $derived((predictedTokens / predictedMs) * 1000);
let timeInSeconds = $derived((predictedMs / 1000).toFixed(2));
-</script>
-<BadgeChatStatistic icon={WholeWord} value="{predictedTokens} tokens" />
+ let promptTokensPerSecond = $derived(
+ promptTokens !== undefined && promptMs !== undefined
+ ? (promptTokens / promptMs) * 1000
+ : undefined
+ );
+
+ let promptTimeInSeconds = $derived(
+ promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined
+ );
+
+ let hasPromptStats = $derived(
+ promptTokens !== undefined &&
+ promptMs !== undefined &&
+ promptTokensPerSecond !== undefined &&
+ promptTimeInSeconds !== undefined
+ );
+</script>
-<BadgeChatStatistic icon={Clock} value="{timeInSeconds}s" />
+<div class="inline-flex items-center text-xs text-muted-foreground">
+ <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
+ {#if hasPromptStats}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <button
+ type="button"
+ class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
+ ChatMessageStatsView.READING
+ ? 'bg-background text-foreground shadow-sm'
+ : 'hover:text-foreground'}"
+ onclick={() => (activeView = ChatMessageStatsView.READING)}
+ >
+ <BookOpenText class="h-3 w-3" />
+ <span class="sr-only">Reading</span>
+ </button>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>Reading (prompt processing)</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <button
+ type="button"
+ class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
+ ChatMessageStatsView.GENERATION
+ ? 'bg-background text-foreground shadow-sm'
+ : 'hover:text-foreground'}"
+ onclick={() => (activeView = ChatMessageStatsView.GENERATION)}
+ >
+ <Sparkles class="h-3 w-3" />
+ <span class="sr-only">Generation</span>
+ </button>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>Generation (token output)</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ </div>
-<BadgeChatStatistic icon={Gauge} value="{tokensPerSecond.toFixed(2)} tokens/s" />
+ <div class="flex items-center gap-1 px-2">
+ {#if activeView === ChatMessageStatsView.GENERATION}
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={WholeWord}
+ value="{predictedTokens} tokens"
+ tooltipLabel="Generated tokens"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Clock}
+ value="{timeInSeconds}s"
+ tooltipLabel="Generation time"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Gauge}
+ value="{tokensPerSecond.toFixed(2)} tokens/s"
+ tooltipLabel="Generation speed"
+ />
+ {:else if hasPromptStats}
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={WholeWord}
+ value="{promptTokens} tokens"
+ tooltipLabel="Prompt tokens"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Clock}
+ value="{promptTimeInSeconds}s"
+ tooltipLabel="Prompt processing time"
+ />
+ <BadgeChatStatistic
+ class="bg-transparent"
+ icon={Gauge}
+ value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
+ tooltipLabel="Prompt processing speed"
+ />
+ {/if}
+ </div>
+</div>
<script lang="ts">
import { BadgeInfo } from '$lib/components/app';
+ import * as Tooltip from '$lib/components/ui/tooltip';
import { copyToClipboard } from '$lib/utils';
import type { Component } from 'svelte';
class?: string;
icon: Component;
value: string | number;
+ tooltipLabel?: string;
}
- let { class: className = '', icon: Icon, value }: Props = $props();
+ let { class: className = '', icon: Icon, value, tooltipLabel }: Props = $props();
function handleClick() {
void copyToClipboard(String(value));
}
</script>
-<BadgeInfo class={className} onclick={handleClick}>
- {#snippet icon()}
- <Icon class="h-3 w-3" />
- {/snippet}
+{#if tooltipLabel}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <BadgeInfo class={className} onclick={handleClick}>
+ {#snippet icon()}
+ <Icon class="h-3 w-3" />
+ {/snippet}
- {value}
-</BadgeInfo>
+ {value}
+ </BadgeInfo>
+ </Tooltip.Trigger>
+ <Tooltip.Content>
+ <p>{tooltipLabel}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+{:else}
+ <BadgeInfo class={className} onclick={handleClick}>
+ {#snippet icon()}
+ <Icon class="h-3 w-3" />
+ {/snippet}
+
+ {value}
+ </BadgeInfo>
+{/if}
--- /dev/null
+export enum ChatMessageStatsView {
+ GENERATION = 'generation',
+ READING = 'reading'
+}
export { AttachmentType } from './attachment';
+export { ChatMessageStatsView } from './chat';
+
export {
FileTypeCategory,
FileTypeImage,
updateProcessingStateFromTimings(
timingData: {
prompt_n: number;
+ prompt_ms?: number;
predicted_n: number;
predicted_per_second: number;
cache_n: number;
if (message.role === 'assistant' && message.timings) {
const restoredState = this.parseTimingData({
prompt_n: message.timings.prompt_n || 0,
+ prompt_ms: message.timings.prompt_ms,
predicted_n: message.timings.predicted_n || 0,
predicted_per_second:
message.timings.predicted_n && message.timings.predicted_ms
private parseTimingData(timingData: Record<string, unknown>): ApiProcessingState | null {
const promptTokens = (timingData.prompt_n as number) || 0;
+ const promptMs = (timingData.prompt_ms as number) || undefined;
const predictedTokens = (timingData.predicted_n as number) || 0;
const tokensPerSecond = (timingData.predicted_per_second as number) || 0;
const cacheTokens = (timingData.cache_n as number) || 0;
speculative: false,
progressPercent,
promptTokens,
+ promptMs,
cacheTokens
};
}
this.updateProcessingStateFromTimings(
{
prompt_n: timings?.prompt_n || 0,
+ prompt_ms: timings?.prompt_ms,
predicted_n: timings?.predicted_n || 0,
predicted_per_second: tokensPerSecond,
cache_n: timings?.cache_n || 0,
content: streamingState.response
};
if (lastMessage.thinking?.trim()) updateData.thinking = lastMessage.thinking;
- const lastKnownState = this.getCurrentProcessingStateSync();
+ const lastKnownState = this.getProcessingState(conversationId);
if (lastKnownState) {
updateData.timings = {
prompt_n: lastKnownState.promptTokens || 0,
+ prompt_ms: lastKnownState.promptMs,
predicted_n: lastKnownState.tokensDecoded || 0,
cache_n: lastKnownState.cacheTokens || 0,
predicted_ms:
this.updateProcessingStateFromTimings(
{
prompt_n: timings?.prompt_n || 0,
+ prompt_ms: timings?.prompt_ms,
predicted_n: timings?.predicted_n || 0,
predicted_per_second: tokensPerSecond,
cache_n: timings?.cache_n || 0,
// Progress information from prompt_progress
progressPercent?: number;
promptTokens?: number;
+ promptMs?: number;
cacheTokens?: number;
}