WebUI Architecture Cleanup (#19541)
* webui: architecture foundation (non-MCP core refactors) * chore: update webui build output
This commit is contained in:
parent
3b3a948134
commit
38adc7d469
Binary file not shown.
|
|
@ -1,9 +1,6 @@
|
||||||
export interface BinaryDetectionOptions {
|
export interface BinaryDetectionOptions {
|
||||||
/** Number of characters to check from the beginning of the file */
|
|
||||||
prefixLength: number;
|
prefixLength: number;
|
||||||
/** Maximum ratio of suspicious characters allowed (0.0 to 1.0) */
|
|
||||||
suspiciousCharThresholdRatio: number;
|
suspiciousCharThresholdRatio: number;
|
||||||
/** Maximum absolute number of null bytes allowed */
|
|
||||||
maxAbsoluteNullBytes: number;
|
maxAbsoluteNullBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const INITIAL_FILE_SIZE = 0;
|
||||||
|
export const PROMPT_CONTENT_SEPARATOR = '\n\n';
|
||||||
|
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export const CODE_BLOCK_SCROLL_CONTAINER_CLASS = 'code-block-scroll-container';
|
||||||
|
export const CODE_BLOCK_WRAPPER_CLASS = 'code-block-wrapper';
|
||||||
|
export const CODE_BLOCK_HEADER_CLASS = 'code-block-header';
|
||||||
|
export const CODE_BLOCK_ACTIONS_CLASS = 'code-block-actions';
|
||||||
|
export const CODE_LANGUAGE_CLASS = 'code-language';
|
||||||
|
export const COPY_CODE_BTN_CLASS = 'copy-code-btn';
|
||||||
|
export const PREVIEW_CODE_BTN_CLASS = 'preview-code-btn';
|
||||||
|
export const RELATIVE_CLASS = 'relative';
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export const NEWLINE = '\n';
|
||||||
|
export const DEFAULT_LANGUAGE = 'text';
|
||||||
|
export const LANG_PATTERN = /^(\w*)\n?/;
|
||||||
|
export const AMPERSAND_REGEX = /&/g;
|
||||||
|
export const LT_REGEX = /</g;
|
||||||
|
export const GT_REGEX = />/g;
|
||||||
|
export const FENCE_PATTERN = /^```|\n```/g;
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
export const BOX_BORDER =
|
||||||
|
'border border-border/30 focus-within:border-border dark:border-border/20 dark:focus-within:border-border';
|
||||||
|
|
||||||
|
export const INPUT_CLASSES = `
|
||||||
|
bg-muted/60 dark:bg-muted/75
|
||||||
|
${BOX_BORDER}
|
||||||
|
shadow-sm
|
||||||
|
outline-none
|
||||||
|
text-foreground
|
||||||
|
`;
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export const MS_PER_SECOND = 1000;
|
||||||
|
export const SECONDS_PER_MINUTE = 60;
|
||||||
|
export const SECONDS_PER_HOUR = 3600;
|
||||||
|
export const SHORT_DURATION_THRESHOLD = 1;
|
||||||
|
export const MEDIUM_DURATION_THRESHOLD = 10;
|
||||||
|
|
||||||
|
/** Default display value when no performance time is available */
|
||||||
|
export const DEFAULT_PERFORMANCE_TIME = '0s';
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export const IMAGE_NOT_ERROR_BOUND_SELECTOR = 'img:not([data-error-bound])';
|
||||||
|
export const DATA_ERROR_BOUND_ATTR = 'errorBound';
|
||||||
|
export const DATA_ERROR_HANDLED_ATTR = 'errorHandled';
|
||||||
|
export const BOOL_TRUE_STRING = 'true';
|
||||||
|
|
@ -1 +1,8 @@
|
||||||
export const PROCESSING_INFO_TIMEOUT = 2000;
|
export const PROCESSING_INFO_TIMEOUT = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistics units labels
|
||||||
|
*/
|
||||||
|
export const STATS_UNITS = {
|
||||||
|
TOKENS_PER_SECOND: 't/s'
|
||||||
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* List of all numeric fields in settings configuration.
|
||||||
|
* These fields will be converted from strings to numbers during save.
|
||||||
|
*/
|
||||||
|
export const NUMERIC_FIELDS = [
|
||||||
|
'temperature',
|
||||||
|
'top_k',
|
||||||
|
'top_p',
|
||||||
|
'min_p',
|
||||||
|
'max_tokens',
|
||||||
|
'pasteLongTextToFileLen',
|
||||||
|
'dynatemp_range',
|
||||||
|
'dynatemp_exponent',
|
||||||
|
'typ_p',
|
||||||
|
'xtc_probability',
|
||||||
|
'xtc_threshold',
|
||||||
|
'repeat_last_n',
|
||||||
|
'repeat_penalty',
|
||||||
|
'presence_penalty',
|
||||||
|
'frequency_penalty',
|
||||||
|
'dry_multiplier',
|
||||||
|
'dry_base',
|
||||||
|
'dry_allowed_length',
|
||||||
|
'dry_penalty_last_n',
|
||||||
|
'agenticMaxTurns',
|
||||||
|
'agenticMaxToolPreviewLines'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields that must be positive integers (>= 1).
|
||||||
|
* These will be clamped to minimum 1 and rounded during save.
|
||||||
|
*/
|
||||||
|
export const POSITIVE_INTEGER_FIELDS = ['agenticMaxTurns', 'agenticMaxToolPreviewLines'] as const;
|
||||||
|
|
@ -1 +1 @@
|
||||||
export const TOOLTIP_DELAY_DURATION = 100;
|
export const TOOLTIP_DELAY_DURATION = 500;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { getContext, setContext } from 'svelte';
|
||||||
|
|
||||||
|
export interface ChatActionsContext {
|
||||||
|
copy: (message: DatabaseMessage) => void;
|
||||||
|
delete: (message: DatabaseMessage) => void;
|
||||||
|
navigateToSibling: (siblingId: string) => void;
|
||||||
|
editWithBranching: (
|
||||||
|
message: DatabaseMessage,
|
||||||
|
newContent: string,
|
||||||
|
newExtras?: DatabaseMessageExtra[]
|
||||||
|
) => void;
|
||||||
|
editWithReplacement: (
|
||||||
|
message: DatabaseMessage,
|
||||||
|
newContent: string,
|
||||||
|
shouldBranch: boolean
|
||||||
|
) => void;
|
||||||
|
editUserMessagePreserveResponses: (
|
||||||
|
message: DatabaseMessage,
|
||||||
|
newContent: string,
|
||||||
|
newExtras?: DatabaseMessageExtra[]
|
||||||
|
) => void;
|
||||||
|
regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void;
|
||||||
|
continueAssistantMessage: (message: DatabaseMessage) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAT_ACTIONS_KEY = Symbol.for('chat-actions');
|
||||||
|
|
||||||
|
export function setChatActionsContext(ctx: ChatActionsContext): ChatActionsContext {
|
||||||
|
return setContext(CHAT_ACTIONS_KEY, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatActionsContext(): ChatActionsContext {
|
||||||
|
return getContext(CHAT_ACTIONS_KEY);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
export {
|
||||||
|
getMessageEditContext,
|
||||||
|
setMessageEditContext,
|
||||||
|
type MessageEditContext,
|
||||||
|
type MessageEditState,
|
||||||
|
type MessageEditActions
|
||||||
|
} from './message-edit.context';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getChatActionsContext,
|
||||||
|
setChatActionsContext,
|
||||||
|
type ChatActionsContext
|
||||||
|
} from './chat-actions.context';
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { getContext, setContext } from 'svelte';
|
||||||
|
|
||||||
|
export interface MessageEditState {
|
||||||
|
readonly isEditing: boolean;
|
||||||
|
readonly editedContent: string;
|
||||||
|
readonly editedExtras: DatabaseMessageExtra[];
|
||||||
|
readonly editedUploadedFiles: ChatUploadedFile[];
|
||||||
|
readonly originalContent: string;
|
||||||
|
readonly originalExtras: DatabaseMessageExtra[];
|
||||||
|
readonly showSaveOnlyOption: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageEditActions {
|
||||||
|
setContent: (content: string) => void;
|
||||||
|
setExtras: (extras: DatabaseMessageExtra[]) => void;
|
||||||
|
setUploadedFiles: (files: ChatUploadedFile[]) => void;
|
||||||
|
save: () => void;
|
||||||
|
saveOnly: () => void;
|
||||||
|
cancel: () => void;
|
||||||
|
startEdit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageEditContext = MessageEditState & MessageEditActions;
|
||||||
|
|
||||||
|
const MESSAGE_EDIT_KEY = Symbol.for('chat-message-edit');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the message edit context. Call this in the parent component (ChatMessage.svelte).
|
||||||
|
*/
|
||||||
|
export function setMessageEditContext(ctx: MessageEditContext): MessageEditContext {
|
||||||
|
return setContext(MESSAGE_EDIT_KEY, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the message edit context. Call this in child components.
|
||||||
|
*/
|
||||||
|
export function getMessageEditContext(): MessageEditContext {
|
||||||
|
return getContext(MESSAGE_EDIT_KEY);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,51 @@
|
||||||
export enum ChatMessageStatsView {
|
export enum ChatMessageStatsView {
|
||||||
GENERATION = 'generation',
|
GENERATION = 'generation',
|
||||||
READING = 'reading'
|
READING = 'reading',
|
||||||
|
TOOLS = 'tools',
|
||||||
|
SUMMARY = 'summary'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reasoning format options for API requests.
|
||||||
|
*/
|
||||||
|
export enum ReasoningFormat {
|
||||||
|
NONE = 'none',
|
||||||
|
AUTO = 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message roles for chat messages.
|
||||||
|
*/
|
||||||
|
export enum MessageRole {
|
||||||
|
USER = 'user',
|
||||||
|
ASSISTANT = 'assistant',
|
||||||
|
SYSTEM = 'system',
|
||||||
|
TOOL = 'tool'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message types for different content kinds.
|
||||||
|
*/
|
||||||
|
export enum MessageType {
|
||||||
|
ROOT = 'root',
|
||||||
|
TEXT = 'text',
|
||||||
|
THINK = 'think',
|
||||||
|
SYSTEM = 'system'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content part types for API chat message content.
|
||||||
|
*/
|
||||||
|
export enum ContentPartType {
|
||||||
|
TEXT = 'text',
|
||||||
|
IMAGE_URL = 'image_url',
|
||||||
|
INPUT_AUDIO = 'input_audio'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error dialog types for displaying server/timeout errors.
|
||||||
|
*/
|
||||||
|
export enum ErrorDialogType {
|
||||||
|
TIMEOUT = 'timeout',
|
||||||
|
SERVER = 'server'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Keyboard key names for event handling
|
||||||
|
*/
|
||||||
|
export enum KeyboardKey {
|
||||||
|
ENTER = 'Enter',
|
||||||
|
ESCAPE = 'Escape',
|
||||||
|
ARROW_UP = 'ArrowUp',
|
||||||
|
ARROW_DOWN = 'ArrowDown',
|
||||||
|
TAB = 'Tab',
|
||||||
|
D_LOWER = 'd',
|
||||||
|
D_UPPER = 'D',
|
||||||
|
E_UPPER = 'E',
|
||||||
|
K_LOWER = 'k',
|
||||||
|
O_UPPER = 'O'
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/**
|
||||||
|
* Parameter source - indicates whether a parameter uses default or custom value
|
||||||
|
*/
|
||||||
|
export enum ParameterSource {
|
||||||
|
DEFAULT = 'default',
|
||||||
|
CUSTOM = 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncable parameter type - data types for parameters that can be synced with server
|
||||||
|
*/
|
||||||
|
export enum SyncableParameterType {
|
||||||
|
NUMBER = 'number',
|
||||||
|
STRING = 'string',
|
||||||
|
BOOLEAN = 'boolean'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings field type - defines the input type for settings fields
|
||||||
|
*/
|
||||||
|
export enum SettingsFieldType {
|
||||||
|
INPUT = 'input',
|
||||||
|
TEXTAREA = 'textarea',
|
||||||
|
CHECKBOX = 'checkbox',
|
||||||
|
SELECT = 'select'
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants/auto-scroll';
|
||||||
|
|
||||||
|
export interface AutoScrollOptions {
|
||||||
|
/** Whether auto-scroll is disabled globally (e.g., from settings) */
|
||||||
|
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 _scrollTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private _container: HTMLElement | undefined;
|
||||||
|
private _disabled: boolean;
|
||||||
|
|
||||||
|
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._container = container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the disabled state.
|
||||||
|
*/
|
||||||
|
setDisabled(disabled: boolean): void {
|
||||||
|
this._disabled = disabled;
|
||||||
|
if (disabled) {
|
||||||
|
this._autoScrollEnabled = false;
|
||||||
|
this.stopInterval();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 - scrollTop - clientHeight;
|
||||||
|
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
|
||||||
|
|
||||||
|
if (scrollTop < this._lastScrollTop && !isAtBottom) {
|
||||||
|
this._userScrolledUp = true;
|
||||||
|
this._autoScrollEnabled = false;
|
||||||
|
} else if (isAtBottom && this._userScrolledUp) {
|
||||||
|
this._userScrolledUp = false;
|
||||||
|
this._autoScrollEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._scrollTimeout) {
|
||||||
|
clearTimeout(this._scrollTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._scrollTimeout = setTimeout(() => {
|
||||||
|
if (isAtBottom) {
|
||||||
|
this._userScrolledUp = false;
|
||||||
|
this._autoScrollEnabled = true;
|
||||||
|
}
|
||||||
|
}, AUTO_SCROLL_INTERVAL);
|
||||||
|
|
||||||
|
this._lastScrollTop = scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the container to the bottom.
|
||||||
|
*/
|
||||||
|
scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {
|
||||||
|
if (this._disabled || !this._container) return;
|
||||||
|
|
||||||
|
this._container.scrollTo({
|
||||||
|
top: this._container.scrollHeight,
|
||||||
|
behavior
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables auto-scroll (e.g., when user sends a message).
|
||||||
|
*/
|
||||||
|
enable(): void {
|
||||||
|
if (this._disabled) return;
|
||||||
|
this._userScrolledUp = false;
|
||||||
|
this._autoScrollEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
if (this._scrollTimeout) {
|
||||||
|
clearTimeout(this._scrollTimeout);
|
||||||
|
this._scrollTimeout = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new AutoScrollController instance.
|
||||||
|
*/
|
||||||
|
export function createAutoScrollController(options: AutoScrollOptions = {}): AutoScrollController {
|
||||||
|
return new AutoScrollController(options);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { activeProcessingState } from '$lib/stores/chat.svelte';
|
import { activeProcessingState } from '$lib/stores/chat.svelte';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
|
import { STATS_UNITS } from '$lib/constants/processing-info';
|
||||||
|
import type { ApiProcessingState } from '$lib/types';
|
||||||
|
|
||||||
export interface LiveProcessingStats {
|
interface LiveProcessingStats {
|
||||||
tokensProcessed: number;
|
tokensProcessed: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
timeMs: number;
|
timeMs: number;
|
||||||
|
|
@ -9,7 +11,7 @@ export interface LiveProcessingStats {
|
||||||
etaSecs?: number;
|
etaSecs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveGenerationStats {
|
interface LiveGenerationStats {
|
||||||
tokensGenerated: number;
|
tokensGenerated: number;
|
||||||
timeMs: number;
|
timeMs: number;
|
||||||
tokensPerSecond: number;
|
tokensPerSecond: number;
|
||||||
|
|
@ -18,6 +20,7 @@ export interface LiveGenerationStats {
|
||||||
export interface UseProcessingStateReturn {
|
export interface UseProcessingStateReturn {
|
||||||
readonly processingState: ApiProcessingState | null;
|
readonly processingState: ApiProcessingState | null;
|
||||||
getProcessingDetails(): string[];
|
getProcessingDetails(): string[];
|
||||||
|
getTechnicalDetails(): string[];
|
||||||
getProcessingMessage(): string;
|
getProcessingMessage(): string;
|
||||||
getPromptProgressText(): string | null;
|
getPromptProgressText(): string | null;
|
||||||
getLiveProcessingStats(): LiveProcessingStats | null;
|
getLiveProcessingStats(): LiveProcessingStats | null;
|
||||||
|
|
@ -138,8 +141,31 @@ export function useProcessingState(): UseProcessingStateReturn {
|
||||||
|
|
||||||
const details: string[] = [];
|
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
|
// Always show context info when we have valid data
|
||||||
if (stateToUse.contextUsed >= 0 && stateToUse.contextTotal > 0) {
|
if (
|
||||||
|
typeof stateToUse.contextTotal === 'number' &&
|
||||||
|
stateToUse.contextUsed >= 0 &&
|
||||||
|
stateToUse.contextTotal > 0
|
||||||
|
) {
|
||||||
const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100);
|
const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100);
|
||||||
|
|
||||||
details.push(
|
details.push(
|
||||||
|
|
@ -163,7 +189,57 @@ export function useProcessingState(): UseProcessingStateReturn {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
|
if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) {
|
||||||
details.push(`${stateToUse.tokensPerSecond.toFixed(1)} tokens/sec`);
|
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) {
|
if (stateToUse.speculative) {
|
||||||
|
|
@ -251,6 +327,7 @@ export function useProcessingState(): UseProcessingStateReturn {
|
||||||
return processingState;
|
return processingState;
|
||||||
},
|
},
|
||||||
getProcessingDetails,
|
getProcessingDetails,
|
||||||
|
getTechnicalDetails,
|
||||||
getProcessingMessage,
|
getProcessingMessage,
|
||||||
getPromptProgressText,
|
getPromptProgressText,
|
||||||
getLiveProcessingStats,
|
getLiveProcessingStats,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,16 @@
|
||||||
import type { Plugin } from 'unified';
|
import type { Plugin } from 'unified';
|
||||||
import type { Root, Element, ElementContent } from 'hast';
|
import type { Root, Element, ElementContent } from 'hast';
|
||||||
import { visit } from 'unist-util-visit';
|
import { visit } from 'unist-util-visit';
|
||||||
|
import {
|
||||||
|
CODE_BLOCK_SCROLL_CONTAINER_CLASS,
|
||||||
|
CODE_BLOCK_WRAPPER_CLASS,
|
||||||
|
CODE_BLOCK_HEADER_CLASS,
|
||||||
|
CODE_BLOCK_ACTIONS_CLASS,
|
||||||
|
CODE_LANGUAGE_CLASS,
|
||||||
|
COPY_CODE_BTN_CLASS,
|
||||||
|
PREVIEW_CODE_BTN_CLASS,
|
||||||
|
RELATIVE_CLASS
|
||||||
|
} from '$lib/constants/code-blocks';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -42,7 +52,7 @@ function createCopyButton(codeId: string): Element {
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'button',
|
tagName: 'button',
|
||||||
properties: {
|
properties: {
|
||||||
className: ['copy-code-btn'],
|
className: [COPY_CODE_BTN_CLASS],
|
||||||
'data-code-id': codeId,
|
'data-code-id': codeId,
|
||||||
title: 'Copy code',
|
title: 'Copy code',
|
||||||
type: 'button'
|
type: 'button'
|
||||||
|
|
@ -56,7 +66,7 @@ function createPreviewButton(codeId: string): Element {
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'button',
|
tagName: 'button',
|
||||||
properties: {
|
properties: {
|
||||||
className: ['preview-code-btn'],
|
className: [PREVIEW_CODE_BTN_CLASS],
|
||||||
'data-code-id': codeId,
|
'data-code-id': codeId,
|
||||||
title: 'Preview code',
|
title: 'Preview code',
|
||||||
type: 'button'
|
type: 'button'
|
||||||
|
|
@ -75,30 +85,39 @@ function createHeader(language: string, codeId: string): Element {
|
||||||
return {
|
return {
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'div',
|
tagName: 'div',
|
||||||
properties: { className: ['code-block-header'] },
|
properties: { className: [CODE_BLOCK_HEADER_CLASS] },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'span',
|
tagName: 'span',
|
||||||
properties: { className: ['code-language'] },
|
properties: { className: [CODE_LANGUAGE_CLASS] },
|
||||||
children: [{ type: 'text', value: language }]
|
children: [{ type: 'text', value: language }]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'div',
|
tagName: 'div',
|
||||||
properties: { className: ['code-block-actions'] },
|
properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
|
||||||
children: actions
|
children: actions
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createScrollContainer(preElement: Element): Element {
|
||||||
|
return {
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
properties: { className: [CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
|
||||||
|
children: [preElement]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createWrapper(header: Element, preElement: Element): Element {
|
function createWrapper(header: Element, preElement: Element): Element {
|
||||||
return {
|
return {
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'div',
|
tagName: 'div',
|
||||||
properties: { className: ['code-block-wrapper'] },
|
properties: { className: [CODE_BLOCK_WRAPPER_CLASS, RELATIVE_CLASS] },
|
||||||
children: [header, preElement]
|
children: [header, createScrollContainer(preElement)]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,368 @@
|
||||||
|
import Dexie, { type EntityTable } from 'dexie';
|
||||||
|
import { findDescendantMessages } from '$lib/utils';
|
||||||
|
|
||||||
|
class LlamacppDatabase extends Dexie {
|
||||||
|
conversations!: EntityTable<DatabaseConversation, string>;
|
||||||
|
messages!: EntityTable<DatabaseMessage, string>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('LlamacppWebui');
|
||||||
|
|
||||||
|
this.version(1).stores({
|
||||||
|
conversations: 'id, lastModified, currNode, name',
|
||||||
|
messages: 'id, convId, type, role, timestamp, parent, children'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new LlamacppDatabase();
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { MessageRole } from '$lib/enums/chat';
|
||||||
|
|
||||||
|
export class DatabaseService {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Conversations
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new conversation.
|
||||||
|
*
|
||||||
|
* @param name - Name of the conversation
|
||||||
|
* @returns The created conversation
|
||||||
|
*/
|
||||||
|
static async createConversation(name: string): Promise<DatabaseConversation> {
|
||||||
|
const conversation: DatabaseConversation = {
|
||||||
|
id: uuid(),
|
||||||
|
name,
|
||||||
|
lastModified: Date.now(),
|
||||||
|
currNode: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.conversations.add(conversation);
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Messages
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new message branch by adding a message and updating parent/child relationships.
|
||||||
|
* Also updates the conversation's currNode to point to the new message.
|
||||||
|
*
|
||||||
|
* @param message - Message to add (without id)
|
||||||
|
* @param parentId - Parent message ID to attach to
|
||||||
|
* @returns The created message
|
||||||
|
*/
|
||||||
|
static async createMessageBranch(
|
||||||
|
message: Omit<DatabaseMessage, 'id'>,
|
||||||
|
parentId: string | null
|
||||||
|
): Promise<DatabaseMessage> {
|
||||||
|
return await db.transaction('rw', [db.conversations, db.messages], async () => {
|
||||||
|
// Handle null parent (root message case)
|
||||||
|
if (parentId !== null) {
|
||||||
|
const parentMessage = await db.messages.get(parentId);
|
||||||
|
if (!parentMessage) {
|
||||||
|
throw new Error(`Parent message ${parentId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMessage: DatabaseMessage = {
|
||||||
|
...message,
|
||||||
|
id: uuid(),
|
||||||
|
parent: parentId,
|
||||||
|
toolCalls: message.toolCalls ?? '',
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.messages.add(newMessage);
|
||||||
|
|
||||||
|
// Update parent's children array if parent exists
|
||||||
|
if (parentId !== null) {
|
||||||
|
const parentMessage = await db.messages.get(parentId);
|
||||||
|
if (parentMessage) {
|
||||||
|
await db.messages.update(parentId, {
|
||||||
|
children: [...parentMessage.children, newMessage.id]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateConversation(message.convId, {
|
||||||
|
currNode: newMessage.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return newMessage;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a root message for a new conversation.
|
||||||
|
* Root messages are not displayed but serve as the tree root for branching.
|
||||||
|
*
|
||||||
|
* @param convId - Conversation ID
|
||||||
|
* @returns The created root message
|
||||||
|
*/
|
||||||
|
static async createRootMessage(convId: string): Promise<string> {
|
||||||
|
const rootMessage: DatabaseMessage = {
|
||||||
|
id: uuid(),
|
||||||
|
convId,
|
||||||
|
type: 'root',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
role: MessageRole.SYSTEM,
|
||||||
|
content: '',
|
||||||
|
parent: null,
|
||||||
|
toolCalls: '',
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.messages.add(rootMessage);
|
||||||
|
return rootMessage.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a system prompt message for a conversation.
|
||||||
|
*
|
||||||
|
* @param convId - Conversation ID
|
||||||
|
* @param systemPrompt - The system prompt content (must be non-empty)
|
||||||
|
* @param parentId - Parent message ID (typically the root message)
|
||||||
|
* @returns The created system message
|
||||||
|
* @throws Error if systemPrompt is empty
|
||||||
|
*/
|
||||||
|
static async createSystemMessage(
|
||||||
|
convId: string,
|
||||||
|
systemPrompt: string,
|
||||||
|
parentId: string
|
||||||
|
): Promise<DatabaseMessage> {
|
||||||
|
const trimmedPrompt = systemPrompt.trim();
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
throw new Error('Cannot create system message with empty content');
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemMessage: DatabaseMessage = {
|
||||||
|
id: uuid(),
|
||||||
|
convId,
|
||||||
|
type: MessageRole.SYSTEM,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
role: MessageRole.SYSTEM,
|
||||||
|
content: trimmedPrompt,
|
||||||
|
parent: parentId,
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.messages.add(systemMessage);
|
||||||
|
|
||||||
|
const parentMessage = await db.messages.get(parentId);
|
||||||
|
if (parentMessage) {
|
||||||
|
await db.messages.update(parentId, {
|
||||||
|
children: [...parentMessage.children, systemMessage.id]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a conversation and all its messages.
|
||||||
|
*
|
||||||
|
* @param id - Conversation ID
|
||||||
|
*/
|
||||||
|
static async deleteConversation(id: string): Promise<void> {
|
||||||
|
await db.transaction('rw', [db.conversations, db.messages], async () => {
|
||||||
|
await db.conversations.delete(id);
|
||||||
|
await db.messages.where('convId').equals(id).delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a message and removes it from its parent's children array.
|
||||||
|
*
|
||||||
|
* @param messageId - ID of the message to delete
|
||||||
|
*/
|
||||||
|
static async deleteMessage(messageId: string): Promise<void> {
|
||||||
|
await db.transaction('rw', db.messages, async () => {
|
||||||
|
const message = await db.messages.get(messageId);
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
// Remove this message from its parent's children array
|
||||||
|
if (message.parent) {
|
||||||
|
const parent = await db.messages.get(message.parent);
|
||||||
|
if (parent) {
|
||||||
|
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
||||||
|
await db.messages.put(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the message
|
||||||
|
await db.messages.delete(messageId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a message and all its descendant messages (cascading deletion).
|
||||||
|
* This removes the entire branch starting from the specified message.
|
||||||
|
*
|
||||||
|
* @param conversationId - ID of the conversation containing the message
|
||||||
|
* @param messageId - ID of the root message to delete (along with all descendants)
|
||||||
|
* @returns Array of all deleted message IDs
|
||||||
|
*/
|
||||||
|
static async deleteMessageCascading(
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
return await db.transaction('rw', db.messages, async () => {
|
||||||
|
// Get all messages in the conversation to find descendants
|
||||||
|
const allMessages = await db.messages.where('convId').equals(conversationId).toArray();
|
||||||
|
|
||||||
|
// Find all descendant messages
|
||||||
|
const descendants = findDescendantMessages(allMessages, messageId);
|
||||||
|
const allToDelete = [messageId, ...descendants];
|
||||||
|
|
||||||
|
// Get the message to delete for parent cleanup
|
||||||
|
const message = await db.messages.get(messageId);
|
||||||
|
if (message && message.parent) {
|
||||||
|
const parent = await db.messages.get(message.parent);
|
||||||
|
if (parent) {
|
||||||
|
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
||||||
|
await db.messages.put(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all messages in the branch
|
||||||
|
await db.messages.bulkDelete(allToDelete);
|
||||||
|
|
||||||
|
return allToDelete;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all conversations, sorted by last modified time (newest first).
|
||||||
|
*
|
||||||
|
* @returns Array of conversations
|
||||||
|
*/
|
||||||
|
static async getAllConversations(): Promise<DatabaseConversation[]> {
|
||||||
|
return await db.conversations.orderBy('lastModified').reverse().toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a conversation by ID.
|
||||||
|
*
|
||||||
|
* @param id - Conversation ID
|
||||||
|
* @returns The conversation if found, otherwise undefined
|
||||||
|
*/
|
||||||
|
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
|
||||||
|
return await db.conversations.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all messages in a conversation, sorted by timestamp (oldest first).
|
||||||
|
*
|
||||||
|
* @param convId - Conversation ID
|
||||||
|
* @returns Array of messages in the conversation
|
||||||
|
*/
|
||||||
|
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
||||||
|
return await db.messages.where('convId').equals(convId).sortBy('timestamp');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a conversation.
|
||||||
|
*
|
||||||
|
* @param id - Conversation ID
|
||||||
|
* @param updates - Partial updates to apply
|
||||||
|
* @returns Promise that resolves when the conversation is updated
|
||||||
|
*/
|
||||||
|
static async updateConversation(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Omit<DatabaseConversation, 'id'>>
|
||||||
|
): Promise<void> {
|
||||||
|
await db.conversations.update(id, {
|
||||||
|
...updates,
|
||||||
|
lastModified: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Navigation
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the conversation's current node (active branch).
|
||||||
|
* This determines which conversation path is currently being viewed.
|
||||||
|
*
|
||||||
|
* @param convId - Conversation ID
|
||||||
|
* @param nodeId - Message ID to set as current node
|
||||||
|
*/
|
||||||
|
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
|
||||||
|
await this.updateConversation(convId, {
|
||||||
|
currNode: nodeId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a message.
|
||||||
|
*
|
||||||
|
* @param id - Message ID
|
||||||
|
* @param updates - Partial updates to apply
|
||||||
|
* @returns Promise that resolves when the message is updated
|
||||||
|
*/
|
||||||
|
static async updateMessage(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Omit<DatabaseMessage, 'id'>>
|
||||||
|
): Promise<void> {
|
||||||
|
await db.messages.update(id, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Import
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports multiple conversations and their messages.
|
||||||
|
* Skips conversations that already exist.
|
||||||
|
*
|
||||||
|
* @param data - Array of { conv, messages } objects
|
||||||
|
*/
|
||||||
|
static async importConversations(
|
||||||
|
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
|
||||||
|
): Promise<{ imported: number; skipped: number }> {
|
||||||
|
let importedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
return await db.transaction('rw', [db.conversations, db.messages], async () => {
|
||||||
|
for (const item of data) {
|
||||||
|
const { conv, messages } = item;
|
||||||
|
|
||||||
|
const existing = await db.conversations.get(conv.id);
|
||||||
|
if (existing) {
|
||||||
|
console.warn(`Conversation "${conv.name}" already exists, skipping...`);
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.conversations.add(conv);
|
||||||
|
for (const msg of messages) {
|
||||||
|
await db.messages.put(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
importedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imported: importedCount, skipped: skippedCount };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { ServerModelStatus } from '$lib/enums';
|
||||||
|
import { apiFetch, apiPost } from '$lib/utils/api-fetch';
|
||||||
|
|
||||||
|
export class ModelsService {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Listing
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch list of models from OpenAI-compatible endpoint.
|
||||||
|
* Works in both MODEL and ROUTER modes.
|
||||||
|
*
|
||||||
|
* @returns List of available models with basic metadata
|
||||||
|
*/
|
||||||
|
static async list(): Promise<ApiModelListResponse> {
|
||||||
|
return apiFetch<ApiModelListResponse>('/v1/models');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch list of all models with detailed metadata (ROUTER mode).
|
||||||
|
* Returns models with load status, paths, and other metadata
|
||||||
|
* beyond what the OpenAI-compatible endpoint provides.
|
||||||
|
*
|
||||||
|
* @returns List of models with detailed status and configuration info
|
||||||
|
*/
|
||||||
|
static async listRouter(): Promise<ApiRouterModelsListResponse> {
|
||||||
|
return apiFetch<ApiRouterModelsListResponse>('/v1/models');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Load/Unload
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a model (ROUTER mode only).
|
||||||
|
* Sends POST request to `/models/load`. Note: the endpoint returns success
|
||||||
|
* before loading completes — use polling to await actual load status.
|
||||||
|
*
|
||||||
|
* @param modelId - Model identifier to load
|
||||||
|
* @param extraArgs - Optional additional arguments to pass to the model instance
|
||||||
|
* @returns Load response from the server
|
||||||
|
*/
|
||||||
|
static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
|
||||||
|
const payload: { model: string; extra_args?: string[] } = { model: modelId };
|
||||||
|
if (extraArgs && extraArgs.length > 0) {
|
||||||
|
payload.extra_args = extraArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiPost<ApiRouterModelsLoadResponse>('/models/load', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unload a model (ROUTER mode only).
|
||||||
|
* Sends POST request to `/models/unload`. Note: the endpoint returns success
|
||||||
|
* before unloading completes — use polling to await actual unload status.
|
||||||
|
*
|
||||||
|
* @param modelId - Model identifier to unload
|
||||||
|
* @returns Unload response from the server
|
||||||
|
*/
|
||||||
|
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
|
||||||
|
return apiPost<ApiRouterModelsUnloadResponse>('/models/unload', { model: modelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Status
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model is loaded based on its metadata.
|
||||||
|
*
|
||||||
|
* @param model - Model data entry from the API response
|
||||||
|
* @returns True if the model status is LOADED
|
||||||
|
*/
|
||||||
|
static isModelLoaded(model: ApiModelDataEntry): boolean {
|
||||||
|
return model.status.value === ServerModelStatus.LOADED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model is currently loading.
|
||||||
|
*
|
||||||
|
* @param model - Model data entry from the API response
|
||||||
|
* @returns True if the model status is LOADING
|
||||||
|
*/
|
||||||
|
static isModelLoading(model: ApiModelDataEntry): boolean {
|
||||||
|
return model.status.value === ServerModelStatus.LOADING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ParameterSyncService } from './parameter-sync.service';
|
||||||
|
|
||||||
|
describe('ParameterSyncService', () => {
|
||||||
|
describe('roundFloatingPoint', () => {
|
||||||
|
it('should fix JavaScript floating-point precision issues', () => {
|
||||||
|
// Test the specific values from the screenshot
|
||||||
|
const mockServerParams = {
|
||||||
|
top_p: 0.949999988079071,
|
||||||
|
min_p: 0.009999999776482582,
|
||||||
|
temperature: 0.800000011920929,
|
||||||
|
top_k: 40,
|
||||||
|
samplers: ['top_k', 'typ_p', 'top_p', 'min_p', 'temperature']
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ParameterSyncService.extractServerDefaults({
|
||||||
|
...mockServerParams,
|
||||||
|
// Add other required fields to match the API type
|
||||||
|
n_predict: 512,
|
||||||
|
seed: -1,
|
||||||
|
dynatemp_range: 0.0,
|
||||||
|
dynatemp_exponent: 1.0,
|
||||||
|
xtc_probability: 0.0,
|
||||||
|
xtc_threshold: 0.1,
|
||||||
|
typ_p: 1.0,
|
||||||
|
repeat_last_n: 64,
|
||||||
|
repeat_penalty: 1.0,
|
||||||
|
presence_penalty: 0.0,
|
||||||
|
frequency_penalty: 0.0,
|
||||||
|
dry_multiplier: 0.0,
|
||||||
|
dry_base: 1.75,
|
||||||
|
dry_allowed_length: 2,
|
||||||
|
dry_penalty_last_n: -1,
|
||||||
|
mirostat: 0,
|
||||||
|
mirostat_tau: 5.0,
|
||||||
|
mirostat_eta: 0.1,
|
||||||
|
stop: [],
|
||||||
|
max_tokens: -1,
|
||||||
|
n_keep: 0,
|
||||||
|
n_discard: 0,
|
||||||
|
ignore_eos: false,
|
||||||
|
stream: true,
|
||||||
|
logit_bias: [],
|
||||||
|
n_probs: 0,
|
||||||
|
min_keep: 0,
|
||||||
|
grammar: '',
|
||||||
|
grammar_lazy: false,
|
||||||
|
grammar_triggers: [],
|
||||||
|
preserved_tokens: [],
|
||||||
|
chat_format: '',
|
||||||
|
reasoning_format: '',
|
||||||
|
reasoning_in_content: false,
|
||||||
|
thinking_forced_open: false,
|
||||||
|
'speculative.n_max': 0,
|
||||||
|
'speculative.n_min': 0,
|
||||||
|
'speculative.p_min': 0.0,
|
||||||
|
timings_per_token: false,
|
||||||
|
post_sampling_probs: false,
|
||||||
|
lora: [],
|
||||||
|
top_n_sigma: 0.0,
|
||||||
|
dry_sequence_breakers: []
|
||||||
|
} as ApiLlamaCppServerProps['default_generation_settings']['params']);
|
||||||
|
|
||||||
|
// Check that the problematic floating-point values are rounded correctly
|
||||||
|
expect(result.top_p).toBe(0.95);
|
||||||
|
expect(result.min_p).toBe(0.01);
|
||||||
|
expect(result.temperature).toBe(0.8);
|
||||||
|
expect(result.top_k).toBe(40); // Integer should remain unchanged
|
||||||
|
expect(result.samplers).toBe('top_k;typ_p;top_p;min_p;temperature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve non-numeric values', () => {
|
||||||
|
const mockServerParams = {
|
||||||
|
samplers: ['top_k', 'temperature'],
|
||||||
|
max_tokens: -1,
|
||||||
|
temperature: 0.7
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ParameterSyncService.extractServerDefaults({
|
||||||
|
...mockServerParams,
|
||||||
|
// Minimal required fields
|
||||||
|
n_predict: 512,
|
||||||
|
seed: -1,
|
||||||
|
dynatemp_range: 0.0,
|
||||||
|
dynatemp_exponent: 1.0,
|
||||||
|
top_k: 40,
|
||||||
|
top_p: 0.95,
|
||||||
|
min_p: 0.05,
|
||||||
|
xtc_probability: 0.0,
|
||||||
|
xtc_threshold: 0.1,
|
||||||
|
typ_p: 1.0,
|
||||||
|
repeat_last_n: 64,
|
||||||
|
repeat_penalty: 1.0,
|
||||||
|
presence_penalty: 0.0,
|
||||||
|
frequency_penalty: 0.0,
|
||||||
|
dry_multiplier: 0.0,
|
||||||
|
dry_base: 1.75,
|
||||||
|
dry_allowed_length: 2,
|
||||||
|
dry_penalty_last_n: -1,
|
||||||
|
mirostat: 0,
|
||||||
|
mirostat_tau: 5.0,
|
||||||
|
mirostat_eta: 0.1,
|
||||||
|
stop: [],
|
||||||
|
n_keep: 0,
|
||||||
|
n_discard: 0,
|
||||||
|
ignore_eos: false,
|
||||||
|
stream: true,
|
||||||
|
logit_bias: [],
|
||||||
|
n_probs: 0,
|
||||||
|
min_keep: 0,
|
||||||
|
grammar: '',
|
||||||
|
grammar_lazy: false,
|
||||||
|
grammar_triggers: [],
|
||||||
|
preserved_tokens: [],
|
||||||
|
chat_format: '',
|
||||||
|
reasoning_format: '',
|
||||||
|
reasoning_in_content: false,
|
||||||
|
thinking_forced_open: false,
|
||||||
|
'speculative.n_max': 0,
|
||||||
|
'speculative.n_min': 0,
|
||||||
|
'speculative.p_min': 0.0,
|
||||||
|
timings_per_token: false,
|
||||||
|
post_sampling_probs: false,
|
||||||
|
lora: [],
|
||||||
|
top_n_sigma: 0.0,
|
||||||
|
dry_sequence_breakers: []
|
||||||
|
} as ApiLlamaCppServerProps['default_generation_settings']['params']);
|
||||||
|
|
||||||
|
expect(result.samplers).toBe('top_k;temperature');
|
||||||
|
expect(result.max_tokens).toBe(-1);
|
||||||
|
expect(result.temperature).toBe(0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge webui settings from props when provided', () => {
|
||||||
|
const result = ParameterSyncService.extractServerDefaults(null, {
|
||||||
|
pasteLongTextToFileLen: 0,
|
||||||
|
pdfAsImage: true,
|
||||||
|
renderUserContentAsMarkdown: false,
|
||||||
|
theme: 'dark'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.pasteLongTextToFileLen).toBe(0);
|
||||||
|
expect(result.pdfAsImage).toBe(true);
|
||||||
|
expect(result.renderUserContentAsMarkdown).toBe(false);
|
||||||
|
expect(result.theme).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
import { normalizeFloatingPoint } from '$lib/utils';
|
||||||
|
import { SyncableParameterType, ParameterSource } from '$lib/enums/settings';
|
||||||
|
|
||||||
|
type ParameterValue = string | number | boolean;
|
||||||
|
type ParameterRecord = Record<string, ParameterValue>;
|
||||||
|
|
||||||
|
interface ParameterInfo {
|
||||||
|
value: string | number | boolean;
|
||||||
|
source: ParameterSource;
|
||||||
|
serverDefault?: string | number | boolean;
|
||||||
|
userOverride?: string | number | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncableParameter {
|
||||||
|
key: string;
|
||||||
|
serverKey: string;
|
||||||
|
type: SyncableParameterType;
|
||||||
|
canSync: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of webui setting keys to server parameter keys.
|
||||||
|
* Only parameters listed here can be synced from the server `/props` endpoint.
|
||||||
|
* Each entry defines the webui key, corresponding server key, value type,
|
||||||
|
* and whether sync is enabled.
|
||||||
|
*/
|
||||||
|
export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
|
||||||
|
{
|
||||||
|
key: 'temperature',
|
||||||
|
serverKey: 'temperature',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{ key: 'top_k', serverKey: 'top_k', type: SyncableParameterType.NUMBER, canSync: true },
|
||||||
|
{ key: 'top_p', serverKey: 'top_p', type: SyncableParameterType.NUMBER, canSync: true },
|
||||||
|
{ key: 'min_p', serverKey: 'min_p', type: SyncableParameterType.NUMBER, canSync: true },
|
||||||
|
{
|
||||||
|
key: 'dynatemp_range',
|
||||||
|
serverKey: 'dynatemp_range',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dynatemp_exponent',
|
||||||
|
serverKey: 'dynatemp_exponent',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'xtc_probability',
|
||||||
|
serverKey: 'xtc_probability',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'xtc_threshold',
|
||||||
|
serverKey: 'xtc_threshold',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{ key: 'typ_p', serverKey: 'typ_p', type: SyncableParameterType.NUMBER, canSync: true },
|
||||||
|
{
|
||||||
|
key: 'repeat_last_n',
|
||||||
|
serverKey: 'repeat_last_n',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'repeat_penalty',
|
||||||
|
serverKey: 'repeat_penalty',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'presence_penalty',
|
||||||
|
serverKey: 'presence_penalty',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'frequency_penalty',
|
||||||
|
serverKey: 'frequency_penalty',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dry_multiplier',
|
||||||
|
serverKey: 'dry_multiplier',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{ key: 'dry_base', serverKey: 'dry_base', type: SyncableParameterType.NUMBER, canSync: true },
|
||||||
|
{
|
||||||
|
key: 'dry_allowed_length',
|
||||||
|
serverKey: 'dry_allowed_length',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dry_penalty_last_n',
|
||||||
|
serverKey: 'dry_penalty_last_n',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{ key: 'max_tokens', serverKey: 'max_tokens', type: SyncableParameterType.NUMBER, canSync: true },
|
||||||
|
{ key: 'samplers', serverKey: 'samplers', type: SyncableParameterType.STRING, canSync: true },
|
||||||
|
{
|
||||||
|
key: 'pasteLongTextToFileLen',
|
||||||
|
serverKey: 'pasteLongTextToFileLen',
|
||||||
|
type: SyncableParameterType.NUMBER,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pdfAsImage',
|
||||||
|
serverKey: 'pdfAsImage',
|
||||||
|
type: SyncableParameterType.BOOLEAN,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'showThoughtInProgress',
|
||||||
|
serverKey: 'showThoughtInProgress',
|
||||||
|
type: SyncableParameterType.BOOLEAN,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'keepStatsVisible',
|
||||||
|
serverKey: 'keepStatsVisible',
|
||||||
|
type: SyncableParameterType.BOOLEAN,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'showMessageStats',
|
||||||
|
serverKey: 'showMessageStats',
|
||||||
|
type: SyncableParameterType.BOOLEAN,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'askForTitleConfirmation',
|
||||||
|
serverKey: 'askForTitleConfirmation',
|
||||||
|
type: SyncableParameterType.BOOLEAN,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'disableAutoScroll',
|
||||||
|
serverKey: 'disableAutoScroll',
|
||||||
|
type: SyncableParameterType.BOOLEAN,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'renderUserContentAsMarkdown',
|
||||||
|
serverKey: 'renderUserContentAsMarkdown',
|
||||||
|
type: SyncableParameterType.BOOLEAN,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'autoMicOnEmpty',
|
||||||
|
serverKey: 'autoMicOnEmpty',
|
||||||
|
type: SyncableParameterType.BOOLEAN,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pyInterpreterEnabled',
|
||||||
|
serverKey: 'pyInterpreterEnabled',
|
||||||
|
type: SyncableParameterType.BOOLEAN,
|
||||||
|
canSync: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enableContinueGeneration',
|
||||||
|
serverKey: 'enableContinueGeneration',
|
||||||
|
type: SyncableParameterType.BOOLEAN,
|
||||||
|
canSync: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export class ParameterSyncService {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Extraction
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round floating-point numbers to avoid JavaScript precision issues.
|
||||||
|
* E.g., 0.1 + 0.2 = 0.30000000000000004 → 0.3
|
||||||
|
*
|
||||||
|
* @param value - Parameter value to normalize
|
||||||
|
* @returns Precision-normalized value
|
||||||
|
*/
|
||||||
|
private static roundFloatingPoint(value: ParameterValue): ParameterValue {
|
||||||
|
return normalizeFloatingPoint(value) as ParameterValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract server default parameters that can be synced from `/props` response.
|
||||||
|
* Handles both generation settings parameters and webui-specific settings.
|
||||||
|
* Converts samplers array to semicolon-delimited string for UI display.
|
||||||
|
*
|
||||||
|
* @param serverParams - Raw generation settings from server `/props` endpoint
|
||||||
|
* @param webuiSettings - Optional webui-specific settings from server
|
||||||
|
* @returns Record of extracted parameter key-value pairs with normalized precision
|
||||||
|
*/
|
||||||
|
static extractServerDefaults(
|
||||||
|
serverParams: ApiLlamaCppServerProps['default_generation_settings']['params'] | null,
|
||||||
|
webuiSettings?: Record<string, string | number | boolean>
|
||||||
|
): ParameterRecord {
|
||||||
|
const extracted: ParameterRecord = {};
|
||||||
|
|
||||||
|
if (serverParams) {
|
||||||
|
for (const param of SYNCABLE_PARAMETERS) {
|
||||||
|
if (param.canSync && param.serverKey in serverParams) {
|
||||||
|
const value = (serverParams as unknown as Record<string, ParameterValue>)[
|
||||||
|
param.serverKey
|
||||||
|
];
|
||||||
|
if (value !== undefined) {
|
||||||
|
// Apply precision rounding to avoid JavaScript floating-point issues
|
||||||
|
extracted[param.key] = this.roundFloatingPoint(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle samplers array conversion to string
|
||||||
|
if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
|
||||||
|
extracted.samplers = serverParams.samplers.join(';');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webuiSettings) {
|
||||||
|
for (const param of SYNCABLE_PARAMETERS) {
|
||||||
|
if (param.canSync && param.serverKey in webuiSettings) {
|
||||||
|
const value = webuiSettings[param.serverKey];
|
||||||
|
if (value !== undefined) {
|
||||||
|
extracted[param.key] = this.roundFloatingPoint(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extracted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Merging
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge server defaults with current user settings.
|
||||||
|
* User overrides always take priority — only parameters not in `userOverrides`
|
||||||
|
* set will be updated from server defaults.
|
||||||
|
*
|
||||||
|
* @param currentSettings - Current parameter values in the settings store
|
||||||
|
* @param serverDefaults - Default values extracted from server props
|
||||||
|
* @param userOverrides - Set of parameter keys explicitly overridden by the user
|
||||||
|
* @returns Merged parameter record with user overrides preserved
|
||||||
|
*/
|
||||||
|
static mergeWithServerDefaults(
|
||||||
|
currentSettings: ParameterRecord,
|
||||||
|
serverDefaults: ParameterRecord,
|
||||||
|
userOverrides: Set<string> = new Set()
|
||||||
|
): ParameterRecord {
|
||||||
|
const merged = { ...currentSettings };
|
||||||
|
|
||||||
|
for (const [key, serverValue] of Object.entries(serverDefaults)) {
|
||||||
|
// Only update if user hasn't explicitly overridden this parameter
|
||||||
|
if (!userOverrides.has(key)) {
|
||||||
|
merged[key] = this.roundFloatingPoint(serverValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Info
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get parameter information including source and values.
|
||||||
|
* Used by ChatSettingsParameterSourceIndicator to display the correct badge
|
||||||
|
* (Custom vs Default) for each parameter in the settings UI.
|
||||||
|
*
|
||||||
|
* @param key - The parameter key to get info for
|
||||||
|
* @param currentValue - The current value of the parameter
|
||||||
|
* @param propsDefaults - Server default values from `/props`
|
||||||
|
* @param userOverrides - Set of parameter keys explicitly overridden by the user
|
||||||
|
* @returns Parameter info with source, server default, and user override values
|
||||||
|
*/
|
||||||
|
static getParameterInfo(
|
||||||
|
key: string,
|
||||||
|
currentValue: ParameterValue,
|
||||||
|
propsDefaults: ParameterRecord,
|
||||||
|
userOverrides: Set<string>
|
||||||
|
): ParameterInfo {
|
||||||
|
const hasPropsDefault = propsDefaults[key] !== undefined;
|
||||||
|
const isUserOverride = userOverrides.has(key);
|
||||||
|
|
||||||
|
// Simple logic: either using default (from props) or custom (user override)
|
||||||
|
const source = isUserOverride ? ParameterSource.CUSTOM : ParameterSource.DEFAULT;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: currentValue,
|
||||||
|
source,
|
||||||
|
serverDefault: hasPropsDefault ? propsDefaults[key] : undefined, // Keep same field name for compatibility
|
||||||
|
userOverride: isUserOverride ? currentValue : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a parameter can be synced from server.
|
||||||
|
*
|
||||||
|
* @param key - The parameter key to check
|
||||||
|
* @returns True if the parameter is in the syncable parameters list
|
||||||
|
*/
|
||||||
|
static canSyncParameter(key: string): boolean {
|
||||||
|
return SYNCABLE_PARAMETERS.some((param) => param.key === key && param.canSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all syncable parameter keys.
|
||||||
|
*
|
||||||
|
* @returns Array of parameter keys that can be synced from server
|
||||||
|
*/
|
||||||
|
static getSyncableParameterKeys(): string[] {
|
||||||
|
return SYNCABLE_PARAMETERS.filter((param) => param.canSync).map((param) => param.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a server parameter value against its expected type.
|
||||||
|
*
|
||||||
|
* @param key - The parameter key to validate
|
||||||
|
* @param value - The value to validate
|
||||||
|
* @returns True if value matches the expected type for this parameter
|
||||||
|
*/
|
||||||
|
static validateServerParameter(key: string, value: ParameterValue): boolean {
|
||||||
|
const param = SYNCABLE_PARAMETERS.find((p) => p.key === key);
|
||||||
|
if (!param) return false;
|
||||||
|
|
||||||
|
switch (param.type) {
|
||||||
|
case SyncableParameterType.NUMBER:
|
||||||
|
return typeof value === 'number' && !isNaN(value);
|
||||||
|
case SyncableParameterType.STRING:
|
||||||
|
return typeof value === 'string';
|
||||||
|
case SyncableParameterType.BOOLEAN:
|
||||||
|
return typeof value === 'boolean';
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Diff
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a diff between current settings and server defaults.
|
||||||
|
* Shows which parameters differ from server values, useful for debugging
|
||||||
|
* and for the "Reset to defaults" functionality.
|
||||||
|
*
|
||||||
|
* @param currentSettings - Current parameter values in the settings store
|
||||||
|
* @param serverDefaults - Default values extracted from server props
|
||||||
|
* @returns Record of parameter diffs with current value, server value, and whether they differ
|
||||||
|
*/
|
||||||
|
static createParameterDiff(
|
||||||
|
currentSettings: ParameterRecord,
|
||||||
|
serverDefaults: ParameterRecord
|
||||||
|
): Record<string, { current: ParameterValue; server: ParameterValue; differs: boolean }> {
|
||||||
|
const diff: Record<
|
||||||
|
string,
|
||||||
|
{ current: ParameterValue; server: ParameterValue; differs: boolean }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const key of this.getSyncableParameterKeys()) {
|
||||||
|
const currentValue = currentSettings[key];
|
||||||
|
const serverValue = serverDefaults[key];
|
||||||
|
|
||||||
|
if (serverValue !== undefined) {
|
||||||
|
diff[key] = {
|
||||||
|
current: currentValue,
|
||||||
|
server: serverValue,
|
||||||
|
differs: currentValue !== serverValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { apiFetchWithParams } from '$lib/utils/api-fetch';
|
||||||
|
|
||||||
|
export class PropsService {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Fetching
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches global server properties from the `/props` endpoint.
|
||||||
|
* In MODEL mode, returns modalities for the single loaded model.
|
||||||
|
* In ROUTER mode, returns server-wide settings without model-specific modalities.
|
||||||
|
*
|
||||||
|
* @param autoload - If false, prevents automatic model loading (default: false)
|
||||||
|
* @returns Server properties including default generation settings and capabilities
|
||||||
|
* @throws {Error} If the request fails or returns invalid data
|
||||||
|
*/
|
||||||
|
static async fetch(autoload = false): Promise<ApiLlamaCppServerProps> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (!autoload) {
|
||||||
|
params.autoload = 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiFetchWithParams<ApiLlamaCppServerProps>('./props', params, { authOnly: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches server properties for a specific model (ROUTER mode only).
|
||||||
|
* Required in ROUTER mode because global `/props` does not include per-model modalities.
|
||||||
|
*
|
||||||
|
* @param modelId - The model ID to fetch properties for
|
||||||
|
* @param autoload - If false, prevents automatic model loading (default: false)
|
||||||
|
* @returns Server properties specific to the requested model
|
||||||
|
* @throws {Error} If the request fails, model not found, or model not loaded
|
||||||
|
*/
|
||||||
|
static async fetchForModel(modelId: string, autoload = false): Promise<ApiLlamaCppServerProps> {
|
||||||
|
const params: Record<string, string> = { model: modelId };
|
||||||
|
if (!autoload) {
|
||||||
|
params.autoload = 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiFetchWithParams<ApiLlamaCppServerProps>('./props', params, { authOnly: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
import type { ServerModelStatus, ServerRole } from '$lib/enums';
|
import type { ContentPartType, ServerModelStatus, ServerRole } from '$lib/enums';
|
||||||
import type { ChatMessagePromptProgress } from './chat';
|
import type { ChatMessagePromptProgress, ChatRole } from './chat';
|
||||||
|
|
||||||
|
export interface ApiChatCompletionToolFunction {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiChatCompletionTool {
|
||||||
|
type: 'function';
|
||||||
|
function: ApiChatCompletionToolFunction;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiChatMessageContentPart {
|
export interface ApiChatMessageContentPart {
|
||||||
type: 'text' | 'image_url' | 'input_audio';
|
type: ContentPartType;
|
||||||
text?: string;
|
text?: string;
|
||||||
image_url?: {
|
image_url?: {
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -34,6 +45,8 @@ export interface ApiErrorResponse {
|
||||||
export interface ApiChatMessageData {
|
export interface ApiChatMessageData {
|
||||||
role: ChatRole;
|
role: ChatRole;
|
||||||
content: string | ApiChatMessageContentPart[];
|
content: string | ApiChatMessageContentPart[];
|
||||||
|
tool_calls?: ApiChatCompletionToolCall[];
|
||||||
|
tool_call_id?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,6 +201,7 @@ export interface ApiChatCompletionRequest {
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
model?: string;
|
model?: string;
|
||||||
return_progress?: boolean;
|
return_progress?: boolean;
|
||||||
|
tools?: ApiChatCompletionTool[];
|
||||||
// Reasoning parameters
|
// Reasoning parameters
|
||||||
reasoning_format?: string;
|
reasoning_format?: string;
|
||||||
// Generation parameters
|
// Generation parameters
|
||||||
|
|
@ -247,6 +261,7 @@ export interface ApiChatCompletionStreamChunk {
|
||||||
model?: string;
|
model?: string;
|
||||||
tool_calls?: ApiChatCompletionToolCallDelta[];
|
tool_calls?: ApiChatCompletionToolCallDelta[];
|
||||||
};
|
};
|
||||||
|
finish_reason?: string | null;
|
||||||
}>;
|
}>;
|
||||||
timings?: {
|
timings?: {
|
||||||
prompt_n?: number;
|
prompt_n?: number;
|
||||||
|
|
@ -267,8 +282,9 @@ export interface ApiChatCompletionResponse {
|
||||||
content: string;
|
content: string;
|
||||||
reasoning_content?: string;
|
reasoning_content?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
tool_calls?: ApiChatCompletionToolCallDelta[];
|
tool_calls?: ApiChatCompletionToolCall[];
|
||||||
};
|
};
|
||||||
|
finish_reason?: string | null;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,7 +351,7 @@ export interface ApiProcessingState {
|
||||||
tokensDecoded: number;
|
tokensDecoded: number;
|
||||||
tokensRemaining: number;
|
tokensRemaining: number;
|
||||||
contextUsed: number;
|
contextUsed: number;
|
||||||
contextTotal: number;
|
contextTotal: number | null;
|
||||||
outputTokensUsed: number; // Total output tokens (thinking + regular content)
|
outputTokensUsed: number; // Total output tokens (thinking + regular content)
|
||||||
outputTokensMax: number; // Max output tokens allowed
|
outputTokensMax: number; // Max output tokens allowed
|
||||||
temperature: number;
|
temperature: number;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
|
import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
|
||||||
|
|
||||||
/**
|
|
||||||
* Model modalities - vision and audio capabilities
|
|
||||||
*/
|
|
||||||
export interface ModelModalities {
|
export interface ModelModalities {
|
||||||
vision: boolean;
|
vision: boolean;
|
||||||
audio: boolean;
|
audio: boolean;
|
||||||
|
|
@ -14,8 +11,15 @@ export interface ModelOption {
|
||||||
model: string;
|
model: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
/** Model modalities from /props endpoint */
|
|
||||||
modalities?: ModelModalities;
|
modalities?: ModelModalities;
|
||||||
details?: ApiModelDetails['details'];
|
details?: ApiModelDetails['details'];
|
||||||
meta?: ApiModelDataEntry['meta'];
|
meta?: ApiModelDataEntry['meta'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modality capabilities for file validation
|
||||||
|
*/
|
||||||
|
export interface ModalityCapabilities {
|
||||||
|
hasVision: boolean;
|
||||||
|
hasAudio: boolean;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* Abort Signal Utilities
|
||||||
|
*
|
||||||
|
* Provides utilities for consistent AbortSignal propagation across the application.
|
||||||
|
* These utilities help ensure that async operations can be properly cancelled
|
||||||
|
* when needed (e.g., user stops generation, navigates away, etc.).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws an AbortError if the signal is aborted.
|
||||||
|
* Use this at the start of async operations to fail fast.
|
||||||
|
*
|
||||||
|
* @param signal - Optional AbortSignal to check
|
||||||
|
* @throws DOMException with name 'AbortError' if signal is aborted
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* async function fetchData(signal?: AbortSignal) {
|
||||||
|
* throwIfAborted(signal);
|
||||||
|
* // ... proceed with operation
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function throwIfAborted(signal?: AbortSignal): void {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Operation was aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an error is an AbortError.
|
||||||
|
* Use this to distinguish between user-initiated cancellation and actual errors.
|
||||||
|
*
|
||||||
|
* @param error - Error to check
|
||||||
|
* @returns true if the error is an AbortError
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* try {
|
||||||
|
* await fetchData(signal);
|
||||||
|
* } catch (error) {
|
||||||
|
* if (isAbortError(error)) {
|
||||||
|
* // User cancelled - no error dialog needed
|
||||||
|
* return;
|
||||||
|
* }
|
||||||
|
* // Handle actual error
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function isAbortError(error: unknown): boolean {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new AbortController that is linked to one or more parent signals.
|
||||||
|
* When any parent signal aborts, the returned controller also aborts.
|
||||||
|
*
|
||||||
|
* Useful for creating child operations that should be cancelled when
|
||||||
|
* either the parent operation or their own timeout/condition triggers.
|
||||||
|
*
|
||||||
|
* @param signals - Parent signals to link to (undefined signals are ignored)
|
||||||
|
* @returns A new AbortController linked to all provided signals
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Link to user's abort signal and add a timeout
|
||||||
|
* const linked = createLinkedController(userSignal, timeoutSignal);
|
||||||
|
* await fetch(url, { signal: linked.signal });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createLinkedController(...signals: (AbortSignal | undefined)[]): AbortController {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
for (const signal of signals) {
|
||||||
|
if (!signal) continue;
|
||||||
|
|
||||||
|
// If already aborted, abort immediately
|
||||||
|
if (signal.aborted) {
|
||||||
|
controller.abort(signal.reason);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link to parent signal
|
||||||
|
signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an AbortSignal that times out after the specified duration.
|
||||||
|
*
|
||||||
|
* @param ms - Timeout duration in milliseconds
|
||||||
|
* @returns AbortSignal that will abort after the timeout
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const signal = createTimeoutSignal(5000); // 5 second timeout
|
||||||
|
* await fetch(url, { signal });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createTimeoutSignal(ms: number): AbortSignal {
|
||||||
|
return AbortSignal.timeout(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a promise to reject if the signal is aborted.
|
||||||
|
* Useful for making non-abortable promises respect an AbortSignal.
|
||||||
|
*
|
||||||
|
* @param promise - Promise to wrap
|
||||||
|
* @param signal - AbortSignal to respect
|
||||||
|
* @returns Promise that rejects with AbortError if signal aborts
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Make a non-abortable operation respect abort signal
|
||||||
|
* const result = await withAbortSignal(
|
||||||
|
* someNonAbortableOperation(),
|
||||||
|
* signal
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function withAbortSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
|
if (!signal) return promise;
|
||||||
|
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const abortHandler = () => {
|
||||||
|
reject(new DOMException('Operation was aborted', 'AbortError'));
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener('abort', abortHandler, { once: true });
|
||||||
|
|
||||||
|
promise
|
||||||
|
.then((value) => {
|
||||||
|
signal.removeEventListener('abort', abortHandler);
|
||||||
|
resolve(value);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
signal.removeEventListener('abort', abortHandler);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { getJsonHeaders, getAuthHeaders } from './api-headers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Fetch Utilities
|
||||||
|
*
|
||||||
|
* Provides common fetch patterns used across services:
|
||||||
|
* - Automatic JSON headers
|
||||||
|
* - Error handling with proper error messages
|
||||||
|
* - Base path resolution
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ApiFetchOptions extends Omit<RequestInit, 'headers'> {
|
||||||
|
/**
|
||||||
|
* Use auth-only headers (no Content-Type).
|
||||||
|
* Default: false (uses JSON headers with Content-Type: application/json)
|
||||||
|
*/
|
||||||
|
authOnly?: boolean;
|
||||||
|
/**
|
||||||
|
* Additional headers to merge with default headers.
|
||||||
|
*/
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch JSON data from an API endpoint with standard headers and error handling.
|
||||||
|
*
|
||||||
|
* @param path - API path (will be prefixed with base path)
|
||||||
|
* @param options - Fetch options with additional authOnly flag
|
||||||
|
* @returns Parsed JSON response
|
||||||
|
* @throws Error with formatted message on failure
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // GET request
|
||||||
|
* const models = await apiFetch<ApiModelListResponse>('/v1/models');
|
||||||
|
*
|
||||||
|
* // POST request
|
||||||
|
* const result = await apiFetch<ApiResponse>('/models/load', {
|
||||||
|
* method: 'POST',
|
||||||
|
* body: JSON.stringify({ model: 'gpt-4' })
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||||
|
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
|
||||||
|
|
||||||
|
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
|
||||||
|
const headers = { ...baseHeaders, ...customHeaders };
|
||||||
|
|
||||||
|
const url =
|
||||||
|
path.startsWith('http://') || path.startsWith('https://') ? path : `${base}${path}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...fetchOptions,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = await parseErrorMessage(response);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch with URL constructed from base URL and query parameters.
|
||||||
|
*
|
||||||
|
* @param basePath - Base API path
|
||||||
|
* @param params - Query parameters to append
|
||||||
|
* @param options - Fetch options
|
||||||
|
* @returns Parsed JSON response
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const props = await apiFetchWithParams<ApiProps>('./props', {
|
||||||
|
* model: 'gpt-4',
|
||||||
|
* autoload: 'false'
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function apiFetchWithParams<T>(
|
||||||
|
basePath: string,
|
||||||
|
params: Record<string, string>,
|
||||||
|
options: ApiFetchOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = new URL(basePath, window.location.href);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { authOnly = false, headers: customHeaders, ...fetchOptions } = options;
|
||||||
|
|
||||||
|
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
|
||||||
|
const headers = { ...baseHeaders, ...customHeaders };
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
...fetchOptions,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = await parseErrorMessage(response);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST JSON data to an API endpoint.
|
||||||
|
*
|
||||||
|
* @param path - API path
|
||||||
|
* @param body - Request body (will be JSON stringified)
|
||||||
|
* @param options - Additional fetch options
|
||||||
|
* @returns Parsed JSON response
|
||||||
|
*/
|
||||||
|
export async function apiPost<T, B = unknown>(
|
||||||
|
path: string,
|
||||||
|
body: B,
|
||||||
|
options: ApiFetchOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
return apiFetch<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse error message from a failed response.
|
||||||
|
* Tries to extract error message from JSON body, falls back to status text.
|
||||||
|
*/
|
||||||
|
async function parseErrorMessage(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData?.error?.message) {
|
||||||
|
return errorData.error.message;
|
||||||
|
}
|
||||||
|
if (errorData?.error && typeof errorData.error === 'string') {
|
||||||
|
return errorData.error;
|
||||||
|
}
|
||||||
|
if (errorData?.message) {
|
||||||
|
return errorData.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON parsing failed, use status text
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Request failed: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
* └── message 5 (assistant)
|
* └── message 5 (assistant)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { MessageRole } from '$lib/enums/chat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters messages to get the conversation path from root to a specific leaf node.
|
* Filters messages to get the conversation path from root to a specific leaf node.
|
||||||
* If the leafNodeId doesn't exist, returns the path with the latest timestamp.
|
* If the leafNodeId doesn't exist, returns the path with the latest timestamp.
|
||||||
|
|
@ -65,8 +67,13 @@ export function filterByLeafNodeId(
|
||||||
currentNode = nodeMap.get(currentNode.parent);
|
currentNode = nodeMap.get(currentNode.parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by timestamp to get chronological order (root to leaf)
|
// Sort: system messages first, then by timestamp
|
||||||
result.sort((a, b) => a.timestamp - b.timestamp);
|
result.sort((a, b) => {
|
||||||
|
if (a.role === MessageRole.SYSTEM && b.role !== MessageRole.SYSTEM) return -1;
|
||||||
|
if (a.role !== MessageRole.SYSTEM && b.role === MessageRole.SYSTEM) return 1;
|
||||||
|
|
||||||
|
return a.timestamp - b.timestamp;
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export {
|
||||||
} from './pdf-processing';
|
} from './pdf-processing';
|
||||||
|
|
||||||
// File conversion utilities (depends on pdf-processing)
|
// File conversion utilities (depends on pdf-processing)
|
||||||
export { parseFilesToMessageExtras, type FileProcessingResult } from './convert-files-to-extra';
|
export { parseFilesToMessageExtras } from './convert-files-to-extra';
|
||||||
|
|
||||||
// File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png)
|
// File upload processing utilities (depends on pdf-processing, svg-to-png, webp-to-png)
|
||||||
export { processFilesToChatUploaded } from './process-uploaded-files';
|
export { processFilesToChatUploaded } from './process-uploaded-files';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const DEFAULT_CACHE_MAX_ENTRIES = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TTL Cache - Time-To-Live cache implementation for memory optimization
|
||||||
|
*
|
||||||
|
* Provides automatic expiration of cached entries to prevent memory bloat
|
||||||
|
* in long-running sessions.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const cache = new TTLCache<string, ApiData>({ ttlMs: 5 * 60 * 1000 }); // 5 minutes
|
||||||
|
* cache.set('key', data);
|
||||||
|
* const value = cache.get('key'); // null if expired
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TTLCacheOptions {
|
||||||
|
/** Time-to-live in milliseconds. Default: 5 minutes */
|
||||||
|
ttlMs?: number;
|
||||||
|
/** Maximum number of entries. Oldest entries are evicted when exceeded. Default: 100 */
|
||||||
|
maxEntries?: number;
|
||||||
|
/** Callback when an entry expires or is evicted */
|
||||||
|
onEvict?: (key: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
value: T;
|
||||||
|
expiresAt: number;
|
||||||
|
lastAccessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TTLCache<K extends string, V> {
|
||||||
|
private cache = new Map<K, CacheEntry<V>>();
|
||||||
|
private readonly ttlMs: number;
|
||||||
|
private readonly maxEntries: number;
|
||||||
|
private readonly onEvict?: (key: string, value: unknown) => void;
|
||||||
|
|
||||||
|
constructor(options: TTLCacheOptions = {}) {
|
||||||
|
this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||||
|
this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
|
||||||
|
this.onEvict = options.onEvict;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from cache. Returns null if expired or not found.
|
||||||
|
*/
|
||||||
|
get(key: K): V | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last accessed time for LRU-like behavior
|
||||||
|
entry.lastAccessed = Date.now();
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in cache with TTL.
|
||||||
|
*/
|
||||||
|
set(key: K, value: V, customTtlMs?: number): void {
|
||||||
|
// Evict oldest entries if at capacity
|
||||||
|
if (this.cache.size >= this.maxEntries && !this.cache.has(key)) {
|
||||||
|
this.evictOldest();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = customTtlMs ?? this.ttlMs;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
this.cache.set(key, {
|
||||||
|
value,
|
||||||
|
expiresAt: now + ttl,
|
||||||
|
lastAccessed: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key exists and is not expired.
|
||||||
|
*/
|
||||||
|
has(key: K): boolean {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.delete(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific key from cache.
|
||||||
|
*/
|
||||||
|
delete(key: K): boolean {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (entry && this.onEvict) {
|
||||||
|
this.onEvict(key, entry.value);
|
||||||
|
}
|
||||||
|
return this.cache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all entries from cache.
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
if (this.onEvict) {
|
||||||
|
for (const [key, entry] of this.cache) {
|
||||||
|
this.onEvict(key, entry.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of entries (including potentially expired ones).
|
||||||
|
*/
|
||||||
|
get size(): number {
|
||||||
|
return this.cache.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all expired entries from cache.
|
||||||
|
* Call periodically for proactive cleanup.
|
||||||
|
*/
|
||||||
|
prune(): number {
|
||||||
|
const now = Date.now();
|
||||||
|
let pruned = 0;
|
||||||
|
|
||||||
|
for (const [key, entry] of this.cache) {
|
||||||
|
if (now > entry.expiresAt) {
|
||||||
|
this.delete(key);
|
||||||
|
pruned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pruned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all valid (non-expired) keys.
|
||||||
|
*/
|
||||||
|
keys(): K[] {
|
||||||
|
const now = Date.now();
|
||||||
|
const validKeys: K[] = [];
|
||||||
|
|
||||||
|
for (const [key, entry] of this.cache) {
|
||||||
|
if (now <= entry.expiresAt) {
|
||||||
|
validKeys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return validKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict the oldest (least recently accessed) entry.
|
||||||
|
*/
|
||||||
|
private evictOldest(): void {
|
||||||
|
let oldestKey: K | null = null;
|
||||||
|
let oldestTime = Infinity;
|
||||||
|
|
||||||
|
for (const [key, entry] of this.cache) {
|
||||||
|
if (entry.lastAccessed < oldestTime) {
|
||||||
|
oldestTime = entry.lastAccessed;
|
||||||
|
oldestKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldestKey !== null) {
|
||||||
|
this.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh TTL for an existing entry without changing the value.
|
||||||
|
*/
|
||||||
|
touch(key: K): boolean {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now > entry.expiresAt) {
|
||||||
|
this.delete(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.expiresAt = now + this.ttlMs;
|
||||||
|
entry.lastAccessed = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive TTL Map for Svelte stores
|
||||||
|
* Wraps SvelteMap with TTL functionality
|
||||||
|
*/
|
||||||
|
export class ReactiveTTLMap<K extends string, V> {
|
||||||
|
private entries = $state<Map<K, CacheEntry<V>>>(new Map());
|
||||||
|
private readonly ttlMs: number;
|
||||||
|
private readonly maxEntries: number;
|
||||||
|
|
||||||
|
constructor(options: TTLCacheOptions = {}) {
|
||||||
|
this.ttlMs = options.ttlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||||
|
this.maxEntries = options.maxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: K): V | null {
|
||||||
|
const entry = this.entries.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.entries.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.lastAccessed = Date.now();
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K, value: V, customTtlMs?: number): void {
|
||||||
|
if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
|
||||||
|
this.evictOldest();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = customTtlMs ?? this.ttlMs;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
this.entries.set(key, {
|
||||||
|
value,
|
||||||
|
expiresAt: now + ttl,
|
||||||
|
lastAccessed: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key: K): boolean {
|
||||||
|
const entry = this.entries.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
this.entries.delete(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: K): boolean {
|
||||||
|
return this.entries.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.entries.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.entries.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
prune(): number {
|
||||||
|
const now = Date.now();
|
||||||
|
let pruned = 0;
|
||||||
|
|
||||||
|
for (const [key, entry] of this.entries) {
|
||||||
|
if (now > entry.expiresAt) {
|
||||||
|
this.entries.delete(key);
|
||||||
|
pruned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pruned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private evictOldest(): void {
|
||||||
|
let oldestKey: K | null = null;
|
||||||
|
let oldestTime = Infinity;
|
||||||
|
|
||||||
|
for (const [key, entry] of this.entries) {
|
||||||
|
if (entry.lastAccessed < oldestTime) {
|
||||||
|
oldestTime = entry.lastAccessed;
|
||||||
|
oldestKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldestKey !== null) {
|
||||||
|
this.entries.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import hljs from 'highlight.js';
|
||||||
|
import {
|
||||||
|
NEWLINE,
|
||||||
|
DEFAULT_LANGUAGE,
|
||||||
|
LANG_PATTERN,
|
||||||
|
AMPERSAND_REGEX,
|
||||||
|
LT_REGEX,
|
||||||
|
GT_REGEX,
|
||||||
|
FENCE_PATTERN
|
||||||
|
} from '$lib/constants/code';
|
||||||
|
|
||||||
|
export interface IncompleteCodeBlock {
|
||||||
|
language: string;
|
||||||
|
code: string;
|
||||||
|
openingIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlights code using highlight.js
|
||||||
|
* @param code - The code to highlight
|
||||||
|
* @param language - The programming language
|
||||||
|
* @returns HTML string with syntax highlighting
|
||||||
|
*/
|
||||||
|
export function highlightCode(code: string, language: string): string {
|
||||||
|
if (!code) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lang = language.toLowerCase();
|
||||||
|
const isSupported = hljs.getLanguage(lang);
|
||||||
|
|
||||||
|
if (isSupported) {
|
||||||
|
return hljs.highlight(code, { language: lang }).value;
|
||||||
|
} else {
|
||||||
|
return hljs.highlightAuto(code).value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to escaped plain text
|
||||||
|
return code
|
||||||
|
.replace(AMPERSAND_REGEX, '&')
|
||||||
|
.replace(LT_REGEX, '<')
|
||||||
|
.replace(GT_REGEX, '>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if markdown ends with an incomplete code block (opened but not closed).
|
||||||
|
* Returns the code block info if found, null otherwise.
|
||||||
|
* @param markdown - The raw markdown string to check
|
||||||
|
* @returns IncompleteCodeBlock info or null
|
||||||
|
*/
|
||||||
|
export function detectIncompleteCodeBlock(markdown: string): IncompleteCodeBlock | null {
|
||||||
|
// Count all code fences in the markdown
|
||||||
|
// A code block is incomplete if there's an odd number of ``` fences
|
||||||
|
const fencePattern = new RegExp(FENCE_PATTERN.source, FENCE_PATTERN.flags);
|
||||||
|
const fences: number[] = [];
|
||||||
|
let fenceMatch;
|
||||||
|
|
||||||
|
while ((fenceMatch = fencePattern.exec(markdown)) !== null) {
|
||||||
|
// Store the position after the ```
|
||||||
|
const pos = fenceMatch[0].startsWith(NEWLINE) ? fenceMatch.index + 1 : fenceMatch.index;
|
||||||
|
fences.push(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If even number of fences (including 0), all code blocks are closed
|
||||||
|
if (fences.length % 2 === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Odd number means last code block is incomplete
|
||||||
|
// The last fence is the opening of the incomplete block
|
||||||
|
const openingIndex = fences[fences.length - 1];
|
||||||
|
const afterOpening = markdown.slice(openingIndex + 3);
|
||||||
|
|
||||||
|
// Extract language and code content
|
||||||
|
const langMatch = afterOpening.match(LANG_PATTERN);
|
||||||
|
const language = langMatch?.[1] || DEFAULT_LANGUAGE;
|
||||||
|
const codeStartIndex = openingIndex + 3 + (langMatch?.[0]?.length ?? 0);
|
||||||
|
const code = markdown.slice(codeStartIndex);
|
||||||
|
|
||||||
|
return {
|
||||||
|
language,
|
||||||
|
code,
|
||||||
|
openingIndex
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* Creates a base64 data URL from MIME type and base64-encoded data.
|
||||||
|
*
|
||||||
|
* @param mimeType - The MIME type (e.g., 'image/png', 'audio/mp3')
|
||||||
|
* @param base64Data - The base64-encoded data
|
||||||
|
* @returns A data URL string in format 'data:{mimeType};base64,{data}'
|
||||||
|
*/
|
||||||
|
export function createBase64DataUrl(mimeType: string, base64Data: string): string {
|
||||||
|
return `data:${mimeType};base64,${base64Data}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* @param fn - The function to debounce
|
||||||
|
* @param delay - The delay in milliseconds
|
||||||
|
* @returns A debounced version of the function
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: Parameters<T>) => void>(
|
||||||
|
fn: T,
|
||||||
|
delay: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
fn(...args);
|
||||||
|
timeoutId = null;
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* Simplified HTML fallback for external images that fail to load.
|
||||||
|
* Displays a centered message with a link to open the image in a new tab.
|
||||||
|
*/
|
||||||
|
export function getImageErrorFallbackHtml(src: string): string {
|
||||||
|
return `<div class="image-error-content">
|
||||||
|
<span>Image cannot be displayed</span>
|
||||||
|
<a href="${src}" target="_blank" rel="noopener noreferrer">(open link)</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
// API utilities
|
// API utilities
|
||||||
export { getAuthHeaders, getJsonHeaders } from './api-headers';
|
export { getAuthHeaders, getJsonHeaders } from './api-headers';
|
||||||
|
export { apiFetch, apiFetchWithParams, apiPost, type ApiFetchOptions } from './api-fetch';
|
||||||
export { validateApiKey } from './api-key-validation';
|
export { validateApiKey } from './api-key-validation';
|
||||||
|
|
||||||
// Attachment utilities
|
// Attachment utilities
|
||||||
|
|
@ -75,8 +76,7 @@ export { maskInlineLaTeX, preprocessLaTeX } from './latex-protection';
|
||||||
export {
|
export {
|
||||||
isFileTypeSupportedByModel,
|
isFileTypeSupportedByModel,
|
||||||
filterFilesByModalities,
|
filterFilesByModalities,
|
||||||
generateModalityErrorMessage,
|
generateModalityErrorMessage
|
||||||
type ModalityCapabilities
|
|
||||||
} from './modality-file-validation';
|
} from './modality-file-validation';
|
||||||
|
|
||||||
// Model name utilities
|
// Model name utilities
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,7 @@
|
||||||
|
|
||||||
import { getFileTypeCategory } from '$lib/utils';
|
import { getFileTypeCategory } from '$lib/utils';
|
||||||
import { FileTypeCategory } from '$lib/enums';
|
import { FileTypeCategory } from '$lib/enums';
|
||||||
|
import type { ModalityCapabilities } from '$lib/types/models';
|
||||||
/** Modality capabilities for file validation */
|
|
||||||
export interface ModalityCapabilities {
|
|
||||||
hasVision: boolean;
|
|
||||||
hasAudio: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a file type is supported by the given modalities
|
* Check if a file type is supported by the given modalities
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,8 @@
|
||||||
* Handles text file detection, reading, and validation
|
* Handles text file detection, reading, and validation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { DEFAULT_BINARY_DETECTION_OPTIONS } from '$lib/constants/binary-detection';
|
||||||
DEFAULT_BINARY_DETECTION_OPTIONS,
|
import type { BinaryDetectionOptions } from '$lib/constants/binary-detection';
|
||||||
type BinaryDetectionOptions
|
|
||||||
} from '$lib/constants/binary-detection';
|
|
||||||
import { FileExtensionText } from '$lib/enums';
|
import { FileExtensionText } from '$lib/enums';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue