ui: Linting & Formatting scripts (#26819)

This commit is contained in:
Aleksander Grygier
2026-08-10 08:38:37 +02:00
committed by GitHub
parent 1e396e72a8
commit 92d1bb0c99
538 changed files with 8806 additions and 6036 deletions
@@ -40,28 +40,30 @@ export function useAttachmentMenu(
close: () => void
): UseAttachmentMenuReturn {
const modalityFlags = $derived(getFlags());
const callbacks = $derived.by(() => {
const cbs = getCallbacks();
const wrap = (fn?: () => void) => () => {
close();
fn?.();
};
return {
[AttachmentAction.FILE_UPLOAD]: wrap(cbs.onFileUpload),
[AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick),
[AttachmentAction.MCP_PROMPT_CLICK]: wrap(cbs.onMcpPromptClick),
[AttachmentAction.MCP_RESOURCES_CLICK]: wrap(cbs.onMcpResourcesClick)
[AttachmentAction.MCP_RESOURCES_CLICK]: wrap(cbs.onMcpResourcesClick),
[AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick)
};
});
function isItemEnabled(enabledWhen: string | undefined): boolean {
if (!enabledWhen || enabledWhen === 'always') return true;
return !!modalityFlags[enabledWhen as keyof AttachmentModalityFlags];
}
function isItemVisible(visibleWhen: string | undefined): boolean {
if (!visibleWhen) return true;
return !!modalityFlags[visibleWhen as keyof AttachmentModalityFlags];
}
@@ -75,8 +77,8 @@ export function useAttachmentMenu(
get callbacks() {
return callbacks;
},
getSystemMessageTooltip,
isItemEnabled,
isItemVisible,
getSystemMessageTooltip
isItemVisible
};
}
@@ -51,7 +51,9 @@ export class AutoScrollController {
*/
setDisabled(disabled: boolean): void {
if (this._disabled === disabled) return;
this._disabled = disabled;
if (disabled) {
this._autoScrollEnabled = false;
this.stopInterval();
@@ -67,7 +69,7 @@ export class AutoScrollController {
handleScroll(): void {
if (this._disabled || !this._container) return;
const { scrollTop, scrollHeight, clientHeight } = this._container;
const { clientHeight, scrollHeight, scrollTop } = this._container;
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
const isScrollingUp = scrollTop < this._lastScrollTop;
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
@@ -88,6 +90,7 @@ export class AutoScrollController {
*/
scrollToBottom(): void {
if (this._disabled || !this._container) return;
this._container.scrollTop = this._container.scrollHeight;
}
@@ -96,6 +99,7 @@ export class AutoScrollController {
*/
enable(): void {
if (this._disabled) return;
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
@@ -106,6 +110,7 @@ export class AutoScrollController {
resetScrollState(): void {
this._userScrolledUp = false;
this._autoScrollEnabled = !this._disabled;
if (this._container) {
this._lastScrollTop = this._container.scrollTop;
}
@@ -139,6 +144,7 @@ export class AutoScrollController {
updateInterval(isStreaming: boolean): void {
if (this._disabled) {
this.stopInterval();
return;
}
@@ -184,9 +190,11 @@ export class AutoScrollController {
this._mutationObserver = new MutationObserver(() => {
if (!this._autoScrollEnabled || this._rafPending) return;
this._rafPending = true;
requestAnimationFrame(() => {
this._rafPending = false;
if (this._autoScrollEnabled && this._container) {
this._container.scrollTop = this._container.scrollHeight;
}
@@ -194,9 +202,9 @@ export class AutoScrollController {
});
this._mutationObserver.observe(this._container, {
characterData: true,
childList: true,
subtree: true,
characterData: true
subtree: true
});
}
@@ -205,6 +213,7 @@ export class AutoScrollController {
this._mutationObserver.disconnect();
this._mutationObserver = null;
}
this._rafPending = false;
}
}
@@ -2,12 +2,12 @@ import { getChatCommands, PROMPT_TRIGGER_PREFIX } from '$lib/constants';
import { ChatFormCommandAction, KeyboardKey } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
import {
type CommandDismissSnapshot,
findCommandToken,
findMentionToken,
type MentionDismissSnapshot,
takeCommandDismissSnapshot,
takeMentionDismissSnapshot,
type CommandDismissSnapshot,
type MentionDismissSnapshot
takeMentionDismissSnapshot
} from '$lib/utils';
/** Dependencies injected as getters so the hook stays free of store circular imports. */
@@ -47,23 +47,20 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
let mentionQuery = $state('');
let isWorkingDirectoryPickerOpen = $state(false);
let workingDirectoryQuery = $state('');
// Last dismissed `@`-mention token; while intact, the picker does not
// reopen, so an escaped `@<query>` stays literal until edited.
let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
// Same dismissal contract for the `/`-command token.
let commandDismissedSnapshot: CommandDismissSnapshot | null = null;
// Fall back to the server home so the picker still finds matches
// before a cwd is set.
const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
const availableCommands = $derived(
getChatCommands({
showModelSelector: opts.getShowModelSelector(),
hasCwdTools: opts.hasCwdTools,
hasPrompts: opts.hasPrompts,
hasCwdTools: opts.hasCwdTools
showModelSelector: opts.getShowModelSelector()
})
);
@@ -81,24 +78,29 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
opts.setValue('');
isPromptPickerOpen = true;
promptSearchQuery = args.trim();
break;
case ChatFormCommandAction.CWD: {
// Keep `/cwd <args>` in the input so the search field and the
// token stay two-way bound; normalize partial tokens (`/cw foo`).
const trimmed = args.trim();
const newValue = `/cwd ${trimmed}`;
if (opts.getValue() !== newValue) {
opts.setValue(newValue);
queueMicrotask(() => opts.setCaretOffset(newValue.length));
}
workingDirectoryQuery = trimmed;
isWorkingDirectoryPickerOpen = true;
break;
}
case ChatFormCommandAction.MODEL:
isWorkingDirectoryPickerOpen = false;
opts.setValue('');
opts.openModelSelector();
break;
}
}
@@ -114,9 +116,11 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
promptSearchQuery = '';
const token = findCommandToken(value);
if (!token) {
isCommandPickerOpen = false;
commandQuery = '';
return;
}
@@ -125,12 +129,14 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
if (isWorkingDirectoryPickerOpen) {
isCommandPickerOpen = false;
commandQuery = '';
if (token.name === 'cwd') {
workingDirectoryQuery = token.args.trim();
} else {
isWorkingDirectoryPickerOpen = false;
workingDirectoryQuery = '';
}
return;
}
@@ -143,6 +149,7 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
if (isDismissedSticky) {
isCommandPickerOpen = false;
commandQuery = '';
return;
}
@@ -156,14 +163,17 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
isCommandPickerOpen = false;
commandQuery = '';
}
return;
}
isCommandPickerOpen = false;
commandQuery = '';
if (commandDismissedSnapshot !== null) {
commandDismissedSnapshot = null;
}
if (isWorkingDirectoryPickerOpen) {
isWorkingDirectoryPickerOpen = false;
}
@@ -186,6 +196,7 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
mentionQuery = token.query;
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
}
@@ -210,6 +221,7 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return true;
}
@@ -219,6 +231,7 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
function handleCommandSelect(command: ChatFormCommand) {
// Dispatch on the live token so typed args seed the target picker.
const token = findCommandToken(opts.getValue());
dispatchCommand(command, token?.args ?? '');
}
@@ -228,8 +241,10 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
if (isCommandPickerOpen) {
commandDismissedSnapshot = takeCommandDismissSnapshot(opts.getValue());
}
isCommandPickerOpen = false;
commandQuery = '';
// Target picker manages its own focus: don't yank it back to the input.
if (!isPromptPickerOpen && !isMentionPickerOpen && !isWorkingDirectoryPickerOpen) {
opts.focusInput();
@@ -240,8 +255,10 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
function handleMentionPickerClose() {
if (isMentionPickerOpen) {
const cursor = opts.getCaretOffset() ?? opts.getValue().length;
mentionDismissedSnapshot = takeMentionDismissSnapshot(opts.getValue(), cursor);
}
isMentionPickerOpen = false;
mentionQuery = '';
opts.focusInput();
@@ -268,21 +285,27 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
// reverse direction is handled by handleInput.
$effect(() => {
if (!isWorkingDirectoryPickerOpen) return;
const value = opts.getValue();
const token = findCommandToken(value);
if (!token || token.name !== 'cwd') return;
const newValue = `/cwd ${workingDirectoryQuery}`;
if (newValue === value) return;
opts.setValue(newValue);
queueMicrotask(() => opts.setCaretOffset(newValue.length));
});
return {
get isCommandPickerOpen() {
return isCommandPickerOpen;
get availableCommands() {
return availableCommands;
},
set isCommandPickerOpen(v: boolean) {
isCommandPickerOpen = v;
closePromptPicker() {
isPromptPickerOpen = false;
promptSearchQuery = '';
},
get commandQuery() {
return commandQuery;
@@ -290,17 +313,21 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
set commandQuery(v: string) {
commandQuery = v;
},
get isPromptPickerOpen() {
return isPromptPickerOpen;
dispatchCommand,
handleCommandPickerClose,
handleCommandSelect,
handleInput,
// True when a picker consumed the event, so the form skips submit.
handleKeydown,
handleMentionPickerClose,
handlePromptPickerClose,
handleWorkingDirectoryClose,
handleWorkingDirectoryOpen,
get isCommandPickerOpen() {
return isCommandPickerOpen;
},
set isPromptPickerOpen(v: boolean) {
isPromptPickerOpen = v;
},
get promptSearchQuery() {
return promptSearchQuery;
},
set promptSearchQuery(v: string) {
promptSearchQuery = v;
set isCommandPickerOpen(v: boolean) {
isCommandPickerOpen = v;
},
get isMentionPickerOpen() {
return isMentionPickerOpen;
@@ -308,11 +335,11 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
set isMentionPickerOpen(v: boolean) {
isMentionPickerOpen = v;
},
get mentionQuery() {
return mentionQuery;
get isPromptPickerOpen() {
return isPromptPickerOpen;
},
set mentionQuery(v: string) {
mentionQuery = v;
set isPromptPickerOpen(v: boolean) {
isPromptPickerOpen = v;
},
get isWorkingDirectoryPickerOpen() {
return isWorkingDirectoryPickerOpen;
@@ -320,34 +347,29 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) {
set isWorkingDirectoryPickerOpen(v: boolean) {
isWorkingDirectoryPickerOpen = v;
},
get mentionQuery() {
return mentionQuery;
},
set mentionQuery(v: string) {
mentionQuery = v;
},
get mentionScopePath() {
return mentionScopePath;
},
openPromptPicker() {
isPromptPickerOpen = true;
},
get promptSearchQuery() {
return promptSearchQuery;
},
set promptSearchQuery(v: string) {
promptSearchQuery = v;
},
get workingDirectoryQuery() {
return workingDirectoryQuery;
},
set workingDirectoryQuery(v: string) {
workingDirectoryQuery = v;
},
get availableCommands() {
return availableCommands;
},
get mentionScopePath() {
return mentionScopePath;
},
handleInput,
// True when a picker consumed the event, so the form skips submit.
handleKeydown,
dispatchCommand,
handleCommandSelect,
handleCommandPickerClose,
handleMentionPickerClose,
handlePromptPickerClose,
handleWorkingDirectoryOpen,
handleWorkingDirectoryClose,
openPromptPicker() {
isPromptPickerOpen = true;
},
closePromptPicker() {
isPromptPickerOpen = false;
promptSearchQuery = '';
}
};
}
@@ -8,17 +8,16 @@
* demand if they aren't cached yet.
*/
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import { modelOptions, modelsStore, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
export function useChatScreenActiveModel() {
const isRouter = $derived(isRouterMode());
const conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
const activeModelId = $derived.by(() => {
const options = modelOptions();
@@ -27,13 +26,16 @@ export function useChatScreenActiveModel() {
}
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
@@ -45,6 +47,7 @@ export function useChatScreenActiveModel() {
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
@@ -56,37 +59,38 @@ export function useChatScreenActiveModel() {
const hasAudioModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsAudio(activeModelId);
}
return false;
});
const hasVideoModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVideo(activeModelId);
}
return false;
});
const hasVisionModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVision(activeModelId);
}
return false;
});
return {
get isRouter() {
return isRouter;
get activeModelId() {
return activeModelId;
},
get conversationModel() {
return conversationModel;
},
get activeModelId() {
return activeModelId;
},
get hasAudioModality() {
return hasAudioModality;
},
@@ -95,6 +99,9 @@ export function useChatScreenActiveModel() {
},
get hasVisionModality() {
return hasVisionModality;
},
get isRouter() {
return isRouter;
}
};
}
@@ -21,6 +21,7 @@ export function useChatScreenDragAndDrop(options: UseChatScreenDragAndDropOption
function handleDragEnter(event: DragEvent) {
event.preventDefault();
dragCounter++;
if (event.dataTransfer?.types.includes('Files')) {
isDragOver = true;
}
@@ -29,6 +30,7 @@ export function useChatScreenDragAndDrop(options: UseChatScreenDragAndDropOption
function handleDragLeave(event: DragEvent) {
event.preventDefault();
dragCounter--;
if (dragCounter === 0) {
isDragOver = false;
}
@@ -49,8 +51,10 @@ export function useChatScreenDragAndDrop(options: UseChatScreenDragAndDropOption
if (isEditing()) {
const handler = getAddFilesHandler();
if (handler) {
handler(files);
return;
}
}
@@ -59,14 +63,14 @@ export function useChatScreenDragAndDrop(options: UseChatScreenDragAndDropOption
}
return {
get isDragOver() {
return isDragOver;
},
dragHandlers: {
dragenter: handleDragEnter,
dragleave: handleDragLeave,
dragover: handleDragOver,
drop: handleDrop
},
get isDragOver() {
return isDragOver;
}
};
}
@@ -7,8 +7,8 @@
* as reactive getters so validation tracks the model in real time.
*/
import { filterFilesByModalities, isFileTypeSupported } from '$lib/utils';
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
interface UseChatScreenFileUploadOptions {
capabilities: () => { hasVision: boolean; hasAudio: boolean; hasVideo: boolean };
@@ -27,8 +27,8 @@ export function useChatScreenFileUpload(options: UseChatScreenFileUploadOptions)
let showFileErrorDialog = $state(false);
let fileErrorData = $state<FileErrorData>({
generallyUnsupported: [],
modalityUnsupported: [],
modalityReasons: {},
modalityUnsupported: [],
supportedTypes: []
});
@@ -44,24 +44,26 @@ export function useChatScreenFileUpload(options: UseChatScreenFileUploadOptions)
}
}
const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
const { modalityReasons, supportedFiles, unsupportedFiles } = filterFilesByModalities(
generallySupported,
options.capabilities()
);
const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
if (allUnsupportedFiles.length > 0) {
const supportedTypes: string[] = ['text files', 'PDFs'];
const caps = options.capabilities();
if (caps.hasVision) supportedTypes.push('images');
if (caps.hasAudio) supportedTypes.push('audio files');
if (caps.hasVideo) supportedTypes.push('video files');
fileErrorData = {
generallyUnsupported,
modalityUnsupported: unsupportedFiles,
modalityReasons,
modalityUnsupported: unsupportedFiles,
supportedTypes
};
showFileErrorDialog = true;
@@ -72,6 +74,7 @@ export function useChatScreenFileUpload(options: UseChatScreenFileUploadOptions)
supportedFiles,
options.activeModelId() ?? undefined
);
uploadedFiles = [...uploadedFiles, ...processed];
}
}
@@ -85,20 +88,20 @@ export function useChatScreenFileUpload(options: UseChatScreenFileUploadOptions)
}
return {
get uploadedFiles() {
return uploadedFiles;
},
set uploadedFiles(value) {
uploadedFiles = value;
},
fileErrorData,
handleFileRemove,
handleFileUpload,
get showFileErrorDialog() {
return showFileErrorDialog;
},
set showFileErrorDialog(value) {
showFileErrorDialog = value;
},
fileErrorData,
handleFileUpload,
handleFileRemove
get uploadedFiles() {
return uploadedFiles;
},
set uploadedFiles(value) {
uploadedFiles = value;
}
};
}
@@ -7,8 +7,8 @@
* scroll handler seeing spurious events from layout shifts.
*/
import { afterNavigate, beforeNavigate } from '$app/navigation';
import type { AutoScrollController } from './use-auto-scroll.svelte';
import { afterNavigate, beforeNavigate } from '$app/navigation';
export function useChatScreenScroll(autoScroll: AutoScrollController) {
let chatScrollContainer: HTMLElement | undefined = $state();
@@ -18,6 +18,7 @@ export function useChatScreenScroll(autoScroll: AutoScrollController) {
// Ignore scroll events caused by navigation layout changes or by our own
// programmatic scrolls so they don't accidentally disable auto-scroll.
if (isNavigating || !event.isTrusted) return;
autoScroll.handleScroll();
}
@@ -4,23 +4,23 @@
* read / fresh / cache / output and cumulative token counts.
*/
import { useProcessingState } from './use-processing-state.svelte';
import {
type ColorLevel,
colorLevelFromPercent
} from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge';
import { STATS_UNITS } from '$lib/constants';
import { MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import {
modelsStore,
modelOptions,
modelsStore,
selectedModelId,
singleModelName
} from '$lib/stores/models.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { MessageRole } from '$lib/enums';
import { STATS_UNITS } from '$lib/constants';
import type { ChatMessageTimings, DatabaseMessage } from '$lib/types';
import { useProcessingState } from './use-processing-state.svelte';
import {
colorLevelFromPercent,
type ColorLevel
} from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge';
interface LiveStats {
freshTokens: number;
@@ -55,8 +55,10 @@ export interface UseContextGaugeReturn {
function lastAssistantTimings(messages: DatabaseMessage[]): ChatMessageTimings | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role === MessageRole.ASSISTANT && m.timings) return m.timings;
}
return undefined;
}
@@ -66,13 +68,15 @@ function deriveLiveStats(
if (!state || (state.status !== 'preparing' && state.status !== 'generating')) {
return null;
}
const promptTokens = state.promptTokens ?? 0;
const cacheTokens = state.cacheTokens ?? 0;
return {
freshTokens: promptTokens,
promptTokens: promptTokens + cacheTokens,
cacheTokens,
outputTokens: state.outputTokensUsed ?? 0
freshTokens: promptTokens,
outputTokens: state.outputTokensUsed ?? 0,
promptTokens: promptTokens + cacheTokens
};
}
@@ -83,13 +87,13 @@ function filterTransientDetails(raw: string[]): string[] {
if (TRANSIENT_DETAILS_EXCLUDED_PREFIXES.some((prefix) => detail.startsWith(prefix))) {
return false;
}
return !detail.includes(STATS_UNITS.TOKENS_PER_SECOND);
});
}
export function useContextGauge(): UseContextGaugeReturn {
const processingState = useProcessingState();
// Resolve the model the gauge reports context for: explicit selection >
// last assistant model > single-model mode (mirrors useChatScreenActiveModel).
const activeModelId = $derived.by(() => {
@@ -98,18 +102,18 @@ export function useContextGauge(): UseContextGaugeReturn {
}
const selectedId = selectedModelId();
if (selectedId) {
const model = modelOptions().find((m) => m.id === selectedId);
if (model) return model.model;
}
return chatStore.getConversationModel(activeMessages() as DatabaseMessage[]);
});
const isActiveModelLoaded = $derived(
activeModelId !== null && modelsStore.isModelLoaded(activeModelId)
);
const isActiveModelLoading = $derived(
activeModelId !== null && modelsStore.isModelOperationInProgress(activeModelId)
);
@@ -118,6 +122,7 @@ export function useContextGauge(): UseContextGaugeReturn {
$effect(() => {
if (activeModelId && isActiveModelLoaded) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
void modelsStore.fetchModelProps(activeModelId);
}
@@ -126,52 +131,54 @@ export function useContextGauge(): UseContextGaugeReturn {
const contextTotal = $derived.by(() => {
void modelsStore.propsCacheVersion;
return activeModelId ? modelsStore.getModelContextSize(activeModelId) : null;
});
const liveStats = $derived(deriveLiveStats(processingState.processingState));
const currentRead = $derived.by(() => {
const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
let read = 0;
if (timings) {
read = (timings.prompt_n ?? 0) + (timings.cache_n ?? 0);
}
// live.promptTokens is already the combined reading (prompt + cache),
// so do not also add live.cacheTokens.
if (liveStats && liveStats.promptTokens > 0) {
read = Math.max(read, liveStats.promptTokens);
}
return read;
});
const currentFresh = $derived.by(() => {
const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
const fresh = timings?.prompt_n ?? 0;
return Math.max(fresh, liveStats?.freshTokens ?? 0);
});
const currentCache = $derived.by(() => {
const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
const cached = timings?.cache_n ?? 0;
if (liveStats && liveStats.promptTokens > 0) {
return Math.max(cached, liveStats.cacheTokens);
}
return cached;
});
const currentOutput = $derived.by(() => {
if (liveStats && liveStats.outputTokens > 0) return liveStats.outputTokens;
const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]);
return timings?.predicted_n ?? 0;
});
const kvTotal = $derived(currentRead + currentOutput);
const contextUsed = $derived(currentRead + currentOutput);
const cumulative = $derived.by(() => {
const messages = activeMessages() as DatabaseMessage[];
// Agentic sessions stamp the same agentic.llm totals onto every
// assistant message; cache_n is never per-turn so cache_total stays 0.
const agenticMessages = messages.filter(
@@ -183,11 +190,12 @@ export function useContextGauge(): UseContextGaugeReturn {
const output = llm.predicted_n ?? 0;
const outputMs = llm.predicted_ms ?? 0;
const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
return {
read: llm.prompt_n ?? 0,
output,
averageTokensPerSecond,
cacheTotal: 0,
averageTokensPerSecond
output,
read: llm.prompt_n ?? 0
};
}
@@ -195,27 +203,27 @@ export function useContextGauge(): UseContextGaugeReturn {
let output = 0;
let outputMs = 0;
let cacheTotal = 0;
for (const m of messages) {
if (m.role !== MessageRole.ASSISTANT || !m.timings) continue;
read += m.timings.prompt_n ?? 0;
cacheTotal += m.timings.cache_n ?? 0;
output += m.timings.predicted_n ?? 0;
outputMs += m.timings.predicted_ms ?? 0;
}
const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null;
return { read, output, cacheTotal, averageTokensPerSecond };
});
return { averageTokensPerSecond, cacheTotal, output, read };
});
const contextPercent = $derived.by(() => {
if (contextTotal === null || contextTotal <= 0) return null;
return Math.round((contextUsed / contextTotal) * 100);
});
const colorLevel = $derived(colorLevelFromPercent(contextPercent));
// Drop lines the surrounding Context / Output / speed rows already render.
const transientDetails = $derived(filterTransientDetails(processingState.getTechnicalDetails()));
const hasAnyUsage = $derived(
cumulative.read > 0 ||
cumulative.output > 0 ||
@@ -227,6 +235,7 @@ export function useContextGauge(): UseContextGaugeReturn {
async function loadModel() {
if (!activeModelId || isActiveModelLoading) return;
try {
await modelsStore.loadModel(activeModelId);
} catch {
@@ -238,11 +247,14 @@ export function useContextGauge(): UseContextGaugeReturn {
get activeModelId() {
return activeModelId;
},
get isActiveModelLoaded() {
return isActiveModelLoaded;
get averageTokensPerSecond() {
return cumulative.averageTokensPerSecond;
},
get isActiveModelLoading() {
return isActiveModelLoading;
get colorLevel() {
return colorLevel;
},
get contextPercent() {
return contextPercent;
},
get contextTotal() {
return contextTotal;
@@ -250,46 +262,43 @@ export function useContextGauge(): UseContextGaugeReturn {
get contextUsed() {
return contextUsed;
},
get currentRead() {
return currentRead;
},
get currentFresh() {
return currentFresh;
},
get currentCache() {
return currentCache;
},
get currentOutput() {
return currentOutput;
},
get kvTotal() {
return kvTotal;
},
get cumulativeRead() {
return cumulative.read;
get cumulativeCacheTotal() {
return cumulative.cacheTotal;
},
get cumulativeOutput() {
return cumulative.output;
},
get cumulativeCacheTotal() {
return cumulative.cacheTotal;
get cumulativeRead() {
return cumulative.read;
},
get averageTokensPerSecond() {
return cumulative.averageTokensPerSecond;
get currentCache() {
return currentCache;
},
get contextPercent() {
return contextPercent;
get currentFresh() {
return currentFresh;
},
get colorLevel() {
return colorLevel;
get currentOutput() {
return currentOutput;
},
get transientDetails() {
return transientDetails;
get currentRead() {
return currentRead;
},
get hasAnyUsage() {
return hasAnyUsage;
},
get isActiveModelLoaded() {
return isActiveModelLoaded;
},
get isActiveModelLoading() {
return isActiveModelLoading;
},
get kvTotal() {
return kvTotal;
},
loadModel,
startMonitoring: () => processingState.startMonitoring()
startMonitoring: () => processingState.startMonitoring(),
get transientDetails() {
return transientDetails;
}
};
}
@@ -33,14 +33,17 @@ export function useDebouncedSearch(opts: UseDebouncedSearchOptions) {
const schedule = debounce((query: string) => {
if (!opts.canRun() || query !== opts.getQuery().trim()) return;
void start(query);
}, opts.debounceMs);
async function start(query: string) {
cancel();
const fresh = new AbortController();
controller = fresh;
const mySeq = ++searchSeq;
isSearching = true;
try {
await opts.run(query, fresh.signal, () => isCurrent(mySeq));
@@ -50,17 +53,17 @@ export function useDebouncedSearch(opts: UseDebouncedSearchOptions) {
}
return {
cancel,
get isSearching() {
return isSearching;
},
/** Bump the loading flag synchronously (e.g. before the debounce fires). */
setLoading(value: boolean) {
isSearching = value;
},
run(query: string) {
schedule(query);
},
cancel
/** Bump the loading flag synchronously (e.g. before the debounce fires). */
setLoading(value: boolean) {
isSearching = value;
}
};
}
@@ -1,6 +1,6 @@
import { onMount } from 'svelte';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { draftMessagesStore } from '$lib/stores/draft-messages.svelte';
import { onMount } from 'svelte';
interface UseDraftMessagesOptions {
getChatId: () => string | undefined;
@@ -24,6 +24,7 @@ export function useDraftMessages(options: UseDraftMessagesOptions) {
beforeNavigate(() => {
const chatId = options.getChatId();
draftMessagesStore.saveDraftMessage(chatId, options.getMessage(), options.getFiles());
});
@@ -31,6 +32,7 @@ export function useDraftMessages(options: UseDraftMessagesOptions) {
if (navigation?.from != null) {
const chatId = options.getChatId();
const draft = draftMessagesStore.getDraftMessage(chatId);
options.setMessage(draft.message);
options.setFiles(draft.files);
}
@@ -38,6 +40,7 @@ export function useDraftMessages(options: UseDraftMessagesOptions) {
function clearDraft() {
const chatId = options.getChatId();
draftMessagesStore.clearDraftMessage(chatId);
}
@@ -1,6 +1,6 @@
import { goto } from '$app/navigation';
import { KeyboardKey } from '$lib/enums';
import { ROUTES } from '$lib/constants/routes';
import { KeyboardKey } from '$lib/enums';
interface KeyboardShortcutsCallbacks {
activateSearchMode?: () => void;
@@ -63,11 +63,15 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
const order = options.orderedIds();
const fromIdx = order.indexOf(fromId);
const toIdx = order.indexOf(toId);
if (fromIdx === -1 || toIdx === -1) return;
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
const shouldSelect = !selected.has(toId);
for (let i = lo; i <= hi; i++) {
const id = order[i];
if (shouldSelect) selected.add(id);
else selected.delete(id);
}
@@ -77,22 +81,27 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
const attr = resolveAttributeName();
const selector = `[data-${attr}]`;
const key = datasetKey(attr);
let bestMatch: HTMLElement | null = null;
let bestCenterDistance = Infinity;
for (const row of document.querySelectorAll<HTMLElement>(selector)) {
const rect = row.getBoundingClientRect();
if (y >= rect.top && y <= rect.bottom && x >= rect.left && x <= rect.right) {
return row.dataset[key] ?? null;
}
if (x >= rect.left && x <= rect.right) {
const centerDistance = Math.abs(y - (rect.top + rect.height / 2));
if (centerDistance < bestCenterDistance) {
bestCenterDistance = centerDistance;
bestMatch = row;
}
}
}
return bestMatch ? (bestMatch.dataset[key] ?? null) : null;
}
@@ -109,6 +118,7 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
for (const row of document.querySelectorAll<HTMLElement>(selector)) {
const id = row.dataset[key];
if (!id || !visibleIds.has(id)) continue;
const rect = row.getBoundingClientRect();
@@ -132,17 +142,22 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
if (event.shiftKey && dragAnchorId !== null) {
const target = findRowAtPoint(event.clientX, event.clientY);
if (target && target !== mousedownRowId) rangeSelect(dragAnchorId, target);
return;
}
if (!isMarqueeDragging) {
const dx = event.clientX - dragStartX;
const dy = event.clientY - dragStartY;
if (Math.hypot(dx, dy) < dragThresholdPx) return;
isMarqueeDragging = true;
dragMode = decideDragMode(mousedownRowId, options.selectedIds());
}
updateMarqueeRect(event.clientX, event.clientY);
}
@@ -150,8 +165,10 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
if (isMarqueeDragging) {
suppressNextClick = true;
const target = findRowAtPoint(event.clientX, event.clientY);
if (target) dragAnchorId = target;
}
isMarqueeDragging = false;
mouseDownActive = false;
mousedownRowId = null;
@@ -171,11 +188,14 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
$effect(() => {
if (!options.enabled()) {
reset();
return;
}
document.addEventListener('mousemove', handleDocumentMouseMove);
document.addEventListener('mouseup', handleDocumentMouseUp);
document.addEventListener('click', handleClickCapture, { capture: true });
return () => {
document.removeEventListener('mousemove', handleDocumentMouseMove);
document.removeEventListener('mouseup', handleDocumentMouseUp);
@@ -185,7 +205,9 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
function rowMouseDown(id: string, event: MouseEvent) {
if (!options.enabled()) return;
if (event.button !== 0) return;
event.preventDefault();
mouseDownActive = true;
mousedownRowId = id;
@@ -197,10 +219,12 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
function rowClick(id: string, shiftKey: boolean) {
if (!options.enabled()) return;
const selected = options.selectedIds();
if (shiftKey) {
const anchor = dragAnchorId;
if (anchor !== null && anchor !== id) {
rangeSelect(anchor, id);
} else if (selected.has(id)) {
@@ -208,12 +232,15 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
} else {
selected.add(id);
}
dragAnchorId = id;
return;
}
if (selected.has(id)) selected.delete(id);
else selected.add(id);
dragAnchorId = id;
}
@@ -229,11 +256,11 @@ export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
}
return {
rowMouseDown,
rowClick,
reset,
get dragAnchorId() {
return dragAnchorId;
}
},
reset,
rowClick,
rowMouseDown
};
}
@@ -24,13 +24,16 @@ export function useMessageEditContext(options: UseMessageEditContextOptions) {
async function handleSaveEdit() {
const trimmed = editedContent.trim();
if (!trimmed && editedExtras.length === 0 && editedUploadedFiles.length === 0) return;
let finalExtras: DatabaseMessageExtra[] = $state.snapshot(editedExtras);
if (editedUploadedFiles.length > 0) {
const plainFiles = $state.snapshot(editedUploadedFiles);
const result = await parseFilesToMessageExtras(plainFiles);
const newExtras = result?.extras || [];
finalExtras = [...finalExtras, ...newExtras];
}
@@ -43,9 +46,7 @@ export function useMessageEditContext(options: UseMessageEditContextOptions) {
}
setMessageEditContext({
get isEditing() {
return isEditing;
},
cancel: handleCancelEdit,
get editedContent() {
return editedContent;
},
@@ -55,24 +56,20 @@ export function useMessageEditContext(options: UseMessageEditContextOptions) {
get editedUploadedFiles() {
return editedUploadedFiles;
},
get isEditing() {
return isEditing;
},
get messageRole() {
return MessageRole.USER;
},
get originalContent() {
return options.getContent();
},
get originalExtras() {
return options.getExtras();
},
get showSaveOnlyOption() {
return options.showSaveOnlyOption ?? false;
},
get showBranchAfterEditOption() {
return false;
},
get shouldBranchAfterEdit() {
return false;
},
get messageRole() {
return MessageRole.USER;
},
save: handleSaveEdit,
saveOnly: handleSaveEdit,
setContent: (c: string) => {
editedContent = c;
},
@@ -82,18 +79,24 @@ export function useMessageEditContext(options: UseMessageEditContextOptions) {
setUploadedFiles: (f: ChatUploadedFile[]) => {
editedUploadedFiles = f;
},
save: handleSaveEdit,
saveOnly: handleSaveEdit,
cancel: handleCancelEdit,
get shouldBranchAfterEdit() {
return false;
},
get showBranchAfterEditOption() {
return false;
},
get showSaveOnlyOption() {
return options.showSaveOnlyOption ?? false;
},
startEdit: handleEdit
});
return {
get isEditing() {
return isEditing;
},
handleCancelEdit,
handleEdit,
handleSaveEdit,
handleCancelEdit
get isEditing() {
return isEditing;
}
};
}
@@ -1,16 +1,16 @@
import { onMount } from 'svelte';
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import {
modelsStore,
modelOptions,
modelsLoading,
modelsStore,
modelsUpdating,
selectedModelId,
singleModelName
} from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import type { ModelOption } from '$lib/types/models';
import { onMount } from 'svelte';
export interface UseModelsSelectorOptions {
currentModel: () => string | null;
@@ -65,18 +65,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
const activeId = $derived(selectedModelId());
const isRouter = $derived(isRouterMode());
const serverModel = $derived(singleModelName());
const currentModel = $derived(opts.currentModel());
const onModelChange = $derived(opts.onModelChange?.());
const isHighlightedCurrentModelActive = $derived.by(() => {
if (!isRouter || !currentModel) return false;
const currentOption = options.find((option) => option.model === currentModel);
return currentOption ? currentOption.id === activeId : false;
});
const isCurrentModelInCache = $derived.by(() => {
if (!isRouter || !currentModel) return true;
return options.some((option) => option.model === currentModel);
});
@@ -84,6 +84,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
let searchTerm = $state('');
let showModelDialog = $state(false);
let infoModelId = $state<string | null>(null);
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
const groupedFilteredOptions = $derived(
groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
@@ -122,6 +123,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
async function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
if (!option) return;
let shouldCloseMenu = true;
@@ -162,10 +164,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
if (displayModel) {
return {
capabilities: [],
id: serverModel ? 'current' : 'offline-current',
model: displayModel,
name: displayModel.split('/').pop() || displayModel,
capabilities: []
name: displayModel.split('/').pop() || displayModel
};
}
@@ -175,10 +177,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
if (currentModel) {
if (!isCurrentModelInCache) {
return {
capabilities: [],
id: 'not-in-cache',
model: currentModel,
name: currentModel.split('/').pop() || currentModel,
capabilities: []
name: currentModel.split('/').pop() || currentModel
};
}
@@ -193,60 +195,64 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}
return {
get options() {
return options;
},
get loading() {
return loading;
},
get updating() {
return updating;
},
get activeId() {
return activeId;
},
get isRouter() {
return isRouter;
},
get serverModel() {
return serverModel;
},
get isHighlightedCurrentModelActive() {
return isHighlightedCurrentModelActive;
},
get isCurrentModelInCache() {
return isCurrentModelInCache;
},
get filteredOptions() {
return filteredOptions;
},
getDisplayOption,
get groupedFilteredOptions() {
return groupedFilteredOptions;
},
handleInfoClick,
handleOpenChange,
handleSelect,
get infoModelId() {
return infoModelId;
},
get isCurrentModelInCache() {
return isCurrentModelInCache;
},
isFavorite(model: string) {
return modelsStore.favoriteModelIds.has(model);
},
get isHighlightedCurrentModelActive() {
return isHighlightedCurrentModelActive;
},
get isLoadingModel() {
return isLoadingModel;
},
get isRouter() {
return isRouter;
},
get loading() {
return loading;
},
get options() {
return options;
},
get searchTerm() {
return searchTerm;
},
get showModelDialog() {
return showModelDialog;
},
get infoModelId() {
return infoModelId;
get serverModel() {
return serverModel;
},
setSearchTerm(value: string) {
@@ -257,16 +263,12 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
showModelDialog = value;
},
handleInfoClick,
handleSelect,
handleOpenChange,
isFavorite(model: string) {
return modelsStore.favoriteModelIds.has(model);
get showModelDialog() {
return showModelDialog;
},
getDisplayOption
get updating() {
return updating;
}
};
}
@@ -28,13 +28,17 @@ export function usePickerNavigation(opts: UsePickerNavigationOptions) {
function resolve(from: number, dir: 1 | -1): number {
const n = opts.count();
if (n === 0) return -1;
if (opts.step) return opts.step(from, dir);
return wrapStep(from, dir, n);
}
function move(dir: 1 | -1) {
const next = resolve(hoveredIndex, dir);
if (next >= 0) {
hoveredIndex = next;
scrollTrigger++;
@@ -62,18 +66,21 @@ export function usePickerNavigation(opts: UsePickerNavigationOptions) {
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
opts.onClose();
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
move(1);
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
move(-1);
return true;
}
@@ -81,8 +88,10 @@ export function usePickerNavigation(opts: UsePickerNavigationOptions) {
if (hoveredIndex >= 0 && hoveredIndex < opts.count()) {
event.preventDefault();
opts.onSelect(hoveredIndex);
return true;
}
// No selectable row - let the caller's Enter-to-submit run.
return false;
}
@@ -91,17 +100,17 @@ export function usePickerNavigation(opts: UsePickerNavigationOptions) {
}
return {
bumpScroll,
handleKeydown,
get hoveredIndex() {
return hoveredIndex;
},
move,
reset,
get scrollTrigger() {
return scrollTrigger;
},
reset,
setHover,
move,
bumpScroll,
handleKeydown
setHover
};
}
@@ -1,6 +1,6 @@
import { activeProcessingState } from '$lib/stores/chat.svelte';
import { STATS_UNITS } from '$lib/constants';
import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types';
import { activeProcessingState } from '$lib/stores/chat.svelte';
import type { ApiProcessingState, LiveGenerationStats, LiveProcessingStats } from '$lib/types';
export interface UseProcessingStateReturn {
readonly processingState: ApiProcessingState | null;
@@ -41,6 +41,7 @@ export function useProcessingState(): UseProcessingStateReturn {
if (!isMonitoring) {
return lastKnownState;
}
// Read directly from the reactive state export
return activeProcessingState();
});
@@ -54,17 +55,18 @@ export function useProcessingState(): UseProcessingStateReturn {
// Track last known processing stats for when promptProgress disappears
$effect(() => {
if (processingState?.promptProgress) {
const { processed, total, time_ms, cache } = processingState.promptProgress;
const { cache, processed, time_ms, total } = processingState.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
if (actualProcessed > 0 && time_ms > 0) {
const tokensPerSecond = actualProcessed / (time_ms / 1000);
lastKnownProcessingStats = {
tokensProcessed: actualProcessed,
totalTokens: actualTotal,
timeMs: time_ms,
tokensPerSecond
tokensPerSecond,
tokensProcessed: actualProcessed,
totalTokens: actualTotal
};
}
}
@@ -76,11 +78,13 @@ export function useProcessingState(): UseProcessingStateReturn {
done === 0 || elapsedSecs < 0.5
? undefined // can be the case for the 0% progress report
: elapsedSecs * (total / done - 1);
return progressETASecs;
}
function startMonitoring(): void {
if (isMonitoring) return;
isMonitoring = true;
}
@@ -102,6 +106,7 @@ export function useProcessingState(): UseProcessingStateReturn {
if (processingState.progressPercent !== undefined) {
return `Processing (${processingState.progressPercent}%)`;
}
return 'Preparing response...';
case 'generating':
return '';
@@ -113,6 +118,7 @@ export function useProcessingState(): UseProcessingStateReturn {
function getProcessingDetails(): string[] {
// Use current processing state or fall back to last known state
const stateToUse = processingState || lastKnownState;
if (!stateToUse) {
return [];
}
@@ -121,7 +127,7 @@ export function useProcessingState(): UseProcessingStateReturn {
// Show prompt processing progress with ETA during preparation phase
if (stateToUse.promptProgress) {
const { processed, total, time_ms, cache } = stateToUse.promptProgress;
const { cache, processed, time_ms, total } = stateToUse.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
@@ -131,6 +137,7 @@ export function useProcessingState(): UseProcessingStateReturn {
if (eta !== undefined) {
const etaSecs = Math.ceil(eta);
details.push(`Processing ${percent}% (ETA: ${etaSecs}s)`);
} else {
details.push(`Processing ${percent}%`);
@@ -182,6 +189,7 @@ export function useProcessingState(): UseProcessingStateReturn {
*/
function getTechnicalDetails(): string[] {
const stateToUse = processingState || lastKnownState;
if (!stateToUse) {
return [];
}
@@ -237,8 +245,7 @@ export function useProcessingState(): UseProcessingStateReturn {
function getPromptProgressText(): string | null {
if (!processingState?.promptProgress) return null;
const { processed, total, cache } = processingState.promptProgress;
const { cache, processed, total } = processingState.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
const percent = Math.round((actualProcessed / actualTotal) * 100);
@@ -246,6 +253,7 @@ export function useProcessingState(): UseProcessingStateReturn {
if (eta !== undefined) {
const etaSecs = Math.ceil(eta);
return `Processing ${percent}% (ETA: ${etaSecs}s)`;
}
@@ -258,8 +266,7 @@ export function useProcessingState(): UseProcessingStateReturn {
*/
function getLiveProcessingStats(): LiveProcessingStats | null {
if (processingState?.promptProgress) {
const { processed, total, time_ms, cache } = processingState.promptProgress;
const { cache, processed, time_ms, total } = processingState.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
@@ -267,10 +274,10 @@ export function useProcessingState(): UseProcessingStateReturn {
const tokensPerSecond = actualProcessed / (time_ms / 1000);
return {
tokensProcessed: actualProcessed,
totalTokens: actualTotal,
timeMs: time_ms,
tokensPerSecond
tokensPerSecond,
tokensProcessed: actualProcessed,
totalTokens: actualTotal
};
}
}
@@ -294,22 +301,22 @@ export function useProcessingState(): UseProcessingStateReturn {
tokensPerSecond && tokensPerSecond > 0 ? (tokensDecoded / tokensPerSecond) * 1000 : 0;
return {
tokensGenerated: tokensDecoded,
timeMs,
tokensGenerated: tokensDecoded,
tokensPerSecond: tokensPerSecond || 0
};
}
return {
getLiveGenerationStats,
getLiveProcessingStats,
getProcessingDetails,
getProcessingMessage,
getPromptProgressText,
getTechnicalDetails,
get processingState() {
return processingState;
},
getProcessingDetails,
getTechnicalDetails,
getProcessingMessage,
getPromptProgressText,
getLiveProcessingStats,
getLiveGenerationStats,
shouldShowDetails,
startMonitoring,
stopMonitoring
+10 -5
View File
@@ -1,8 +1,8 @@
import { browser } from '$app/environment';
import { useRegisterSW } from 'virtual:pwa-register/svelte';
import { versionStore } from '$lib/stores/version.svelte';
import { BUILD_VERSION_LOCALSTORAGE_KEY } from '$lib/constants/storage';
import { SW_CONFIG } from '$lib/constants/pwa';
import { BUILD_VERSION_LOCALSTORAGE_KEY } from '$lib/constants/storage';
import { versionStore } from '$lib/stores/version.svelte';
import { useRegisterSW } from 'virtual:pwa-register/svelte';
/**
* Hook for PWA service worker registration, update polling, and build version mismatch detection.
@@ -24,6 +24,7 @@ export function usePwa() {
if (swCheckInterval) {
clearInterval(swCheckInterval);
}
swCheckInterval = setInterval(async () => {
if (!r || r.installing || !navigator?.onLine) return;
@@ -35,6 +36,7 @@ export function usePwa() {
'cache-control': SW_CONFIG.UPDATE_FETCH_OPTIONS.HEADERS.CACHE_CONTROL
}
});
if (resp?.status === 200) {
await r.update();
}
@@ -53,14 +55,17 @@ export function usePwa() {
// This comparison detects server upgrades for non-PWA users.
$effect(() => {
if (!browser) return;
// PWA pages update via the service worker path; the storage check is the non-PWA fallback only
if (navigator.serviceWorker?.controller) return;
const currentVersion = versionStore.value;
if (!currentVersion) return;
try {
const storedVersion = localStorage.getItem(BUILD_VERSION_LOCALSTORAGE_KEY);
needRefreshByStorage = !!storedVersion && storedVersion !== currentVersion;
localStorage.setItem(BUILD_VERSION_LOCALSTORAGE_KEY, currentVersion);
} catch {
@@ -73,10 +78,10 @@ export function usePwa() {
get needRefresh() {
return pwaNeedRefresh;
},
updateServiceWorker,
/** Version mismatch detected via localStorage (non-PWA users) */
get needRefreshByStorage() {
return needRefreshByStorage;
}
},
updateServiceWorker
};
}
@@ -1,18 +1,18 @@
import { ReasoningEffort } from '$lib/enums';
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
import { ReasoningEffort } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import {
checkModelSupportsThinking,
loadedModelIds,
modelsStore,
propsCacheVersion,
supportsThinking
} from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import type { ReasoningEffortLevel } from '$lib/types';
import type { DatabaseMessage } from '$lib/types/database';
import {
modelsStore,
checkModelSupportsThinking,
supportsThinking,
propsCacheVersion,
loadedModelIds
} from '$lib/stores/models.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
export interface UseReasoningMenuReturn {
readonly modelSupportsThinking: boolean;
@@ -36,62 +36,64 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
const conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
// a router chat can carry reasoning from an earlier turn before the props
// cache is primed, so a model that already produced thinking still qualifies
const modelSupportsThinkingFromMessages = $derived.by(() => {
const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
if (!modelId) return false;
return conversationsStore.activeMessages.some(
(m) => m.role === 'assistant' && m.model === modelId && !!m.reasoningContent
);
});
const modelSupportsThinking = $derived.by(() => {
loadedModelIds();
propsCacheVersion();
if (isRouterMode()) {
const modelId = modelsStore.selectedModelName || conversationModel;
return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
}
return supportsThinking() || modelSupportsThinkingFromMessages;
});
const currentEffort = $derived(conversationsStore.getReasoningEffort());
const thinkingEnabled = $derived(
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
);
return {
get modelSupportsThinking() {
return modelSupportsThinking;
},
get thinkingEnabled() {
return thinkingEnabled;
get currentEffort() {
return currentEffort;
},
get isOff() {
return currentEffort === ReasoningEffort.OFF;
},
get currentEffort() {
return currentEffort;
isSelected(level: ReasoningEffortLevel): boolean {
return currentEffort === level.value;
},
get levels() {
return REASONING_EFFORT_LEVELS;
},
isSelected(level: ReasoningEffortLevel): boolean {
return currentEffort === level.value;
},
tokenLabel(level: ReasoningEffortLevel): string | null {
if (level.value === ReasoningEffort.DEFAULT) return 'Model default';
const tokens = REASONING_EFFORT_TOKENS[level.value];
if (tokens === undefined) return null;
return tokens === -1 ? 'Unlimited' : `Max ${tokens.toLocaleString()} tokens`;
get modelSupportsThinking() {
return modelSupportsThinking;
},
select(level: ReasoningEffortLevel): void {
conversationsStore.setReasoningEffort(level.value as ReasoningEffort);
},
get thinkingEnabled() {
return thinkingEnabled;
},
tokenLabel(level: ReasoningEffortLevel): string | null {
if (level.value === ReasoningEffort.DEFAULT) return 'Model default';
const tokens = REASONING_EFFORT_TOKENS[level.value];
if (tokens === undefined) return null;
return tokens === -1 ? 'Unlimited' : `Max ${tokens.toLocaleString()} tokens`;
}
};
}
@@ -20,6 +20,7 @@ export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
$effect(() => {
const trigger = opts.getTrigger();
if (trigger === undefined) return;
// Skip the initial run on mount: the list opens with the first row
@@ -27,18 +28,23 @@ export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
// positioned, which would scroll the whole page to the top.
if (lastTrigger === null) {
lastTrigger = trigger;
return;
}
if (trigger === lastTrigger) return;
lastTrigger = trigger;
untrack(() => {
const container = opts.getContainer();
const index = opts.getIndex();
if (!container || index < 0 || index >= opts.getCount()) return;
const row = container.querySelector(
`[data-${opts.dataIndex}-index="${index}"]`
) as HTMLElement | null;
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
});
@@ -8,28 +8,30 @@ export function useScrollCarousel() {
const containerRect = scrollContainer.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const elementCenter = elementRect.left + elementRect.width / 2;
const containerCenter = containerRect.left + containerRect.width / 2;
const scrollOffset = elementCenter - containerCenter;
scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
scrollContainer.scrollBy({ behavior: 'smooth', left: scrollOffset });
}
function scrollLeft() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
scrollContainer.scrollBy({ behavior: 'smooth', left: -250 });
}
function scrollRight() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
scrollContainer.scrollBy({ behavior: 'smooth', left: 250 });
}
function updateScrollButtons() {
if (!scrollContainer) return;
const { scrollLeft: sl, scrollWidth, clientWidth } = scrollContainer;
const { clientWidth, scrollLeft: sl, scrollWidth } = scrollContainer;
canScrollLeft = sl > 0;
canScrollRight = sl < scrollWidth - clientWidth - 1;
}
@@ -53,9 +55,9 @@ export function useScrollCarousel() {
set scrollContainer(el: HTMLDivElement | undefined) {
scrollContainer = el;
},
scrollToCenter,
scrollLeft,
scrollRight,
scrollToCenter,
updateScrollButtons
};
}
@@ -1,7 +1,7 @@
import { page } from '$app/state';
import { beforeNavigate } from '$app/navigation';
import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
import { page } from '$app/state';
import { ROUTES } from '$lib/constants/routes';
import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
export interface ChatSettings {
reset: () => void;
@@ -12,10 +12,9 @@ export function useSettingsNavigation() {
activePanel: 'chat' as 'chat' | 'settings' | 'mcp',
chatSettingsRef: undefined as ChatSettings | undefined
});
const isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings'));
beforeNavigate(({ to, from }) => {
beforeNavigate(({ from, to }) => {
if (to?.route?.id?.startsWith('/settings') && !from?.route?.id?.startsWith('/settings')) {
settingsReferrer.url = window.location.hash || ROUTES.START;
}
@@ -35,12 +34,12 @@ export function useSettingsNavigation() {
});
return {
get panel() {
return subroute;
},
get isSettingsRoute() {
return isSettingsRoute;
},
get panel() {
return subroute;
}
};
}
@@ -1,10 +1,10 @@
import { CLI_FLAGS } from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
import { ToolSource } from '$lib/enums';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { ToolGroup } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
export interface UseToolsPanelReturn {
readonly expandedGroups: SvelteSet<string>;
@@ -31,7 +31,6 @@ export interface UseToolsPanelReturn {
*/
export function useToolsPanel(): UseToolsPanelReturn {
const expandedGroups = new SvelteSet<string>();
const groups = $derived(toolsStore.toolGroups);
const activeGroups = $derived(
groups.filter(
@@ -44,13 +43,17 @@ export function useToolsPanel(): UseToolsPanelReturn {
const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
const noToolsInfoMessage = $derived.by(() => {
if (toolsStore.loading) return null;
if (toolsStore.toolGroups.length > 0) return null;
// Tools endpoint is unreachable (404) — server started without --tools
if (toolsStore.isToolsEndpointUnreachable) {
return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} <name> flag. To see MCP Tools you need to add / enable MCP Server(s).`;
}
// Other errors — return null so UI shows "Failed to load tools"
if (toolsStore.error) return null;
return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} <name> flag. To see MCP Tools you need to add / enable MCP Server(s).`;
});
@@ -87,7 +90,9 @@ export function useToolsPanel(): UseToolsPanelReturn {
function toggleGroupByKey(key: string): void {
// Find current group by key to get up-to-date tool references
const group = activeGroups.find((g) => g.key === key);
if (!group) return;
toolsStore.toggleGroup(group);
}
@@ -95,29 +100,30 @@ export function useToolsPanel(): UseToolsPanelReturn {
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
toolsStore.fetchBuiltinTools();
}
mcpStore.runHealthChecksForServers(mcpStore.getServers().filter((s) => s.enabled));
}
return {
expandedGroups,
get groups() {
return groups;
},
get activeGroups() {
return activeGroups;
},
get totalToolCount() {
return totalToolCount;
expandedGroups,
getEnabledToolCount,
getFavicon,
get groups() {
return groups;
},
handleOpen,
isGroupChecked,
isGroupDisabled,
get noToolsInfoMessage() {
return noToolsInfoMessage;
},
isGroupChecked,
getEnabledToolCount,
getFavicon,
isGroupDisabled,
toggleGroupExpanded,
toggleGroupByKey,
handleOpen
toggleGroupExpanded,
get totalToolCount() {
return totalToolCount;
}
};
}