sync from b7516
This commit is contained in:
@@ -10,11 +10,21 @@
|
||||
import { INPUT_CLASSES } from '$lib/constants/input-classes';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { MimeTypeText } from '$lib/enums';
|
||||
import {
|
||||
FileTypeCategory,
|
||||
MimeTypeApplication,
|
||||
FileExtensionAudio,
|
||||
FileExtensionImage,
|
||||
FileExtensionPdf,
|
||||
FileExtensionText,
|
||||
MimeTypeAudio,
|
||||
MimeTypeImage,
|
||||
MimeTypeText
|
||||
} from '$lib/enums';
|
||||
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
|
||||
import {
|
||||
AudioRecorder,
|
||||
@@ -51,6 +61,7 @@
|
||||
let audioRecorder: AudioRecorder | undefined;
|
||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||
let currentConfig = $derived(config());
|
||||
let fileAcceptString = $state<string | undefined>(undefined);
|
||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
||||
let isRecording = $state(false);
|
||||
let message = $state('');
|
||||
@@ -93,6 +104,40 @@
|
||||
return null;
|
||||
});
|
||||
|
||||
// State for model props reactivity
|
||||
let modelPropsVersion = $state(0);
|
||||
|
||||
// Fetch model props when active model changes (works for both MODEL and ROUTER mode)
|
||||
$effect(() => {
|
||||
if (activeModelId) {
|
||||
const cached = modelsStore.getModelProps(activeModelId);
|
||||
if (!cached) {
|
||||
modelsStore.fetchModelProps(activeModelId).then(() => {
|
||||
modelPropsVersion++;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Derive modalities from active model (works for both MODEL and ROUTER mode)
|
||||
let hasAudioModality = $derived.by(() => {
|
||||
if (activeModelId) {
|
||||
void modelPropsVersion; // Trigger reactivity on props fetch
|
||||
return modelsStore.modelSupportsAudio(activeModelId);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
let hasVisionModality = $derived.by(() => {
|
||||
if (activeModelId) {
|
||||
void modelPropsVersion; // Trigger reactivity on props fetch
|
||||
return modelsStore.modelSupportsVision(activeModelId);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
function checkModelSelected(): boolean {
|
||||
if (!hasModelSelected) {
|
||||
// Open the model selector
|
||||
@@ -103,12 +148,42 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAcceptStringForFileType(fileType: FileTypeCategory): string {
|
||||
switch (fileType) {
|
||||
case FileTypeCategory.IMAGE:
|
||||
return [...Object.values(FileExtensionImage), ...Object.values(MimeTypeImage)].join(',');
|
||||
|
||||
case FileTypeCategory.AUDIO:
|
||||
return [...Object.values(FileExtensionAudio), ...Object.values(MimeTypeAudio)].join(',');
|
||||
|
||||
case FileTypeCategory.PDF:
|
||||
return [...Object.values(FileExtensionPdf), ...Object.values(MimeTypeApplication)].join(
|
||||
','
|
||||
);
|
||||
|
||||
case FileTypeCategory.TEXT:
|
||||
return [...Object.values(FileExtensionText), MimeTypeText.PLAIN].join(',');
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(files: File[]) {
|
||||
onFileUpload?.(files);
|
||||
}
|
||||
|
||||
function handleFileUpload() {
|
||||
fileInputRef?.click();
|
||||
function handleFileUpload(fileType?: FileTypeCategory) {
|
||||
if (fileType) {
|
||||
fileAcceptString = getAcceptStringForFileType(fileType);
|
||||
} else {
|
||||
fileAcceptString = undefined;
|
||||
}
|
||||
|
||||
// Use setTimeout to ensure the accept attribute is applied before opening dialog
|
||||
setTimeout(() => {
|
||||
fileInputRef?.click();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
async function handleKeydown(event: KeyboardEvent) {
|
||||
@@ -268,7 +343,13 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
|
||||
<ChatFormFileInputInvisible
|
||||
bind:this={fileInputRef}
|
||||
bind:accept={fileAcceptString}
|
||||
{hasAudioModality}
|
||||
{hasVisionModality}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
|
||||
import { FileTypeCategory } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
hasAudioModality?: boolean;
|
||||
hasVisionModality?: boolean;
|
||||
onFileUpload?: () => void;
|
||||
onFileUpload?: (fileType?: FileTypeCategory) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -26,6 +27,10 @@
|
||||
? 'Text files and PDFs supported. Images, audio, and video require vision models.'
|
||||
: 'Attach files';
|
||||
});
|
||||
|
||||
function handleFileUpload(fileType?: FileTypeCategory) {
|
||||
onFileUpload?.(fileType);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
@@ -56,7 +61,7 @@
|
||||
<DropdownMenu.Item
|
||||
class="images-button flex cursor-pointer items-center gap-2"
|
||||
disabled={!hasVisionModality}
|
||||
onclick={() => onFileUpload?.()}
|
||||
onclick={() => handleFileUpload(FileTypeCategory.IMAGE)}
|
||||
>
|
||||
<FILE_TYPE_ICONS.image class="h-4 w-4" />
|
||||
|
||||
@@ -76,7 +81,7 @@
|
||||
<DropdownMenu.Item
|
||||
class="audio-button flex cursor-pointer items-center gap-2"
|
||||
disabled={!hasAudioModality}
|
||||
onclick={() => onFileUpload?.()}
|
||||
onclick={() => handleFileUpload(FileTypeCategory.AUDIO)}
|
||||
>
|
||||
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
|
||||
|
||||
@@ -93,7 +98,7 @@
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onFileUpload?.()}
|
||||
onclick={() => handleFileUpload(FileTypeCategory.TEXT)}
|
||||
>
|
||||
<FILE_TYPE_ICONS.text class="h-4 w-4" />
|
||||
|
||||
@@ -104,7 +109,7 @@
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onFileUpload?.()}
|
||||
onclick={() => handleFileUpload(FileTypeCategory.PDF)}
|
||||
>
|
||||
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
isRecording?: boolean;
|
||||
hasText?: boolean;
|
||||
uploadedFiles?: ChatUploadedFile[];
|
||||
onFileUpload?: () => void;
|
||||
onFileUpload?: (fileType?: FileTypeCategory) => void;
|
||||
onMicClick?: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { generateModalityAwareAcceptString } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
accept?: string;
|
||||
class?: string;
|
||||
hasAudioModality?: boolean;
|
||||
hasVisionModality?: boolean;
|
||||
multiple?: boolean;
|
||||
onFileSelect?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
let { class: className = '', multiple = true, onFileSelect }: Props = $props();
|
||||
let {
|
||||
accept = $bindable(),
|
||||
class: className = '',
|
||||
hasAudioModality = false,
|
||||
hasVisionModality = false,
|
||||
multiple = true,
|
||||
onFileSelect
|
||||
}: Props = $props();
|
||||
|
||||
let fileInputElement: HTMLInputElement | undefined;
|
||||
|
||||
// Use modality-aware accept string by default, but allow override
|
||||
let finalAccept = $derived(
|
||||
accept ??
|
||||
generateModalityAwareAcceptString({
|
||||
hasVision: hasVisionModality,
|
||||
hasAudio: hasAudioModality
|
||||
})
|
||||
);
|
||||
|
||||
export function click() {
|
||||
fileInputElement?.click();
|
||||
}
|
||||
@@ -25,6 +46,7 @@
|
||||
bind:this={fileInputElement}
|
||||
type="file"
|
||||
{multiple}
|
||||
accept={finalAccept}
|
||||
onchange={handleFileSelect}
|
||||
class="hidden {className}"
|
||||
/>
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
|
||||
|
||||
const processingState = useProcessingState();
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let displayedModel = $derived((): string | null => {
|
||||
@@ -117,12 +116,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isLoading() && !message?.content?.trim()) {
|
||||
processingState.startMonitoring();
|
||||
}
|
||||
});
|
||||
|
||||
function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
|
||||
const callNumber = index + 1;
|
||||
const functionName = toolCall.function?.name?.trim();
|
||||
@@ -193,7 +186,7 @@
|
||||
<div class="mt-6 w-full max-w-[48rem]" in:fade>
|
||||
<div class="processing-container">
|
||||
<span class="processing-text">
|
||||
{processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
|
||||
{processingState.getProcessingMessage()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,23 +263,6 @@
|
||||
predictedTokens={message.timings.predicted_n}
|
||||
predictedMs={message.timings.predicted_ms}
|
||||
/>
|
||||
{:else if isLoading() && currentConfig.showMessageStats}
|
||||
{@const liveStats = processingState.getLiveProcessingStats()}
|
||||
{@const genStats = processingState.getLiveGenerationStats()}
|
||||
{@const promptProgress = processingState.processingState?.promptProgress}
|
||||
{@const isStillProcessingPrompt =
|
||||
promptProgress && promptProgress.processed < promptProgress.total}
|
||||
|
||||
{#if liveStats || genStats}
|
||||
<ChatMessageStatistics
|
||||
isLive={true}
|
||||
isProcessingPrompt={!!isStillProcessingPrompt}
|
||||
promptTokens={liveStats?.tokensProcessed}
|
||||
promptMs={liveStats?.timeMs}
|
||||
predictedTokens={genStats?.tokensGenerated}
|
||||
predictedMs={genStats?.timeMs}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,64 +5,21 @@
|
||||
import { ChatMessageStatsView } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
predictedTokens?: number;
|
||||
predictedMs?: number;
|
||||
predictedTokens: number;
|
||||
predictedMs: number;
|
||||
promptTokens?: number;
|
||||
promptMs?: number;
|
||||
// Live mode: when true, shows stats during streaming
|
||||
isLive?: boolean;
|
||||
// Whether prompt processing is still in progress
|
||||
isProcessingPrompt?: boolean;
|
||||
// Initial view to show (defaults to READING in live mode)
|
||||
initialView?: ChatMessageStatsView;
|
||||
}
|
||||
|
||||
let {
|
||||
predictedTokens,
|
||||
predictedMs,
|
||||
promptTokens,
|
||||
promptMs,
|
||||
isLive = false,
|
||||
isProcessingPrompt = false,
|
||||
initialView = ChatMessageStatsView.GENERATION
|
||||
}: Props = $props();
|
||||
let { predictedTokens, predictedMs, promptTokens, promptMs }: Props = $props();
|
||||
|
||||
let activeView: ChatMessageStatsView = $state(initialView);
|
||||
let hasAutoSwitchedToGeneration = $state(false);
|
||||
let activeView: ChatMessageStatsView = $state(ChatMessageStatsView.GENERATION);
|
||||
|
||||
// In live mode: auto-switch to GENERATION tab when prompt processing completes
|
||||
$effect(() => {
|
||||
if (isLive) {
|
||||
// Auto-switch to generation tab only when prompt processing is done (once)
|
||||
if (
|
||||
!hasAutoSwitchedToGeneration &&
|
||||
!isProcessingPrompt &&
|
||||
predictedTokens &&
|
||||
predictedTokens > 0
|
||||
) {
|
||||
activeView = ChatMessageStatsView.GENERATION;
|
||||
hasAutoSwitchedToGeneration = true;
|
||||
} else if (!hasAutoSwitchedToGeneration) {
|
||||
// Stay on READING while prompt is still being processed
|
||||
activeView = ChatMessageStatsView.READING;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let hasGenerationStats = $derived(
|
||||
predictedTokens !== undefined &&
|
||||
predictedTokens > 0 &&
|
||||
predictedMs !== undefined &&
|
||||
predictedMs > 0
|
||||
);
|
||||
|
||||
let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
|
||||
let timeInSeconds = $derived(
|
||||
predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00'
|
||||
);
|
||||
let tokensPerSecond = $derived((predictedTokens / predictedMs) * 1000);
|
||||
let timeInSeconds = $derived((predictedMs / 1000).toFixed(2));
|
||||
|
||||
let promptTokensPerSecond = $derived(
|
||||
promptTokens !== undefined && promptMs !== undefined && promptMs > 0
|
||||
promptTokens !== undefined && promptMs !== undefined
|
||||
? (promptTokens / promptMs) * 1000
|
||||
: undefined
|
||||
);
|
||||
@@ -77,14 +34,11 @@
|
||||
promptTokensPerSecond !== undefined &&
|
||||
promptTimeInSeconds !== undefined
|
||||
);
|
||||
|
||||
// In live mode, generation tab is disabled until we have generation stats
|
||||
let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
|
||||
</script>
|
||||
|
||||
<div class="inline-flex items-center text-xs text-muted-foreground">
|
||||
<div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
|
||||
{#if hasPromptStats || isLive}
|
||||
{#if hasPromptStats}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
@@ -111,32 +65,25 @@
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
|
||||
ChatMessageStatsView.GENERATION
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: isGenerationDisabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: 'hover:text-foreground'}"
|
||||
onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)}
|
||||
disabled={isGenerationDisabled}
|
||||
: 'hover:text-foreground'}"
|
||||
onclick={() => (activeView = ChatMessageStatsView.GENERATION)}
|
||||
>
|
||||
<Sparkles class="h-3 w-3" />
|
||||
<span class="sr-only">Generation</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>
|
||||
{isGenerationDisabled
|
||||
? 'Generation (waiting for tokens...)'
|
||||
: 'Generation (token output)'}
|
||||
</p>
|
||||
<p>Generation (token output)</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
{#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
|
||||
{#if activeView === ChatMessageStatsView.GENERATION}
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={WholeWord}
|
||||
value="{predictedTokens?.toLocaleString()} tokens"
|
||||
value="{predictedTokens} tokens"
|
||||
tooltipLabel="Generated tokens"
|
||||
/>
|
||||
<BadgeChatStatistic
|
||||
|
||||
@@ -185,11 +185,6 @@
|
||||
key: 'samplers',
|
||||
label: 'Samplers',
|
||||
type: 'input'
|
||||
},
|
||||
{
|
||||
key: 'backend_sampling',
|
||||
label: 'Backend sampling',
|
||||
type: 'checkbox'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -21,7 +21,6 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
|
||||
autoMicOnEmpty: false,
|
||||
// make sure these default values are in sync with `common.h`
|
||||
samplers: 'top_k;typ_p;top_p;min_p;temperature',
|
||||
backend_sampling: false,
|
||||
temperature: 0.8,
|
||||
dynatemp_range: 0.0,
|
||||
dynatemp_exponent: 1.0,
|
||||
@@ -58,8 +57,6 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
|
||||
'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
|
||||
samplers:
|
||||
'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
|
||||
backend_sampling:
|
||||
'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.',
|
||||
temperature:
|
||||
'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.',
|
||||
dynatemp_range:
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
import { activeProcessingState } from '$lib/stores/chat.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
|
||||
export interface LiveProcessingStats {
|
||||
tokensProcessed: number;
|
||||
totalTokens: number;
|
||||
timeMs: number;
|
||||
tokensPerSecond: number;
|
||||
etaSecs?: number;
|
||||
}
|
||||
|
||||
export interface LiveGenerationStats {
|
||||
tokensGenerated: number;
|
||||
timeMs: number;
|
||||
tokensPerSecond: number;
|
||||
}
|
||||
|
||||
export interface UseProcessingStateReturn {
|
||||
readonly processingState: ApiProcessingState | null;
|
||||
getProcessingDetails(): string[];
|
||||
getProcessingMessage(): string;
|
||||
getPromptProgressText(): string | null;
|
||||
getLiveProcessingStats(): LiveProcessingStats | null;
|
||||
getLiveGenerationStats(): LiveGenerationStats | null;
|
||||
shouldShowDetails(): boolean;
|
||||
startMonitoring(): void;
|
||||
stopMonitoring(): void;
|
||||
@@ -46,7 +29,6 @@ export interface UseProcessingStateReturn {
|
||||
export function useProcessingState(): UseProcessingStateReturn {
|
||||
let isMonitoring = $state(false);
|
||||
let lastKnownState = $state<ApiProcessingState | null>(null);
|
||||
let lastKnownProcessingStats = $state<LiveProcessingStats | null>(null);
|
||||
|
||||
// Derive processing state reactively from chatStore's direct state
|
||||
const processingState = $derived.by(() => {
|
||||
@@ -64,34 +46,6 @@ export function useProcessingState(): UseProcessingStateReturn {
|
||||
}
|
||||
});
|
||||
|
||||
// Track last known processing stats for when promptProgress disappears
|
||||
$effect(() => {
|
||||
if (processingState?.promptProgress) {
|
||||
const { processed, total, time_ms, cache } = processingState.promptProgress;
|
||||
const actualProcessed = processed - cache;
|
||||
const actualTotal = total - cache;
|
||||
|
||||
if (actualProcessed > 0 && time_ms > 0) {
|
||||
const tokensPerSecond = actualProcessed / (time_ms / 1000);
|
||||
lastKnownProcessingStats = {
|
||||
tokensProcessed: actualProcessed,
|
||||
totalTokens: actualTotal,
|
||||
timeMs: time_ms,
|
||||
tokensPerSecond
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function getETASecs(done: number, total: number, elapsedMs: number): number | undefined {
|
||||
const elapsedSecs = elapsedMs / 1000;
|
||||
const progressETASecs =
|
||||
done === 0 || elapsedSecs < 0.5
|
||||
? undefined // can be the case for the 0% progress report
|
||||
: elapsedSecs * (total / done - 1);
|
||||
return progressETASecs;
|
||||
}
|
||||
|
||||
function startMonitoring(): void {
|
||||
if (isMonitoring) return;
|
||||
isMonitoring = true;
|
||||
@@ -105,25 +59,28 @@ export function useProcessingState(): UseProcessingStateReturn {
|
||||
const currentConfig = config();
|
||||
if (!currentConfig.keepStatsVisible) {
|
||||
lastKnownState = null;
|
||||
lastKnownProcessingStats = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getProcessingMessage(): string {
|
||||
if (!processingState) {
|
||||
const state = processingState;
|
||||
if (!state) {
|
||||
return 'Processing...';
|
||||
}
|
||||
|
||||
switch (processingState.status) {
|
||||
switch (state.status) {
|
||||
case 'initializing':
|
||||
return 'Initializing...';
|
||||
case 'preparing':
|
||||
if (processingState.progressPercent !== undefined) {
|
||||
return `Processing (${processingState.progressPercent}%)`;
|
||||
if (state.progressPercent !== undefined) {
|
||||
return `Processing (${state.progressPercent}%)`;
|
||||
}
|
||||
return 'Preparing response...';
|
||||
case 'generating':
|
||||
return '';
|
||||
if (state.tokensDecoded > 0) {
|
||||
return `Generating... (${state.tokensDecoded} tokens)`;
|
||||
}
|
||||
return 'Generating...';
|
||||
default:
|
||||
return 'Processing...';
|
||||
}
|
||||
@@ -174,76 +131,8 @@ export function useProcessingState(): UseProcessingStateReturn {
|
||||
}
|
||||
|
||||
function shouldShowDetails(): boolean {
|
||||
return processingState !== null && processingState.status !== 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short progress message with percent
|
||||
*/
|
||||
function getPromptProgressText(): string | null {
|
||||
if (!processingState?.promptProgress) return null;
|
||||
|
||||
const { processed, total, cache } = processingState.promptProgress;
|
||||
|
||||
const actualProcessed = processed - cache;
|
||||
const actualTotal = total - cache;
|
||||
const percent = Math.round((actualProcessed / actualTotal) * 100);
|
||||
const eta = getETASecs(actualProcessed, actualTotal, processingState.promptProgress.time_ms);
|
||||
|
||||
if (eta !== undefined) {
|
||||
const etaSecs = Math.ceil(eta);
|
||||
return `Processing ${percent}% (ETA: ${etaSecs}s)`;
|
||||
}
|
||||
|
||||
return `Processing ${percent}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns live processing statistics for display (prompt processing phase)
|
||||
* Returns last known stats when promptProgress becomes unavailable
|
||||
*/
|
||||
function getLiveProcessingStats(): LiveProcessingStats | null {
|
||||
if (processingState?.promptProgress) {
|
||||
const { processed, total, time_ms, cache } = processingState.promptProgress;
|
||||
|
||||
const actualProcessed = processed - cache;
|
||||
const actualTotal = total - cache;
|
||||
|
||||
if (actualProcessed > 0 && time_ms > 0) {
|
||||
const tokensPerSecond = actualProcessed / (time_ms / 1000);
|
||||
|
||||
return {
|
||||
tokensProcessed: actualProcessed,
|
||||
totalTokens: actualTotal,
|
||||
timeMs: time_ms,
|
||||
tokensPerSecond
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Return last known stats if promptProgress is no longer available
|
||||
return lastKnownProcessingStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns live generation statistics for display (token generation phase)
|
||||
*/
|
||||
function getLiveGenerationStats(): LiveGenerationStats | null {
|
||||
if (!processingState) return null;
|
||||
|
||||
const { tokensDecoded, tokensPerSecond } = processingState;
|
||||
|
||||
if (tokensDecoded <= 0) return null;
|
||||
|
||||
// Calculate time from tokens and speed
|
||||
const timeMs =
|
||||
tokensPerSecond && tokensPerSecond > 0 ? (tokensDecoded / tokensPerSecond) * 1000 : 0;
|
||||
|
||||
return {
|
||||
tokensGenerated: tokensDecoded,
|
||||
timeMs,
|
||||
tokensPerSecond: tokensPerSecond || 0
|
||||
};
|
||||
const state = processingState;
|
||||
return state !== null && state.status !== 'idle';
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -252,9 +141,6 @@ export function useProcessingState(): UseProcessingStateReturn {
|
||||
},
|
||||
getProcessingDetails,
|
||||
getProcessingMessage,
|
||||
getPromptProgressText,
|
||||
getLiveProcessingStats,
|
||||
getLiveGenerationStats,
|
||||
shouldShowDetails,
|
||||
startMonitoring,
|
||||
stopMonitoring
|
||||
|
||||
@@ -86,7 +86,6 @@ export class ChatService {
|
||||
dry_penalty_last_n,
|
||||
// Other parameters
|
||||
samplers,
|
||||
backend_sampling,
|
||||
custom,
|
||||
timings_per_token,
|
||||
// Config options
|
||||
@@ -118,8 +117,7 @@ export class ChatService {
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
})),
|
||||
stream,
|
||||
return_progress: stream ? true : undefined
|
||||
stream
|
||||
};
|
||||
|
||||
// Include model in request if provided (required in ROUTER mode)
|
||||
@@ -160,8 +158,6 @@ export class ChatService {
|
||||
: samplers;
|
||||
}
|
||||
|
||||
if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling;
|
||||
|
||||
if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
|
||||
|
||||
if (custom) {
|
||||
@@ -275,7 +271,7 @@ export class ChatService {
|
||||
onReasoningChunk?: (chunk: string) => void,
|
||||
onToolCallChunk?: (chunk: string) => void,
|
||||
onModel?: (model: string) => void,
|
||||
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
|
||||
onTimings?: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
|
||||
conversationId?: string,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<void> {
|
||||
@@ -370,13 +366,11 @@ export class ChatService {
|
||||
onModel?.(chunkModel);
|
||||
}
|
||||
|
||||
if (promptProgress) {
|
||||
ChatService.notifyTimings(undefined, promptProgress, onTimings);
|
||||
}
|
||||
|
||||
if (timings) {
|
||||
if (timings || promptProgress) {
|
||||
ChatService.notifyTimings(timings, promptProgress, onTimings);
|
||||
lastTimings = timings;
|
||||
if (timings) {
|
||||
lastTimings = timings;
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
@@ -774,11 +768,10 @@ export class ChatService {
|
||||
timings: ChatMessageTimings | undefined,
|
||||
promptProgress: ChatMessagePromptProgress | undefined,
|
||||
onTimingsCallback:
|
||||
| ((timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void)
|
||||
| ((timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void)
|
||||
| undefined
|
||||
): void {
|
||||
if (!onTimingsCallback || (!timings && !promptProgress)) return;
|
||||
|
||||
if (!timings || !onTimingsCallback) return;
|
||||
onTimingsCallback(timings, promptProgress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,17 +303,11 @@ class ChatStore {
|
||||
const currentConfig = config();
|
||||
const outputTokensMax = currentConfig.max_tokens || -1;
|
||||
|
||||
// Note: for timings data, the n_prompt does NOT include cache tokens
|
||||
const contextUsed = promptTokens + cacheTokens + predictedTokens;
|
||||
const outputTokensUsed = predictedTokens;
|
||||
|
||||
// Note: for prompt progress, the "processed" DOES include cache tokens
|
||||
// we need to exclude them to get the real prompt tokens processed count
|
||||
const progressCache = promptProgress?.cache || 0;
|
||||
const progressActualDone = (promptProgress?.processed ?? 0) - progressCache;
|
||||
const progressActualTotal = (promptProgress?.total ?? 0) - progressCache;
|
||||
const progressPercent = promptProgress
|
||||
? Math.round((progressActualDone / progressActualTotal) * 100)
|
||||
? Math.round((promptProgress.processed / promptProgress.total) * 100)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
@@ -330,7 +324,6 @@ class ChatStore {
|
||||
topP: currentConfig.top_p ?? 0.95,
|
||||
speculative: false,
|
||||
progressPercent,
|
||||
promptProgress,
|
||||
promptTokens,
|
||||
promptMs,
|
||||
cacheTokens
|
||||
@@ -541,7 +534,7 @@ class ChatStore {
|
||||
conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
|
||||
},
|
||||
onModel: (modelName: string) => recordModel(modelName),
|
||||
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
|
||||
onTimings: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
|
||||
const tokensPerSecond =
|
||||
timings?.predicted_ms && timings?.predicted_n
|
||||
? (timings.predicted_n / timings.predicted_ms) * 1000
|
||||
@@ -1039,7 +1032,7 @@ class ChatStore {
|
||||
});
|
||||
},
|
||||
|
||||
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
|
||||
onTimings: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
|
||||
const tokensPerSecond =
|
||||
timings?.predicted_ms && timings?.predicted_n
|
||||
? (timings.predicted_n / timings.predicted_ms) * 1000
|
||||
@@ -1461,8 +1454,6 @@ class ChatStore {
|
||||
if (hasValue(currentConfig.dry_penalty_last_n))
|
||||
apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n);
|
||||
if (currentConfig.samplers) apiOptions.samplers = currentConfig.samplers;
|
||||
if (currentConfig.backend_sampling)
|
||||
apiOptions.backend_sampling = currentConfig.backend_sampling;
|
||||
if (currentConfig.custom) apiOptions.custom = currentConfig.custom;
|
||||
|
||||
return apiOptions;
|
||||
|
||||
@@ -294,14 +294,15 @@ class SettingsStore {
|
||||
* This sets up the default values from /props endpoint
|
||||
*/
|
||||
syncWithServerDefaults(): void {
|
||||
const propsDefaults = this.getServerDefaults();
|
||||
|
||||
if (Object.keys(propsDefaults).length === 0) {
|
||||
console.warn('No server defaults available for initialization');
|
||||
const serverParams = serverStore.defaultParams;
|
||||
if (!serverParams) {
|
||||
console.warn('No server parameters available for initialization');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const propsDefaults = this.getServerDefaults();
|
||||
|
||||
for (const [key, propsValue] of Object.entries(propsDefaults)) {
|
||||
const currentValue = getConfigValue(this.config, key);
|
||||
|
||||
|
||||
5
tools/server/webui/src/lib/types/api.d.ts
vendored
5
tools/server/webui/src/lib/types/api.d.ts
vendored
@@ -149,7 +149,6 @@ export interface ApiLlamaCppServerProps {
|
||||
reasoning_in_content: boolean;
|
||||
thinking_forced_open: boolean;
|
||||
samplers: string[];
|
||||
backend_sampling: boolean;
|
||||
'speculative.n_max': number;
|
||||
'speculative.n_min': number;
|
||||
'speculative.p_min': number;
|
||||
@@ -187,7 +186,6 @@ export interface ApiChatCompletionRequest {
|
||||
}>;
|
||||
stream?: boolean;
|
||||
model?: string;
|
||||
return_progress?: boolean;
|
||||
// Reasoning parameters
|
||||
reasoning_format?: string;
|
||||
// Generation parameters
|
||||
@@ -213,7 +211,6 @@ export interface ApiChatCompletionRequest {
|
||||
dry_penalty_last_n?: number;
|
||||
// Sampler configuration
|
||||
samplers?: string[];
|
||||
backend_sampling?: boolean;
|
||||
// Custom parameters (JSON string)
|
||||
custom?: Record<string, unknown>;
|
||||
timings_per_token?: boolean;
|
||||
@@ -314,7 +311,6 @@ export interface ApiSlotData {
|
||||
reasoning_in_content: boolean;
|
||||
thinking_forced_open: boolean;
|
||||
samplers: string[];
|
||||
backend_sampling: boolean;
|
||||
'speculative.n_max': number;
|
||||
'speculative.n_min': number;
|
||||
'speculative.p_min': number;
|
||||
@@ -345,7 +341,6 @@ export interface ApiProcessingState {
|
||||
tokensPerSecond?: number;
|
||||
// Progress information from prompt_progress
|
||||
progressPercent?: number;
|
||||
promptProgress?: ChatMessagePromptProgress;
|
||||
promptTokens?: number;
|
||||
promptMs?: number;
|
||||
cacheTokens?: number;
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface SettingsChatServiceOptions {
|
||||
dry_penalty_last_n?: number;
|
||||
// Sampler configuration
|
||||
samplers?: string | string[];
|
||||
backend_sampling?: boolean;
|
||||
// Custom parameters
|
||||
custom?: string;
|
||||
timings_per_token?: boolean;
|
||||
@@ -52,7 +51,7 @@ export interface SettingsChatServiceOptions {
|
||||
onReasoningChunk?: (chunk: string) => void;
|
||||
onToolCallChunk?: (chunk: string) => void;
|
||||
onModel?: (model: string) => void;
|
||||
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
|
||||
onTimings?: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
|
||||
onComplete?: (
|
||||
response: string,
|
||||
reasoningContent?: string,
|
||||
|
||||
@@ -65,7 +65,10 @@ export async function copyCodeToClipboard(
|
||||
successMessage = 'Code copied to clipboard',
|
||||
errorMessage = 'Failed to copy code'
|
||||
): Promise<boolean> {
|
||||
return copyToClipboard(rawCode, successMessage, errorMessage);
|
||||
const doc = new DOMParser().parseFromString(rawCode, 'text/html');
|
||||
const decodedCode = doc.body.textContent ?? rawCode;
|
||||
|
||||
return copyToClipboard(decodedCode, successMessage, errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -195,28 +195,9 @@ export function getFileTypeByExtension(filename: string): string | null {
|
||||
}
|
||||
|
||||
export function isFileTypeSupported(filename: string, mimeType?: string): boolean {
|
||||
// Images are detected and handled separately for vision models
|
||||
if (mimeType) {
|
||||
const category = getFileTypeCategory(mimeType);
|
||||
if (
|
||||
category === FileTypeCategory.IMAGE ||
|
||||
category === FileTypeCategory.AUDIO ||
|
||||
category === FileTypeCategory.PDF
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check extension for known types (especially images without MIME)
|
||||
const extCategory = getFileTypeCategoryByExtension(filename);
|
||||
if (
|
||||
extCategory === FileTypeCategory.IMAGE ||
|
||||
extCategory === FileTypeCategory.AUDIO ||
|
||||
extCategory === FileTypeCategory.PDF
|
||||
) {
|
||||
if (mimeType && getFileTypeCategory(mimeType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: treat everything else as text (inclusive by default)
|
||||
return true;
|
||||
return getFileTypeByExtension(filename) !== null;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export {
|
||||
isFileTypeSupportedByModel,
|
||||
filterFilesByModalities,
|
||||
generateModalityErrorMessage,
|
||||
generateModalityAwareAcceptString,
|
||||
type ModalityCapabilities
|
||||
} from './modality-file-validation';
|
||||
|
||||
|
||||
@@ -4,7 +4,17 @@
|
||||
*/
|
||||
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
import { FileTypeCategory } from '$lib/enums';
|
||||
import {
|
||||
FileExtensionAudio,
|
||||
FileExtensionImage,
|
||||
FileExtensionPdf,
|
||||
FileExtensionText,
|
||||
MimeTypeAudio,
|
||||
MimeTypeImage,
|
||||
MimeTypeApplication,
|
||||
MimeTypeText,
|
||||
FileTypeCategory
|
||||
} from '$lib/enums';
|
||||
|
||||
/** Modality capabilities for file validation */
|
||||
export interface ModalityCapabilities {
|
||||
@@ -160,3 +170,29 @@ export function generateModalityErrorMessage(
|
||||
* @param capabilities - The modality capabilities to check against
|
||||
* @returns Accept string for HTML file input element
|
||||
*/
|
||||
export function generateModalityAwareAcceptString(capabilities: ModalityCapabilities): string {
|
||||
const { hasVision, hasAudio } = capabilities;
|
||||
|
||||
const acceptedExtensions: string[] = [];
|
||||
const acceptedMimeTypes: string[] = [];
|
||||
|
||||
// Always include text files and PDFs
|
||||
acceptedExtensions.push(...Object.values(FileExtensionText));
|
||||
acceptedMimeTypes.push(...Object.values(MimeTypeText));
|
||||
acceptedExtensions.push(...Object.values(FileExtensionPdf));
|
||||
acceptedMimeTypes.push(...Object.values(MimeTypeApplication));
|
||||
|
||||
// Include images only if vision is supported
|
||||
if (hasVision) {
|
||||
acceptedExtensions.push(...Object.values(FileExtensionImage));
|
||||
acceptedMimeTypes.push(...Object.values(MimeTypeImage));
|
||||
}
|
||||
|
||||
// Include audio only if audio is supported
|
||||
if (hasAudio) {
|
||||
acceptedExtensions.push(...Object.values(FileExtensionAudio));
|
||||
acceptedMimeTypes.push(...Object.values(MimeTypeAudio));
|
||||
}
|
||||
|
||||
return [...acceptedExtensions, ...acceptedMimeTypes].join(',');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
|
||||
import { isTextFileByName } from './text-files';
|
||||
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
|
||||
import { FileTypeCategory } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
@@ -83,6 +84,17 @@ export async function processFilesToChatUploaded(
|
||||
}
|
||||
|
||||
results.push({ ...base, preview });
|
||||
} else if (
|
||||
getFileTypeCategory(file.type) === FileTypeCategory.TEXT ||
|
||||
isTextFileByName(file.name)
|
||||
) {
|
||||
try {
|
||||
const textContent = await readFileAsUTF8(file);
|
||||
results.push({ ...base, textContent });
|
||||
} catch (err) {
|
||||
console.warn('Failed to read text file, adding without content:', err);
|
||||
results.push(base);
|
||||
}
|
||||
} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
|
||||
// Extract text content from PDF for preview
|
||||
try {
|
||||
@@ -117,14 +129,8 @@ export async function processFilesToChatUploaded(
|
||||
const preview = await readFileAsDataURL(file);
|
||||
results.push({ ...base, preview });
|
||||
} else {
|
||||
// Fallback: treat unknown files as text
|
||||
try {
|
||||
const textContent = await readFileAsUTF8(file);
|
||||
results.push({ ...base, textContent });
|
||||
} catch (err) {
|
||||
console.warn('Failed to read file as text, adding without content:', err);
|
||||
results.push(base);
|
||||
}
|
||||
// Other files: add as-is
|
||||
results.push(base);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing file', file.name, error);
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
$effect(() => {
|
||||
const serverProps = serverStore.props;
|
||||
|
||||
if (serverProps) {
|
||||
if (serverProps?.default_generation_settings?.params) {
|
||||
settingsStore.syncWithServerDefaults();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -65,7 +65,10 @@
|
||||
await expect(textarea).toHaveValue(text);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
await expect(fileInput).not.toHaveAttribute('accept');
|
||||
const acceptAttr = fileInput?.getAttribute('accept');
|
||||
await expect(fileInput).toHaveAttribute('accept');
|
||||
await expect(acceptAttr).not.toContain('image/');
|
||||
await expect(acceptAttr).not.toContain('audio/');
|
||||
|
||||
// Open file attachments dropdown
|
||||
const fileUploadButton = canvas.getByText('Attach files');
|
||||
|
||||
Reference in New Issue
Block a user