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