Inital commit
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user