1import { activeProcessingState } from '$lib/stores/chat.svelte';
2import { config } from '$lib/stores/settings.svelte';
3
4export interface LiveProcessingStats {
5 tokensProcessed: number;
6 totalTokens: number;
7 timeMs: number;
8 tokensPerSecond: number;
9 etaSecs?: number;
10}
11
12export interface LiveGenerationStats {
13 tokensGenerated: number;
14 timeMs: number;
15 tokensPerSecond: number;
16}
17
18export interface UseProcessingStateReturn {
19 readonly processingState: ApiProcessingState | null;
20 getProcessingDetails(): string[];
21 getProcessingMessage(): string;
22 getPromptProgressText(): string | null;
23 getLiveProcessingStats(): LiveProcessingStats | null;
24 getLiveGenerationStats(): LiveGenerationStats | null;
25 shouldShowDetails(): boolean;
26 startMonitoring(): void;
27 stopMonitoring(): void;
28}
29
30/**
31 * useProcessingState - Reactive processing state hook
32 *
33 * This hook provides reactive access to the processing state of the server.
34 * It directly reads from chatStore's reactive state and provides
35 * formatted processing details for UI display.
36 *
37 * **Features:**
38 * - Real-time processing state via direct reactive state binding
39 * - Context and output token tracking
40 * - Tokens per second calculation
41 * - Automatic updates when streaming data arrives
42 * - Supports multiple concurrent conversations
43 *
44 * @returns Hook interface with processing state and control methods
45 */
46export function useProcessingState(): UseProcessingStateReturn {
47 let isMonitoring = $state(false);
48 let lastKnownState = $state<ApiProcessingState | null>(null);
49 let lastKnownProcessingStats = $state<LiveProcessingStats | null>(null);
50
51 // Derive processing state reactively from chatStore's direct state
52 const processingState = $derived.by(() => {
53 if (!isMonitoring) {
54 return lastKnownState;
55 }
56 // Read directly from the reactive state export
57 return activeProcessingState();
58 });
59
60 // Track last known state for keepStatsVisible functionality
61 $effect(() => {
62 if (processingState && isMonitoring) {
63 lastKnownState = processingState;
64 }
65 });
66
67 // Track last known processing stats for when promptProgress disappears
68 $effect(() => {
69 if (processingState?.promptProgress) {
70 const { processed, total, time_ms, cache } = processingState.promptProgress;
71 const actualProcessed = processed - cache;
72 const actualTotal = total - cache;
73
74 if (actualProcessed > 0 && time_ms > 0) {
75 const tokensPerSecond = actualProcessed / (time_ms / 1000);
76 lastKnownProcessingStats = {
77 tokensProcessed: actualProcessed,
78 totalTokens: actualTotal,
79 timeMs: time_ms,
80 tokensPerSecond
81 };
82 }
83 }
84 });
85
86 function getETASecs(done: number, total: number, elapsedMs: number): number | undefined {
87 const elapsedSecs = elapsedMs / 1000;
88 const progressETASecs =
89 done === 0 || elapsedSecs < 0.5
90 ? undefined // can be the case for the 0% progress report
91 : elapsedSecs * (total / done - 1);
92 return progressETASecs;
93 }
94
95 function startMonitoring(): void {
96 if (isMonitoring) return;
97 isMonitoring = true;
98 }
99
100 function stopMonitoring(): void {
101 if (!isMonitoring) return;
102 isMonitoring = false;
103
104 // Only clear last known state if keepStatsVisible is disabled
105 const currentConfig = config();
106 if (!currentConfig.keepStatsVisible) {
107 lastKnownState = null;
108 lastKnownProcessingStats = null;
109 }
110 }
111
112 function getProcessingMessage(): string {
113 if (!processingState) {
114 return 'Processing...';
115 }
116
117 switch (processingState.status) {
118 case 'initializing':
119 return 'Initializing...';
120 case 'preparing':
121 if (processingState.progressPercent !== undefined) {
122 return `Processing (${processingState.progressPercent}%)`;
123 }
124 return 'Preparing response...';
125 case 'generating':
126 return '';
127 default:
128 return 'Processing...';
129 }
130 }
131
132 function getProcessingDetails(): string[] {
133 // Use current processing state or fall back to last known state
134 const stateToUse = processingState || lastKnownState;
135 if (!stateToUse) {
136 return [];
137 }
138
139 const details: string[] = [];
140
141 // Always show context info when we have valid data
142 if (stateToUse.contextUsed >= 0 && stateToUse.contextTotal > 0) {
143 const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100);
144
145 details.push(
146 `Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)`
147 );
148 }
149
150 if (stateToUse.outputTokensUsed > 0) {
151 // Handle infinite max_tokens (-1) case
152 if (stateToUse.outputTokensMax <= 0) {
153 details.push(`Output: ${stateToUse.outputTokensUsed}/∞`);
154 } else {
155 const outputPercent = Math.round(
156 (stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100
157 );
158
159 details.push(
160 `Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)`
161 );
162 }
163 }
164
165 if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
166 details.push(`${stateToUse.tokensPerSecond.toFixed(1)} tokens/sec`);
167 }
168
169 if (stateToUse.speculative) {
170 details.push('Speculative decoding enabled');
171 }
172
173 return details;
174 }
175
176 function shouldShowDetails(): boolean {
177 return processingState !== null && processingState.status !== 'idle';
178 }
179
180 /**
181 * Returns a short progress message with percent
182 */
183 function getPromptProgressText(): string | null {
184 if (!processingState?.promptProgress) return null;
185
186 const { processed, total, cache } = processingState.promptProgress;
187
188 const actualProcessed = processed - cache;
189 const actualTotal = total - cache;
190 const percent = Math.round((actualProcessed / actualTotal) * 100);
191 const eta = getETASecs(actualProcessed, actualTotal, processingState.promptProgress.time_ms);
192
193 if (eta !== undefined) {
194 const etaSecs = Math.ceil(eta);
195 return `Processing ${percent}% (ETA: ${etaSecs}s)`;
196 }
197
198 return `Processing ${percent}%`;
199 }
200
201 /**
202 * Returns live processing statistics for display (prompt processing phase)
203 * Returns last known stats when promptProgress becomes unavailable
204 */
205 function getLiveProcessingStats(): LiveProcessingStats | null {
206 if (processingState?.promptProgress) {
207 const { processed, total, time_ms, cache } = processingState.promptProgress;
208
209 const actualProcessed = processed - cache;
210 const actualTotal = total - cache;
211
212 if (actualProcessed > 0 && time_ms > 0) {
213 const tokensPerSecond = actualProcessed / (time_ms / 1000);
214
215 return {
216 tokensProcessed: actualProcessed,
217 totalTokens: actualTotal,
218 timeMs: time_ms,
219 tokensPerSecond
220 };
221 }
222 }
223
224 // Return last known stats if promptProgress is no longer available
225 return lastKnownProcessingStats;
226 }
227
228 /**
229 * Returns live generation statistics for display (token generation phase)
230 */
231 function getLiveGenerationStats(): LiveGenerationStats | null {
232 if (!processingState) return null;
233
234 const { tokensDecoded, tokensPerSecond } = processingState;
235
236 if (tokensDecoded <= 0) return null;
237
238 // Calculate time from tokens and speed
239 const timeMs =
240 tokensPerSecond && tokensPerSecond > 0 ? (tokensDecoded / tokensPerSecond) * 1000 : 0;
241
242 return {
243 tokensGenerated: tokensDecoded,
244 timeMs,
245 tokensPerSecond: tokensPerSecond || 0
246 };
247 }
248
249 return {
250 get processingState() {
251 return processingState;
252 },
253 getProcessingDetails,
254 getProcessingMessage,
255 getPromptProgressText,
256 getLiveProcessingStats,
257 getLiveGenerationStats,
258 shouldShowDetails,
259 startMonitoring,
260 stopMonitoring
261 };
262}