Inital commit

This commit is contained in:
2026-07-29 01:00:10 -05:00
commit 23e8ea90e4
3301 changed files with 1376308 additions and 0 deletions
@@ -0,0 +1,82 @@
import { page } from '$app/state';
import { AttachmentAction } from '$lib/enums';
export interface AttachmentModalityFlags {
hasVisionModality: boolean;
hasAudioModality: boolean;
hasVideoModality: boolean;
hasMcpPromptsSupport: boolean;
hasMcpResourcesSupport: boolean;
}
export interface AttachmentActionCallbacks {
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
}
export interface UseAttachmentMenuReturn {
readonly callbacks: Record<string, () => void>;
isItemEnabled(enabledWhen: string | undefined): boolean;
isItemVisible(visibleWhen: string | undefined): boolean;
getSystemMessageTooltip(): string;
}
/**
* useAttachmentMenu - Shared logic for attachment menu components.
*
* Encapsulates the modality-flag checks and callback wrapping that is
* identical across the desktop dropdown (`ChatFormActionAddDropdown`)
* and the mobile sheet (`ChatFormActionAddSheet`).
*
* @param getFlags - Getter returning the current modality capability flags.
* @param getCallbacks - Getter returning the raw action callbacks from props.
* @param close - Function that dismisses the hosting UI element (dropdown / sheet).
*/
export function useAttachmentMenu(
getFlags: () => AttachmentModalityFlags,
getCallbacks: () => AttachmentActionCallbacks,
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)
};
});
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];
}
function getSystemMessageTooltip(): string {
return !page.params.id
? 'Add custom system message for a new conversation'
: 'Inject custom system message at the beginning of the conversation';
}
return {
get callbacks() {
return callbacks;
},
isItemEnabled,
isItemVisible,
getSystemMessageTooltip
};
}
@@ -0,0 +1,217 @@
import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants';
export interface AutoScrollOptions {
disabled?: boolean;
}
/**
* Creates an auto-scroll controller for a scrollable container.
*
* Features:
* - Auto-scrolls to bottom during streaming/loading
* - Stops auto-scroll when user manually scrolls up
* - Resumes auto-scroll when user scrolls back to bottom
*/
export class AutoScrollController {
private _autoScrollEnabled = $state(true);
private _userScrolledUp = $state(false);
private _lastScrollTop = $state(0);
private _scrollInterval: ReturnType<typeof setInterval> | undefined;
private _container: HTMLElement | undefined;
private _disabled: boolean;
private _mutationObserver: MutationObserver | null = null;
private _rafPending = false;
private _observerEnabled = false;
constructor(options: AutoScrollOptions = {}) {
this._disabled = options.disabled ?? false;
}
get autoScrollEnabled(): boolean {
return this._autoScrollEnabled;
}
get userScrolledUp(): boolean {
return this._userScrolledUp;
}
/**
* Binds the controller to a scrollable container element.
*/
setContainer(container: HTMLElement | undefined): void {
this._doStopObserving();
this._container = container;
if (this._observerEnabled && container && !this._disabled) {
this._doStartObserving();
}
}
/**
* Updates the disabled state.
*/
setDisabled(disabled: boolean): void {
if (this._disabled === disabled) return;
this._disabled = disabled;
if (disabled) {
this._autoScrollEnabled = false;
this.stopInterval();
this._doStopObserving();
} else if (this._observerEnabled && this._container && !this._mutationObserver) {
this._doStartObserving();
}
}
/**
* Handles scroll events to detect user scroll direction and toggle auto-scroll.
*/
handleScroll(): void {
if (this._disabled || !this._container) return;
const { scrollTop, scrollHeight, clientHeight } = this._container;
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
const isScrollingUp = scrollTop < this._lastScrollTop;
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
if (isScrollingUp && !isAtBottom) {
this._userScrolledUp = true;
this._autoScrollEnabled = false;
} else if (isAtBottom && this._userScrolledUp) {
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
this._lastScrollTop = scrollTop;
}
/**
* Scrolls the container to the bottom instantly.
*/
scrollToBottom(): void {
if (this._disabled || !this._container) return;
this._container.scrollTop = this._container.scrollHeight;
}
/**
* Enables auto-scroll (e.g., when user sends a message).
*/
enable(): void {
if (this._disabled) return;
this._userScrolledUp = false;
this._autoScrollEnabled = true;
}
/**
* Resets scroll state when switching conversations.
*/
resetScrollState(): void {
this._userScrolledUp = false;
this._autoScrollEnabled = !this._disabled;
if (this._container) {
this._lastScrollTop = this._container.scrollTop;
}
}
/**
* Starts the auto-scroll interval for continuous scrolling during streaming.
*/
startInterval(): void {
if (this._disabled || this._scrollInterval) return;
this._scrollInterval = setInterval(() => {
this.scrollToBottom();
}, AUTO_SCROLL_INTERVAL);
}
/**
* Stops the auto-scroll interval.
*/
stopInterval(): void {
if (this._scrollInterval) {
clearInterval(this._scrollInterval);
this._scrollInterval = undefined;
}
}
/**
* Updates the auto-scroll interval based on streaming state.
* Call this in a $effect to automatically manage the interval.
*/
updateInterval(isStreaming: boolean): void {
if (this._disabled) {
this.stopInterval();
return;
}
if (isStreaming && this._autoScrollEnabled) {
if (!this._scrollInterval) {
this.startInterval();
}
} else {
this.stopInterval();
}
}
/**
* Cleans up resources. Call this in onDestroy or when the component unmounts.
*/
destroy(): void {
this.stopInterval();
this._doStopObserving();
}
/**
* Starts a MutationObserver on the container that auto-scrolls to bottom
* on content changes. More responsive than interval-based polling.
*/
startObserving(): void {
this._observerEnabled = true;
if (this._container && !this._disabled && !this._mutationObserver) {
this._doStartObserving();
}
}
/**
* Stops the MutationObserver.
*/
stopObserving(): void {
this._observerEnabled = false;
this._doStopObserving();
}
private _doStartObserving(): void {
if (!this._container || this._mutationObserver) return;
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;
}
});
});
this._mutationObserver.observe(this._container, {
childList: true,
subtree: true,
characterData: true
});
}
private _doStopObserving(): void {
if (this._mutationObserver) {
this._mutationObserver.disconnect();
this._mutationObserver = null;
}
this._rafPending = false;
}
}
/**
* Creates a new AutoScrollController instance.
*/
export function createAutoScrollController(options: AutoScrollOptions = {}): AutoScrollController {
return new AutoScrollController(options);
}
@@ -0,0 +1,100 @@
/**
* Active model resolution and capability detection for the ChatScreen.
*
* Picks the model that should be used for the current view
* (router: user-selected or conversation fallback; non-router: first
* available option), and reactively tracks which modalities (vision /
* audio / video) it supports — fetching model props from the server on
* 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';
export function useChatScreenActiveModel() {
const isRouter = $derived(isRouterMode());
const conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
const activeModelId = $derived.by(() => {
const options = modelOptions();
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
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;
}
return null;
});
let modelPropsVersion = $state(0);
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
}
});
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 conversationModel() {
return conversationModel;
},
get activeModelId() {
return activeModelId;
},
get hasAudioModality() {
return hasAudioModality;
},
get hasVideoModality() {
return hasVideoModality;
},
get hasVisionModality() {
return hasVisionModality;
}
};
}
@@ -0,0 +1,72 @@
/**
* Drag-and-drop state machine for the ChatScreen.
*
* Tracks pointer enter/leave nesting so the overlay stays visible while the
* cursor traverses child elements, then routes the dropped files either to
* the active message-edit handler (if a message is being edited) or to the
* caller's onDrop callback.
*/
import { getAddFilesHandler, isEditing } from '$lib/stores/chat.svelte';
interface UseChatScreenDragAndDropOptions {
/** Called when the user drops files and no message is being edited. */
onDrop: (files: File[]) => void;
}
export function useChatScreenDragAndDrop(options: UseChatScreenDragAndDropOptions) {
let dragCounter = $state(0);
let isDragOver = $state(false);
function handleDragEnter(event: DragEvent) {
event.preventDefault();
dragCounter++;
if (event.dataTransfer?.types.includes('Files')) {
isDragOver = true;
}
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
dragCounter--;
if (dragCounter === 0) {
isDragOver = false;
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
isDragOver = false;
dragCounter = 0;
if (!event.dataTransfer?.files) return;
const files = Array.from(event.dataTransfer.files);
if (isEditing()) {
const handler = getAddFilesHandler();
if (handler) {
handler(files);
return;
}
}
options.onDrop(files);
}
return {
get isDragOver() {
return isDragOver;
},
dragHandlers: {
dragenter: handleDragEnter,
dragleave: handleDragLeave,
dragover: handleDragOver,
drop: handleDrop
}
};
}
@@ -0,0 +1,104 @@
/**
* File upload lifecycle for the ChatScreen form.
*
* Owns the queue of processed `ChatUploadedFile`, the rejection-by-capability
* dialog state, and the dual-layer validation pipeline (general format +
* model modality). The caller provides the active model's capabilities and ID
* as reactive getters so validation tracks the model in real time.
*/
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
interface UseChatScreenFileUploadOptions {
capabilities: () => { hasVision: boolean; hasAudio: boolean; hasVideo: boolean };
activeModelId: () => string | null | undefined;
}
export interface FileErrorData {
generallyUnsupported: File[];
modalityUnsupported: File[];
modalityReasons: Record<string, string>;
supportedTypes: string[];
}
export function useChatScreenFileUpload(options: UseChatScreenFileUploadOptions) {
let uploadedFiles = $state<ChatUploadedFile[]>([]);
let showFileErrorDialog = $state(false);
let fileErrorData = $state<FileErrorData>({
generallyUnsupported: [],
modalityUnsupported: [],
modalityReasons: {},
supportedTypes: []
});
async function processFiles(files: File[]) {
const generallySupported: File[] = [];
const generallyUnsupported: File[] = [];
for (const file of files) {
if (isFileTypeSupported(file.name, file.type)) {
generallySupported.push(file);
} else {
generallyUnsupported.push(file);
}
}
const { supportedFiles, unsupportedFiles, modalityReasons } = 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,
supportedTypes
};
showFileErrorDialog = true;
}
if (supportedFiles.length > 0) {
const processed = await processFilesToChatUploaded(
supportedFiles,
options.activeModelId() ?? undefined
);
uploadedFiles = [...uploadedFiles, ...processed];
}
}
function handleFileUpload(files: File[]) {
return processFiles(files);
}
function handleFileRemove(fileId: string) {
uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
}
return {
get uploadedFiles() {
return uploadedFiles;
},
set uploadedFiles(value) {
uploadedFiles = value;
},
get showFileErrorDialog() {
return showFileErrorDialog;
},
set showFileErrorDialog(value) {
showFileErrorDialog = value;
},
fileErrorData,
handleFileUpload,
handleFileRemove
};
}
@@ -0,0 +1,47 @@
/**
* Scroll container binding and navigation guard for the ChatScreen.
*
* Binds the `AutoScrollController` to `document.documentElement`, exposes
* the container for programmatic scrolling, and flags an `isNavigating`
* window during route changes so the controller can reset without its
* scroll handler seeing spurious events from layout shifts.
*/
import { afterNavigate, beforeNavigate } from '$app/navigation';
import type { AutoScrollController } from './use-auto-scroll.svelte';
export function useChatScreenScroll(autoScroll: AutoScrollController) {
let chatScrollContainer: HTMLElement | undefined = $state();
let isNavigating = $state(false);
function handleScroll(event: UIEvent) {
// 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();
}
beforeNavigate(() => {
isNavigating = true;
autoScroll.resetScrollState();
});
afterNavigate(() => {
setTimeout(() => {
isNavigating = false;
autoScroll.resetScrollState();
}, 10);
});
$effect(() => {
chatScrollContainer = document.documentElement;
autoScroll.setContainer(chatScrollContainer);
});
return {
get chatScrollContainer() {
return chatScrollContainer;
},
handleScroll
};
}
@@ -0,0 +1,295 @@
/**
* Reactive state for the context usage gauge: resolves the active model,
* fetches its cached props, parses live server stats, and exposes per-turn
* read / fresh / cache / output and cumulative token counts.
*/
import {
modelsStore,
modelOptions,
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;
promptTokens: number;
cacheTokens: number;
outputTokens: number;
}
export interface UseContextGaugeReturn {
readonly activeModelId: string | null;
readonly isActiveModelLoaded: boolean;
readonly isActiveModelLoading: boolean;
readonly contextTotal: number | null;
readonly contextUsed: number;
readonly currentRead: number;
readonly currentFresh: number;
readonly currentCache: number;
readonly currentOutput: number;
readonly kvTotal: number;
readonly cumulativeRead: number;
readonly cumulativeOutput: number;
readonly cumulativeCacheTotal: number;
readonly averageTokensPerSecond: number | null;
readonly contextPercent: number | null;
readonly colorLevel: ColorLevel;
readonly transientDetails: string[];
readonly hasAnyUsage: boolean;
loadModel(): Promise<void>;
startMonitoring(): void;
}
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;
}
function deriveLiveStats(
state: ReturnType<typeof useProcessingState>['processingState']
): LiveStats | null {
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
};
}
const TRANSIENT_DETAILS_EXCLUDED_PREFIXES = ['Context:', 'Output:'];
function filterTransientDetails(raw: string[]): string[] {
return raw.filter((detail) => {
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(() => {
if (!isRouterMode()) {
return singleModelName();
}
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)
);
// Pull /props on demand so n_ctx surfaces before the first chat request.
$effect(() => {
if (activeModelId && isActiveModelLoaded) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
void modelsStore.fetchModelProps(activeModelId);
}
}
});
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(
(m) => m.role === MessageRole.ASSISTANT && m.timings?.agentic?.llm?.predicted_n != null
);
if (agenticMessages.length > 0) {
const llm = agenticMessages[agenticMessages.length - 1].timings!.agentic!.llm;
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,
cacheTotal: 0,
averageTokensPerSecond
};
}
let read = 0;
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 };
});
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 ||
currentRead > 0 ||
currentOutput > 0 ||
cumulative.averageTokensPerSecond !== null ||
transientDetails.length > 0
);
async function loadModel() {
if (!activeModelId || isActiveModelLoading) return;
try {
await modelsStore.loadModel(activeModelId);
} catch {
// toast already surfaced by modelsStore.loadModel
}
}
return {
get activeModelId() {
return activeModelId;
},
get isActiveModelLoaded() {
return isActiveModelLoaded;
},
get isActiveModelLoading() {
return isActiveModelLoading;
},
get contextTotal() {
return contextTotal;
},
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 cumulativeOutput() {
return cumulative.output;
},
get cumulativeCacheTotal() {
return cumulative.cacheTotal;
},
get averageTokensPerSecond() {
return cumulative.averageTokensPerSecond;
},
get contextPercent() {
return contextPercent;
},
get colorLevel() {
return colorLevel;
},
get transientDetails() {
return transientDetails;
},
get hasAnyUsage() {
return hasAnyUsage;
},
loadModel,
startMonitoring: () => processingState.startMonitoring()
};
}
@@ -0,0 +1,45 @@
import { onMount } from 'svelte';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { draftMessagesStore } from '$lib/stores/draft-messages.svelte';
interface UseDraftMessagesOptions {
getChatId: () => string | undefined;
getMessage: () => string;
getFiles: () => ChatUploadedFile[];
setMessage: (message: string) => void;
setFiles: (files: ChatUploadedFile[]) => void;
getInitialMessage: () => string;
}
export function useDraftMessages(options: UseDraftMessagesOptions) {
onMount(() => {
const chatId = options.getChatId();
const draft = draftMessagesStore.getDraftMessage(chatId);
if ((draft.message || draft.files.length > 0) && !options.getInitialMessage()) {
options.setMessage(draft.message);
options.setFiles(draft.files);
}
});
beforeNavigate(() => {
const chatId = options.getChatId();
draftMessagesStore.saveDraftMessage(chatId, options.getMessage(), options.getFiles());
});
afterNavigate((navigation) => {
if (navigation?.from != null) {
const chatId = options.getChatId();
const draft = draftMessagesStore.getDraftMessage(chatId);
options.setMessage(draft.message);
options.setFiles(draft.files);
}
});
function clearDraft() {
const chatId = options.getChatId();
draftMessagesStore.clearDraftMessage(chatId);
}
return { clearDraft };
}
@@ -0,0 +1,66 @@
import { goto } from '$app/navigation';
import { KeyboardKey } from '$lib/enums';
import { ROUTES } from '$lib/constants/routes';
interface KeyboardShortcutsCallbacks {
activateSearchMode?: () => void;
editActiveConversation?: () => void;
onSearchActivated?: () => void;
deleteActiveConversation?: () => void;
navigateToPrevConversation?: () => void;
navigateToNextConversation?: () => void;
toggleSidebar?: () => void;
}
export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
function handleKeydown(event: KeyboardEvent) {
const isCmdOrCtrl = event.metaKey || event.ctrlKey;
if (isCmdOrCtrl && event.key === KeyboardKey.K_LOWER) {
event.preventDefault();
callbacks.activateSearchMode?.();
callbacks.onSearchActivated?.();
}
if (isCmdOrCtrl && event.key === KeyboardKey.B_LOWER) {
event.preventDefault();
callbacks.toggleSidebar?.();
}
if (
isCmdOrCtrl &&
event.shiftKey &&
(event.key === KeyboardKey.O_LOWER || event.key === KeyboardKey.O_UPPER)
) {
event.preventDefault();
goto(ROUTES.NEW_CHAT);
}
if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) {
event.preventDefault();
callbacks.editActiveConversation?.();
}
if (
isCmdOrCtrl &&
event.shiftKey &&
(event.key === KeyboardKey.D_LOWER || event.key === KeyboardKey.D_UPPER)
) {
event.preventDefault();
callbacks.deleteActiveConversation?.();
}
if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
callbacks.navigateToPrevConversation?.();
}
if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
callbacks.navigateToNextConversation?.();
}
}
return { handleKeydown };
}
@@ -0,0 +1,239 @@
/**
* Reusable selection state-machine: shift+click/shift+drag range select plus
* rubber-band marquee drag, anchored on the last clicked row. Both the sidebar
* conversation list and the dialog conversations table share this.
*
* The hook mutates the consumer's SvelteSet<string> directly; the consumer
* owns the source of truth and reads it like any other $state. orderedIds
* must reflect the current visual order of selectable rows so the range
* matches what the user sees on screen.
*/
import { SvelteSet } from 'svelte/reactivity';
interface UseMarqueeSelectionOptions {
/** Latest selected-IDs set. Re-read per selection event so consumer-side reassignment works. */
selectedIds: () => SvelteSet<string>;
/** IDs in the current rendered order; used to compute shift+click ranges and gate marquee visibility. */
orderedIds: () => string[];
/** Document listeners attach only while the getter returns true. */
enabled: () => boolean;
/** DOM attribute key (after the `data-` prefix) that marks selectable rows. */
attributeName?: () => string;
/** Minimum pixel distance before a press becomes a marquee drag. */
dragThresholdPx?: number;
}
export function useMarqueeSelection(options: UseMarqueeSelectionOptions) {
const dragThresholdPx = options.dragThresholdPx ?? 5;
let dragAnchorId = $state<string | null>(null);
let isMarqueeDragging = $state(false);
let mouseDownActive = false;
let dragStartX = 0;
let dragStartY = 0;
let mousedownRowId: string | null = null;
let dragMode: 'add' | 'remove' | null = null;
let suppressNextClick = false;
function resolveAttributeName(): string {
return options.attributeName?.() ?? 'conversation-row';
}
/**
* `dataset` keys are camelCased. `data-conversation-row` -> `conversationRow`.
* We resolve the attribute name once per call and read via the camelCase key.
*/
function datasetKey(key: string = resolveAttributeName()): string {
return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
function decideDragMode(startingRowId: string | null, currentlySelected: ReadonlySet<string>) {
return startingRowId !== null && currentlySelected.has(startingRowId) ? 'remove' : 'add';
}
/**
* Range-select uses Finder-style toggle-by-target semantics: if the target
* row is currently selected the range becomes deselected, otherwise it
* becomes selected. Anchor moves to `toId` so chained shift+clicks keep
* extending from the previous endpoint.
*/
function rangeSelect(fromId: string, toId: string) {
const selected = options.selectedIds();
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);
}
}
function findRowAtPoint(x: number, y: number): string | null {
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;
}
function updateMarqueeRect(currentX: number, currentY: number) {
const attr = resolveAttributeName();
const selector = `[data-${attr}]`;
const key = datasetKey(attr);
const selected = options.selectedIds();
const left = Math.min(dragStartX, currentX);
const top = Math.min(dragStartY, currentY);
const right = Math.max(dragStartX, currentX);
const bottom = Math.max(dragStartY, currentY);
const visibleIds = new SvelteSet(options.orderedIds());
for (const row of document.querySelectorAll<HTMLElement>(selector)) {
const id = row.dataset[key];
if (!id || !visibleIds.has(id)) continue;
const rect = row.getBoundingClientRect();
const intersects = !(
rect.right < left ||
rect.left > right ||
rect.bottom < top ||
rect.top > bottom
);
if (dragMode === 'add') {
if (intersects) selected.add(id);
} else if (dragMode === 'remove') {
if (intersects && selected.has(id)) selected.delete(id);
}
}
}
function handleDocumentMouseMove(event: MouseEvent) {
if (!mouseDownActive) return;
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);
}
function handleDocumentMouseUp(event: MouseEvent) {
if (isMarqueeDragging) {
suppressNextClick = true;
const target = findRowAtPoint(event.clientX, event.clientY);
if (target) dragAnchorId = target;
}
isMarqueeDragging = false;
mouseDownActive = false;
mousedownRowId = null;
dragMode = null;
dragStartX = 0;
dragStartY = 0;
}
function handleClickCapture(event: MouseEvent) {
if (suppressNextClick) {
event.stopPropagation();
event.preventDefault();
suppressNextClick = false;
}
}
$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);
document.removeEventListener('click', handleClickCapture, { capture: true });
};
});
function rowMouseDown(id: string, event: MouseEvent) {
if (!options.enabled()) return;
if (event.button !== 0) return;
event.preventDefault();
mouseDownActive = true;
mousedownRowId = id;
dragStartX = event.clientX;
dragStartY = event.clientY;
isMarqueeDragging = false;
dragMode = null;
}
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)) {
selected.delete(id);
} else {
selected.add(id);
}
dragAnchorId = id;
return;
}
if (selected.has(id)) selected.delete(id);
else selected.add(id);
dragAnchorId = id;
}
function reset() {
dragAnchorId = null;
isMarqueeDragging = false;
mouseDownActive = false;
suppressNextClick = false;
mousedownRowId = null;
dragMode = null;
dragStartX = 0;
dragStartY = 0;
}
return {
rowMouseDown,
rowClick,
reset,
get dragAnchorId() {
return dragAnchorId;
}
};
}
@@ -0,0 +1,99 @@
import { setMessageEditContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
import { parseFilesToMessageExtras } from '$lib/utils/convert-files-to-extra';
interface UseMessageEditContextOptions {
getContent: () => string;
getExtras: () => DatabaseMessageExtra[];
showSaveOnlyOption?: boolean;
onSave: (content: string, extras?: DatabaseMessageExtra[]) => void;
}
export function useMessageEditContext(options: UseMessageEditContextOptions) {
let isEditing = $state(false);
let editedContent = $state('');
let editedExtras = $state<DatabaseMessageExtra[]>([]);
let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
function handleEdit() {
editedContent = options.getContent();
editedExtras = [...options.getExtras()];
editedUploadedFiles = [];
isEditing = true;
}
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];
}
options.onSave(trimmed, finalExtras.length > 0 ? finalExtras : undefined);
isEditing = false;
}
function handleCancelEdit() {
isEditing = false;
}
setMessageEditContext({
get isEditing() {
return isEditing;
},
get editedContent() {
return editedContent;
},
get editedExtras() {
return editedExtras;
},
get editedUploadedFiles() {
return editedUploadedFiles;
},
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;
},
setContent: (c: string) => {
editedContent = c;
},
setExtras: (e: DatabaseMessageExtra[]) => {
editedExtras = e;
},
setUploadedFiles: (f: ChatUploadedFile[]) => {
editedUploadedFiles = f;
},
save: handleSaveEdit,
saveOnly: handleSaveEdit,
cancel: handleCancelEdit,
startEdit: handleEdit
});
return {
get isEditing() {
return isEditing;
},
handleEdit,
handleSaveEdit,
handleCancelEdit
};
}
@@ -0,0 +1,273 @@
import { onMount } from 'svelte';
import {
modelsStore,
modelOptions,
modelsLoading,
modelsUpdating,
selectedModelId,
singleModelName
} from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import type { ModelOption } from '$lib/types/models';
export interface UseModelsSelectorOptions {
currentModel: () => string | null;
useGlobalSelection?: () => boolean;
onModelChange?: () =>
| ((modelId: string, modelName: string) => Promise<boolean> | boolean | void)
| undefined;
onOpenChange?: (open: boolean) => void;
}
export interface UseModelsSelectorReturn {
readonly options: ModelOption[];
readonly loading: boolean;
readonly updating: boolean;
readonly activeId: string | null;
readonly isRouter: boolean;
readonly serverModel: string | null;
readonly isHighlightedCurrentModelActive: boolean;
readonly isCurrentModelInCache: boolean;
readonly filteredOptions: ModelOption[];
readonly groupedFilteredOptions: ReturnType<typeof groupModelOptions>;
readonly isLoadingModel: boolean;
readonly searchTerm: string;
readonly showModelDialog: boolean;
readonly infoModelId: string | null;
setSearchTerm(value: string): void;
setShowModelDialog(value: boolean): void;
handleInfoClick(modelName: string): void;
handleSelect(modelId: string): Promise<void>;
handleOpenChange(open: boolean): void;
isFavorite(model: string): boolean;
getDisplayOption(): ModelOption | undefined;
}
/**
* Shared reactive state and logic for model selection.
*
* Used by both the desktop dropdown (`ModelsSelectorDropdown`)
* and the mobile sheet (`ModelsSelectorSheet`) to avoid
* duplicating store derivations, selection handling, and model loading.
*/
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
const options = $derived(
modelOptions().filter((option) => {
const modelProps = modelsStore.getModelProps(option.model);
return modelProps?.ui !== false;
})
);
const loading = $derived(modelsLoading());
const updating = $derived(modelsUpdating());
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);
});
let isLoadingModel = $state(false);
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) =>
modelsStore.isModelLoaded(m)
)
);
function handleInfoClick(modelName: string) {
infoModelId = modelName;
showModelDialog = true;
}
onMount(() => {
modelsStore.fetch().catch((error) => {
console.error('Unable to load models:', error);
});
});
function handleOpenChange(open: boolean) {
if (loading || updating) return;
if (isRouter) {
searchTerm = '';
if (open) {
modelsStore.fetchRouterModels().then(() => {
modelsStore.fetchModalitiesForLoadedModels();
});
}
opts.onOpenChange?.(open);
} else {
showModelDialog = open;
}
}
async function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
if (!option) return;
let shouldCloseMenu = true;
if (onModelChange) {
const result = await onModelChange(option.id, option.model);
if (result === false) {
shouldCloseMenu = false;
}
} else {
await modelsStore.selectModelById(option.id);
}
if (shouldCloseMenu) {
handleOpenChange(false);
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-slot="chat-form"] textarea'
);
textarea?.focus({ preventScroll: true });
});
}
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore
.loadModel(option.model)
.catch((error) => console.error('Failed to load model:', error))
.finally(() => (isLoadingModel = false));
}
}
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
const displayModel = serverModel || currentModel;
if (displayModel) {
return {
id: serverModel ? 'current' : 'offline-current',
model: displayModel,
name: displayModel.split('/').pop() || displayModel,
capabilities: []
};
}
return undefined;
}
if (currentModel) {
if (!isCurrentModelInCache) {
return {
id: 'not-in-cache',
model: currentModel,
name: currentModel.split('/').pop() || currentModel,
capabilities: []
};
}
return options.find((option) => option.model === currentModel);
}
if (activeId) {
return options.find((option) => option.id === activeId);
}
return undefined;
}
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;
},
get groupedFilteredOptions() {
return groupedFilteredOptions;
},
get isLoadingModel() {
return isLoadingModel;
},
get searchTerm() {
return searchTerm;
},
get showModelDialog() {
return showModelDialog;
},
get infoModelId() {
return infoModelId;
},
setSearchTerm(value: string) {
searchTerm = value;
},
setShowModelDialog(value: boolean) {
showModelDialog = value;
},
handleInfoClick,
handleSelect,
handleOpenChange,
isFavorite(model: string) {
return modelsStore.favoriteModelIds.has(model);
},
getDisplayOption
};
}
@@ -0,0 +1,317 @@
import { activeProcessingState } from '$lib/stores/chat.svelte';
import { STATS_UNITS } from '$lib/constants';
import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types';
export interface UseProcessingStateReturn {
readonly processingState: ApiProcessingState | null;
getProcessingDetails(): string[];
getTechnicalDetails(): string[];
getProcessingMessage(): string;
getPromptProgressText(): string | null;
getLiveProcessingStats(): LiveProcessingStats | null;
getLiveGenerationStats(): LiveGenerationStats | null;
shouldShowDetails(): boolean;
startMonitoring(): void;
stopMonitoring(): void;
}
/**
* useProcessingState - Reactive processing state hook
*
* This hook provides reactive access to the processing state of the server.
* It directly reads from chatStore's reactive state and provides
* formatted processing details for UI display.
*
* **Features:**
* - Real-time processing state via direct reactive state binding
* - Context and output token tracking
* - Tokens per second calculation
* - Automatic updates when streaming data arrives
* - Supports multiple concurrent conversations
*
* @returns Hook interface with processing state and control methods
*/
export function useProcessingState(): UseProcessingStateReturn {
let isMonitoring = $state(false);
let lastKnownState = $state<ApiProcessingState | null>(null);
let lastKnownProcessingStats = $state<LiveProcessingStats | null>(null);
// Derive processing state reactively from chatStore's direct state
const processingState = $derived.by(() => {
if (!isMonitoring) {
return lastKnownState;
}
// Read directly from the reactive state export
return activeProcessingState();
});
$effect(() => {
if (processingState && isMonitoring) {
lastKnownState = processingState;
}
});
// Track last known processing stats for when promptProgress disappears
$effect(() => {
if (processingState?.promptProgress) {
const { processed, total, time_ms, cache } = 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
};
}
}
});
function getETASecs(done: number, total: number, elapsedMs: number): number | undefined {
const elapsedSecs = elapsedMs / 1000;
const progressETASecs =
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;
}
function stopMonitoring(): void {
if (!isMonitoring) return;
isMonitoring = false;
}
function getProcessingMessage(): string {
if (!processingState) {
return 'Processing...';
}
switch (processingState.status) {
case 'initializing':
return 'Initializing...';
case 'preparing':
if (processingState.progressPercent !== undefined) {
return `Processing (${processingState.progressPercent}%)`;
}
return 'Preparing response...';
case 'generating':
return '';
default:
return 'Processing...';
}
}
function getProcessingDetails(): string[] {
// Use current processing state or fall back to last known state
const stateToUse = processingState || lastKnownState;
if (!stateToUse) {
return [];
}
const details: string[] = [];
// Show prompt processing progress with ETA during preparation phase
if (stateToUse.promptProgress) {
const { processed, total, time_ms, cache } = stateToUse.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
if (actualProcessed < actualTotal && actualProcessed > 0) {
const percent = Math.round((actualProcessed / actualTotal) * 100);
const eta = getETASecs(actualProcessed, actualTotal, time_ms);
if (eta !== undefined) {
const etaSecs = Math.ceil(eta);
details.push(`Processing ${percent}% (ETA: ${etaSecs}s)`);
} else {
details.push(`Processing ${percent}%`);
}
}
}
// Always show context info when we have valid data
if (
typeof stateToUse.contextTotal === 'number' &&
stateToUse.contextUsed >= 0 &&
stateToUse.contextTotal > 0
) {
const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100);
details.push(
`Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)`
);
}
if (stateToUse.outputTokensUsed > 0) {
// Handle infinite max_tokens (-1) case
if (stateToUse.outputTokensMax <= 0) {
details.push(`Output: ${stateToUse.outputTokensUsed}/∞`);
} else {
const outputPercent = Math.round(
(stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100
);
details.push(
`Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)`
);
}
}
if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
details.push(`${stateToUse.tokensPerSecond.toFixed(1)} ${STATS_UNITS.TOKENS_PER_SECOND}`);
}
if (stateToUse.speculative) {
details.push('Speculative decoding enabled');
}
return details;
}
/**
* Returns technical details without the progress message (for bottom bar)
*/
function getTechnicalDetails(): string[] {
const stateToUse = processingState || lastKnownState;
if (!stateToUse) {
return [];
}
const details: string[] = [];
// Always show context info when we have valid data
if (
typeof stateToUse.contextTotal === 'number' &&
stateToUse.contextUsed >= 0 &&
stateToUse.contextTotal > 0
) {
const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100);
details.push(
`Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)`
);
}
if (stateToUse.outputTokensUsed > 0) {
// Handle infinite max_tokens (-1) case
if (stateToUse.outputTokensMax <= 0) {
details.push(`Output: ${stateToUse.outputTokensUsed}/∞`);
} else {
const outputPercent = Math.round(
(stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100
);
details.push(
`Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)`
);
}
}
if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
details.push(`${stateToUse.tokensPerSecond.toFixed(1)} ${STATS_UNITS.TOKENS_PER_SECOND}`);
}
if (stateToUse.speculative) {
details.push('Speculative decoding enabled');
}
return details;
}
function shouldShowDetails(): boolean {
return processingState !== null && processingState.status !== 'idle';
}
/**
* Returns a short progress message with percent
*/
function getPromptProgressText(): string | null {
if (!processingState?.promptProgress) return null;
const { processed, total, cache } = processingState.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
const percent = Math.round((actualProcessed / actualTotal) * 100);
const eta = getETASecs(actualProcessed, actualTotal, processingState.promptProgress.time_ms);
if (eta !== undefined) {
const etaSecs = Math.ceil(eta);
return `Processing ${percent}% (ETA: ${etaSecs}s)`;
}
return `Processing ${percent}%`;
}
/**
* Returns live processing statistics for display (prompt processing phase)
* Returns last known stats when promptProgress becomes unavailable
*/
function getLiveProcessingStats(): LiveProcessingStats | null {
if (processingState?.promptProgress) {
const { processed, total, time_ms, cache } = processingState.promptProgress;
const actualProcessed = processed - cache;
const actualTotal = total - cache;
if (actualProcessed > 0 && time_ms > 0) {
const tokensPerSecond = actualProcessed / (time_ms / 1000);
return {
tokensProcessed: actualProcessed,
totalTokens: actualTotal,
timeMs: time_ms,
tokensPerSecond
};
}
}
// Return last known stats if promptProgress is no longer available
return lastKnownProcessingStats;
}
/**
* Returns live generation statistics for display (token generation phase)
*/
function getLiveGenerationStats(): LiveGenerationStats | null {
if (!processingState) return null;
const { tokensDecoded, tokensPerSecond } = processingState;
if (tokensDecoded <= 0) return null;
// Calculate time from tokens and speed
const timeMs =
tokensPerSecond && tokensPerSecond > 0 ? (tokensDecoded / tokensPerSecond) * 1000 : 0;
return {
tokensGenerated: tokensDecoded,
timeMs,
tokensPerSecond: tokensPerSecond || 0
};
}
return {
get processingState() {
return processingState;
},
getProcessingDetails,
getTechnicalDetails,
getProcessingMessage,
getPromptProgressText,
getLiveProcessingStats,
getLiveGenerationStats,
shouldShowDetails,
startMonitoring,
stopMonitoring
};
}
+82
View File
@@ -0,0 +1,82 @@
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';
/**
* Hook for PWA service worker registration, update polling, and build version mismatch detection.
*
* Combines two concerns that always belong together:
* 1. SW registration with periodic polling for updates
* 2. localStorage-based version tracking for non-PWA users
*/
export function usePwa() {
let swCheckInterval: ReturnType<typeof setInterval> | null = null;
let needRefreshByStorage = $state(false);
const {
// offlineReady, // to do - add installation banners for iOS
needRefresh: pwaNeedRefresh,
updateServiceWorker
} = useRegisterSW({
onRegisteredSW(swUrl: string, r: ServiceWorkerRegistration | undefined) {
if (swCheckInterval) {
clearInterval(swCheckInterval);
}
swCheckInterval = setInterval(async () => {
if (!r || r.installing || !navigator?.onLine) return;
try {
const resp = await fetch(swUrl, {
cache: SW_CONFIG.UPDATE_FETCH_OPTIONS.CACHE,
headers: {
cache: SW_CONFIG.UPDATE_FETCH_OPTIONS.HEADERS.CACHE,
'cache-control': SW_CONFIG.UPDATE_FETCH_OPTIONS.HEADERS.CACHE_CONTROL
}
});
if (resp?.status === 200) {
await r.update();
}
} catch (e) {
console.error(e);
}
}, SW_CONFIG.CHECK_INTERVAL_MS);
},
onRegisterError(error: unknown) {
console.error('[PWA] SW registration error:', error);
}
});
// Detect version mismatch via localStorage.
// _app/version.json is SvelteKit's native version file for PWA cache invalidation.
// 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 {
needRefreshByStorage = false;
}
});
return {
/** Writable that is true when a PWA service worker update is available */
get needRefresh() {
return pwaNeedRefresh;
},
updateServiceWorker,
/** Version mismatch detected via localStorage (non-PWA users) */
get needRefreshByStorage() {
return needRefreshByStorage;
}
};
}
@@ -0,0 +1,97 @@
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 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;
readonly thinkingEnabled: boolean;
readonly isOff: boolean;
readonly currentEffort: ReasoningEffort;
readonly levels: ReasoningEffortLevel[];
isSelected(level: ReasoningEffortLevel): boolean;
tokenLabel(level: ReasoningEffortLevel): string | null;
select(level: ReasoningEffortLevel): void;
}
/**
* Shared reactive state and helpers for the reasoning effort menu.
*
* Used by both the desktop dropdown (`ChatFormActionAddReasoningSubmenu`)
* and the mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
* thinking-support derivation and the effort selection logic.
*/
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 isOff() {
return currentEffort === ReasoningEffort.OFF;
},
get currentEffort() {
return currentEffort;
},
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`;
},
select(level: ReasoningEffortLevel): void {
conversationsStore.setReasoningEffort(level.value as ReasoningEffort);
}
};
}
@@ -0,0 +1,61 @@
export function useScrollCarousel() {
let canScrollLeft = $state(false);
let canScrollRight = $state(false);
let scrollContainer = $state<HTMLDivElement | undefined>();
function scrollToCenter(element: HTMLElement) {
if (!scrollContainer) return;
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' });
}
function scrollLeft() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
}
function scrollRight() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
}
function updateScrollButtons() {
if (!scrollContainer) return;
const { scrollLeft: sl, scrollWidth, clientWidth } = scrollContainer;
canScrollLeft = sl > 0;
canScrollRight = sl < scrollWidth - clientWidth - 1;
}
$effect(() => {
if (scrollContainer) {
updateScrollButtons();
}
});
return {
get canScrollLeft() {
return canScrollLeft;
},
get canScrollRight() {
return canScrollRight;
},
get scrollContainer() {
return scrollContainer;
},
set scrollContainer(el: HTMLDivElement | undefined) {
scrollContainer = el;
},
scrollToCenter,
scrollLeft,
scrollRight,
updateScrollButtons
};
}
@@ -0,0 +1,46 @@
import { page } from '$app/state';
import { beforeNavigate } from '$app/navigation';
import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
import { ROUTES } from '$lib/constants/routes';
export interface ChatSettings {
reset: () => void;
}
export function useSettingsNavigation() {
const subroute = $state({
activePanel: 'chat' as 'chat' | 'settings' | 'mcp',
chatSettingsRef: undefined as ChatSettings | undefined
});
const isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings'));
beforeNavigate(({ to, from }) => {
if (to?.route?.id?.startsWith('/settings') && !from?.route?.id?.startsWith('/settings')) {
settingsReferrer.url = window.location.hash || ROUTES.START;
}
});
$effect(() => {
if (subroute.activePanel === 'settings' && subroute.chatSettingsRef) {
subroute.chatSettingsRef.reset();
}
});
// Return to chat when navigating to a new route
$effect(() => {
void page.url;
subroute.activePanel = 'chat';
});
return {
get panel() {
return subroute;
},
get isSettingsRoute() {
return isSettingsRoute;
}
};
}
@@ -0,0 +1,123 @@
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';
export interface UseToolsPanelReturn {
readonly expandedGroups: SvelteSet<string>;
readonly groups: ToolGroup[];
readonly activeGroups: ToolGroup[];
readonly totalToolCount: number;
readonly noToolsInfoMessage: string | null;
isGroupChecked(group: ToolGroup): boolean;
getEnabledToolCount(group: ToolGroup): number;
getFavicon(group: ToolGroup): string | null;
isGroupDisabled(group: ToolGroup): boolean;
toggleGroupExpanded(key: string): void;
/** Toggle all tools in a group by its stable key (avoids stale group object references). */
toggleGroupByKey(key: string): void;
handleOpen(): void;
}
/**
* Shared reactive state and helpers for the tools panel UI.
*
* Used by both the desktop dropdown (`ChatFormActionAddToolsSubmenu`)
* and the mobile sheet (`ChatFormActionAddSheet`) to avoid
* duplicating group filtering, checked-state derivation, and favicon logic.
*/
export function useToolsPanel(): UseToolsPanelReturn {
const expandedGroups = new SvelteSet<string>();
const groups = $derived(toolsStore.toolGroups);
const activeGroups = $derived(
groups.filter(
(g) =>
g.source !== ToolSource.MCP ||
!g.serverId ||
conversationsStore.isMcpServerEnabledForChat(g.serverId)
)
);
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).`;
});
function isGroupChecked(group: ToolGroup): boolean {
return toolsStore.isGroupFullyEnabled(group);
}
function getEnabledToolCount(group: ToolGroup): number {
return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.key)).length;
}
function getFavicon(group: ToolGroup): string | null {
if (group.source !== ToolSource.MCP || !group.serverId) return null;
return mcpStore.getServerFavicon(group.serverId);
}
function isGroupDisabled(group: ToolGroup): boolean {
return (
group.source === ToolSource.MCP &&
!!group.serverId &&
!conversationsStore.isMcpServerEnabledForChat(group.serverId)
);
}
function toggleGroupExpanded(key: string): void {
if (expandedGroups.has(key)) {
expandedGroups.delete(key);
} else {
expandedGroups.add(key);
}
}
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);
}
function handleOpen(): void {
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;
},
get noToolsInfoMessage() {
return noToolsInfoMessage;
},
isGroupChecked,
getEnabledToolCount,
getFavicon,
isGroupDisabled,
toggleGroupExpanded,
toggleGroupByKey,
handleOpen
};
}