Merge remote-tracking branch 'webui/allozaur/server_model_management_v1_2' into xsn/server_model_maagement_v1_2
Co-authored-by: Aleksander <aleksander.grygier@gmail.com>
This commit is contained in:
parent
d32bbfec82
commit
4af1b6cbac
Binary file not shown.
|
|
@ -12,9 +12,20 @@ import type {
|
|||
ApiContextSizeError,
|
||||
ApiErrorResponse,
|
||||
ApiLlamaCppServerProps,
|
||||
ApiProcessingState
|
||||
ApiProcessingState,
|
||||
ApiRouterModelMeta,
|
||||
ApiRouterModelsLoadRequest,
|
||||
ApiRouterModelsLoadResponse,
|
||||
ApiRouterModelsStatusRequest,
|
||||
ApiRouterModelsStatusResponse,
|
||||
ApiRouterModelsListResponse,
|
||||
ApiRouterModelsUnloadRequest,
|
||||
ApiRouterModelsUnloadResponse
|
||||
} from '$lib/types/api';
|
||||
|
||||
import { ServerMode, ServerModelStatus } from '$lib/enums/server';
|
||||
import { ModelModality } from '$lib/enums/model';
|
||||
|
||||
import type {
|
||||
ChatMessageType,
|
||||
ChatRole,
|
||||
|
|
@ -60,6 +71,14 @@ declare global {
|
|||
ApiErrorResponse,
|
||||
ApiLlamaCppServerProps,
|
||||
ApiProcessingState,
|
||||
ApiRouterModelMeta,
|
||||
ApiRouterModelsLoadRequest,
|
||||
ApiRouterModelsLoadResponse,
|
||||
ApiRouterModelsStatusRequest,
|
||||
ApiRouterModelsStatusResponse,
|
||||
ApiRouterModelsListResponse,
|
||||
ApiRouterModelsUnloadRequest,
|
||||
ApiRouterModelsUnloadResponse,
|
||||
ChatMessageData,
|
||||
ChatMessagePromptProgress,
|
||||
ChatMessageSiblingInfo,
|
||||
|
|
@ -75,6 +94,9 @@ declare global {
|
|||
DatabaseMessageExtraTextFile,
|
||||
DatabaseMessageExtraPdfFile,
|
||||
DatabaseMessageExtraLegacyContext,
|
||||
ModelModality,
|
||||
ServerMode,
|
||||
ServerModelStatus,
|
||||
SettingsConfigValue,
|
||||
SettingsFieldConfig,
|
||||
SettingsConfigType,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
|
||||
import { FileTypeCategory, MimeTypeApplication } from '$lib/enums/files';
|
||||
import { ModelModality } from '$lib/enums/model';
|
||||
import { AttachmentType } from '$lib/enums/attachment';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
import { convertPDFToImage } from '$lib/utils/pdf-processing';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { getFileTypeCategory } from '$lib/utils/file-type';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -21,33 +24,36 @@
|
|||
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
|
||||
|
||||
let displayPreview = $derived(
|
||||
uploadedFile?.preview || (attachment?.type === 'imageFile' ? attachment.base64Url : preview)
|
||||
uploadedFile?.preview ||
|
||||
(attachment?.type === AttachmentType.IMAGE ? attachment.base64Url : preview)
|
||||
);
|
||||
|
||||
let displayType = $derived(
|
||||
uploadedFile?.type ||
|
||||
(attachment?.type === 'imageFile'
|
||||
uploadedFile
|
||||
? uploadedFile.type
|
||||
: attachment?.type === AttachmentType.IMAGE
|
||||
? 'image'
|
||||
: attachment?.type === 'textFile'
|
||||
: attachment?.type === AttachmentType.TEXT
|
||||
? 'text'
|
||||
: attachment?.type === 'audioFile'
|
||||
? attachment.mimeType || 'audio'
|
||||
: attachment?.type === 'pdfFile'
|
||||
: attachment?.type === AttachmentType.AUDIO
|
||||
? attachment.mimeType || ModelModality.AUDIO
|
||||
: attachment?.type === AttachmentType.PDF
|
||||
? MimeTypeApplication.PDF
|
||||
: type || 'unknown')
|
||||
: type || 'unknown'
|
||||
);
|
||||
|
||||
let displayTextContent = $derived(
|
||||
uploadedFile?.textContent ||
|
||||
(attachment?.type === 'textFile'
|
||||
(attachment?.type === AttachmentType.TEXT
|
||||
? attachment.content
|
||||
: attachment?.type === 'pdfFile'
|
||||
: attachment?.type === AttachmentType.PDF
|
||||
? attachment.content
|
||||
: textContent)
|
||||
);
|
||||
|
||||
let isAudio = $derived(
|
||||
getFileTypeCategory(displayType) === FileTypeCategory.AUDIO || displayType === 'audio'
|
||||
getFileTypeCategory(displayType) === FileTypeCategory.AUDIO ||
|
||||
displayType === ModelModality.AUDIO
|
||||
);
|
||||
|
||||
let isImage = $derived(
|
||||
|
|
@ -87,9 +93,9 @@
|
|||
|
||||
if (uploadedFile?.file) {
|
||||
file = uploadedFile.file;
|
||||
} else if (attachment?.type === 'pdfFile') {
|
||||
} else if (attachment?.type === AttachmentType.PDF) {
|
||||
// Check if we have pre-processed images
|
||||
if (attachment.images && Array.isArray(attachment.images)) {
|
||||
if (attachment.images && Array.isArray(attachment.images) && attachment.images.length > 0) {
|
||||
pdfImages = attachment.images;
|
||||
return;
|
||||
}
|
||||
|
|
@ -237,7 +243,7 @@
|
|||
<div class="w-full max-w-md text-center">
|
||||
<Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
|
||||
|
||||
{#if attachment?.type === 'audioFile'}
|
||||
{#if attachment?.type === AttachmentType.AUDIO}
|
||||
<audio
|
||||
controls
|
||||
class="mb-4 w-full"
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import { FileTypeCategory } from '$lib/enums/files';
|
||||
import { getFileTypeCategory } from '$lib/utils/file-type';
|
||||
import { FileTypeCategory } from '$lib/enums/files';
|
||||
import { ModelModality } from '$lib/enums/model';
|
||||
import { AttachmentType } from '$lib/enums/attachment';
|
||||
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
|
||||
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -68,7 +71,7 @@
|
|||
|
||||
// Add stored attachments (ChatMessage)
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
if (attachment.type === 'imageFile') {
|
||||
if (attachment.type === AttachmentType.IMAGE) {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
|
|
@ -78,7 +81,7 @@
|
|||
attachment,
|
||||
attachmentIndex: index
|
||||
});
|
||||
} else if (attachment.type === 'textFile') {
|
||||
} else if (attachment.type === AttachmentType.TEXT) {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
|
|
@ -88,7 +91,25 @@
|
|||
attachmentIndex: index,
|
||||
textContent: attachment.content
|
||||
});
|
||||
} else if (attachment.type === 'context') {
|
||||
} else if (attachment.type === AttachmentType.AUDIO) {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
type: attachment.mimeType || ModelModality.AUDIO,
|
||||
isImage: false,
|
||||
attachment,
|
||||
attachmentIndex: index
|
||||
});
|
||||
} else if (attachment.type === AttachmentType.PDF) {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
type: 'application/pdf',
|
||||
isImage: false,
|
||||
attachment,
|
||||
attachmentIndex: index
|
||||
});
|
||||
} else if (attachment.type === AttachmentType.LEGACY_CONTEXT) {
|
||||
// Legacy format from old webui - treat as text file
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
|
|
@ -99,25 +120,6 @@
|
|||
attachmentIndex: index,
|
||||
textContent: attachment.content
|
||||
});
|
||||
} else if (attachment.type === 'audioFile') {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
type: attachment.mimeType || 'audio',
|
||||
isImage: false,
|
||||
attachment,
|
||||
attachmentIndex: index
|
||||
});
|
||||
} else if (attachment.type === 'pdfFile') {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
type: 'application/pdf',
|
||||
isImage: false,
|
||||
attachment,
|
||||
attachmentIndex: index,
|
||||
textContent: attachment.content
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@
|
|||
DialogChatAttachmentPreview
|
||||
} from '$lib/components/app';
|
||||
import { FileTypeCategory } from '$lib/enums/files';
|
||||
import { ModelModality } from '$lib/enums/model';
|
||||
import { AttachmentType } from '$lib/enums/attachment';
|
||||
import { getFileTypeCategory } from '$lib/utils/file-type';
|
||||
import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
|
||||
interface Props {
|
||||
uploadedFiles?: ChatUploadedFile[];
|
||||
|
|
@ -52,7 +55,7 @@
|
|||
}
|
||||
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
if (attachment.type === 'imageFile') {
|
||||
if (attachment.type === AttachmentType.IMAGE) {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
|
|
@ -62,7 +65,7 @@
|
|||
attachment,
|
||||
attachmentIndex: index
|
||||
});
|
||||
} else if (attachment.type === 'textFile') {
|
||||
} else if (attachment.type === AttachmentType.TEXT) {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
|
|
@ -72,7 +75,25 @@
|
|||
attachmentIndex: index,
|
||||
textContent: attachment.content
|
||||
});
|
||||
} else if (attachment.type === 'context') {
|
||||
} else if (attachment.type === AttachmentType.AUDIO) {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
type: attachment.mimeType || ModelModality.AUDIO,
|
||||
isImage: false,
|
||||
attachment,
|
||||
attachmentIndex: index
|
||||
});
|
||||
} else if (attachment.type === AttachmentType.PDF) {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
type: 'application/pdf',
|
||||
isImage: false,
|
||||
attachment,
|
||||
attachmentIndex: index
|
||||
});
|
||||
} else if (attachment.type === AttachmentType.LEGACY_CONTEXT) {
|
||||
// Legacy format from old webui - treat as text file
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
|
|
@ -83,25 +104,6 @@
|
|||
attachmentIndex: index,
|
||||
textContent: attachment.content
|
||||
});
|
||||
} else if (attachment.type === 'audioFile') {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
type: attachment.mimeType || 'audio',
|
||||
isImage: false,
|
||||
attachment,
|
||||
attachmentIndex: index
|
||||
});
|
||||
} else if (attachment.type === 'pdfFile') {
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
type: 'application/pdf',
|
||||
isImage: false,
|
||||
attachment,
|
||||
attachmentIndex: index,
|
||||
textContent: attachment.content
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -253,9 +253,11 @@
|
|||
|
||||
<ChatFormActions
|
||||
canSend={message.trim().length > 0 || uploadedFiles.length > 0}
|
||||
hasText={message.trim().length > 0}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
{isRecording}
|
||||
{uploadedFiles}
|
||||
onFileUpload={handleFileUpload}
|
||||
onMicClick={handleMicClick}
|
||||
onStop={handleStop}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { Mic } from '@lucide/svelte';
|
||||
import { Mic, Square } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { supportsAudio } from '$lib/stores/server.svelte';
|
||||
|
|
@ -27,8 +27,6 @@
|
|||
<Button
|
||||
class="h-8 w-8 rounded-full p-0 {isRecording
|
||||
? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-foreground/10 hover:text-foreground'} {!supportsAudio()
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: ''}"
|
||||
disabled={disabled || isLoading || !supportsAudio()}
|
||||
onclick={onMicClick}
|
||||
|
|
@ -36,7 +34,11 @@
|
|||
>
|
||||
<span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
|
||||
|
||||
<Mic class="h-4 w-4" />
|
||||
{#if isRecording}
|
||||
<Square class="h-4 w-4 animate-pulse fill-white" />
|
||||
{:else}
|
||||
<Mic class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,17 +4,21 @@
|
|||
import {
|
||||
ChatFormActionFileAttachments,
|
||||
ChatFormActionRecord,
|
||||
ChatFormModelSelector
|
||||
SelectorModel
|
||||
} from '$lib/components/app';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import type { FileTypeCategory } from '$lib/enums/files';
|
||||
import { FileTypeCategory } from '$lib/enums/files';
|
||||
import { getFileTypeCategory } from '$lib/utils/file-type';
|
||||
import { supportsAudio } from '$lib/stores/server.svelte';
|
||||
import type { ChatUploadedFile } from '$lib/types/chat';
|
||||
|
||||
interface Props {
|
||||
canSend?: boolean;
|
||||
class?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
isRecording?: boolean;
|
||||
hasText?: boolean;
|
||||
uploadedFiles?: ChatUploadedFile[];
|
||||
onFileUpload?: (fileType?: FileTypeCategory) => void;
|
||||
onMicClick?: () => void;
|
||||
onStop?: () => void;
|
||||
|
|
@ -22,24 +26,29 @@
|
|||
|
||||
let {
|
||||
canSend = false,
|
||||
class: className = '',
|
||||
className = '',
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
isRecording = false,
|
||||
hasText = false,
|
||||
uploadedFiles = [],
|
||||
onFileUpload,
|
||||
onMicClick,
|
||||
onStop
|
||||
}: Props = $props();
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
let hasAudioModality = $derived(supportsAudio());
|
||||
let hasAudioAttachments = $derived(
|
||||
uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
|
||||
);
|
||||
let shouldShowRecordButton = $derived(hasAudioModality && !hasText && !hasAudioAttachments);
|
||||
let shouldShowSubmitButton = $derived(!shouldShowRecordButton || hasAudioAttachments);
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center gap-2 {className}">
|
||||
<div class="flex w-full items-center gap-3 {className}">
|
||||
<ChatFormActionFileAttachments class="mr-auto" {disabled} {onFileUpload} />
|
||||
|
||||
{#if currentConfig.modelSelectorEnabled}
|
||||
<ChatFormModelSelector class="shrink-0" />
|
||||
{/if}
|
||||
<SelectorModel class="max-w-80" />
|
||||
|
||||
{#if isLoading}
|
||||
<Button
|
||||
|
|
@ -51,15 +60,19 @@
|
|||
<Square class="h-8 w-8 fill-destructive stroke-destructive" />
|
||||
</Button>
|
||||
{:else}
|
||||
<ChatFormActionRecord {disabled} {isLoading} {isRecording} {onMicClick} />
|
||||
{#if shouldShowRecordButton}
|
||||
<ChatFormActionRecord {disabled} {isLoading} {isRecording} {onMicClick} />
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSend || disabled || isLoading}
|
||||
class="h-8 w-8 rounded-full p-0"
|
||||
>
|
||||
<span class="sr-only">Send</span>
|
||||
<ArrowUp class="h-12 w-12" />
|
||||
</Button>
|
||||
{#if shouldShowSubmitButton}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSend || disabled || isLoading}
|
||||
class="h-8 w-8 rounded-full p-0"
|
||||
>
|
||||
<span class="sr-only">Send</span>
|
||||
<ArrowUp class="h-12 w-12" />
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { ChatMessageThinkingBlock, MarkdownContent } from '$lib/components/app';
|
||||
import { ChatMessageThinkingBlock, MarkdownContent, SelectorModel } from '$lib/components/app';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { isLoading } from '$lib/stores/chat.svelte';
|
||||
import autoResizeTextarea from '$lib/utils/autoresize-textarea';
|
||||
import { fade } from 'svelte/transition';
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Package,
|
||||
X,
|
||||
Gauge,
|
||||
Clock,
|
||||
WholeWord,
|
||||
ChartNoAxesColumn,
|
||||
Wrench
|
||||
} from '@lucide/svelte';
|
||||
import { Check, Copy, X, Gauge, Clock, WholeWord, Wrench } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { INPUT_CLASSES } from '$lib/constants/input-classes';
|
||||
import ChatMessageActions from './ChatMessageActions.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { modelName as serverModelName } from '$lib/stores/server.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { selectModel } from '$lib/stores/models.svelte';
|
||||
import { copyToClipboard } from '$lib/utils/copy';
|
||||
import type { ApiChatCompletionToolCall } from '$lib/types/api';
|
||||
|
||||
|
|
@ -93,17 +84,28 @@
|
|||
|
||||
const processingState = useProcessingState();
|
||||
let currentConfig = $derived(config());
|
||||
let serverModel = $derived(serverModelName());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let displayedModel = $derived((): string | null => {
|
||||
if (!currentConfig.showModelInfo) return null;
|
||||
|
||||
// Only show model from streaming data, no fallbacks to server props
|
||||
if (message.model) {
|
||||
return message.model;
|
||||
}
|
||||
|
||||
return serverModel;
|
||||
return null;
|
||||
});
|
||||
|
||||
async function handleModelChange(modelId: string) {
|
||||
try {
|
||||
await selectModel(modelId);
|
||||
|
||||
onRegenerate();
|
||||
} catch (error) {
|
||||
console.error('Failed to change model:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyModel() {
|
||||
const model = displayedModel();
|
||||
|
||||
|
|
@ -244,21 +246,52 @@
|
|||
|
||||
<div class="info my-6 grid gap-4">
|
||||
{#if displayedModel()}
|
||||
<span class="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
<span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{#if isRouter && currentConfig.modelSelectorEnabled}
|
||||
<SelectorModel
|
||||
currentModel={displayedModel()}
|
||||
onModelChange={handleModelChange}
|
||||
disabled={isLoading()}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
onclick={handleCopyModel}
|
||||
>
|
||||
{displayedModel()}
|
||||
|
||||
<span>Model used:</span>
|
||||
</span>
|
||||
<Copy class="ml-1 h-3 w-3 " />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
onclick={handleCopyModel}
|
||||
>
|
||||
{displayedModel()}
|
||||
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||
{@const tokensPerSecond =
|
||||
(message.timings.predicted_n / message.timings.predicted_ms) * 1000}
|
||||
|
||||
<Copy class="ml-1 h-3 w-3 " />
|
||||
</button>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
>
|
||||
<WholeWord class="h-3 w-3" />
|
||||
|
||||
{message.timings.predicted_n} tokens
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
>
|
||||
<Clock class="h-3 w-3" />
|
||||
|
||||
{(message.timings.predicted_ms / 1000).toFixed(2)}s
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
>
|
||||
<Gauge class="h-3 w-3" />
|
||||
|
||||
{tokensPerSecond.toFixed(2)} tokens/s
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
|
|
@ -302,38 +335,6 @@
|
|||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||
{@const tokensPerSecond = (message.timings.predicted_n / message.timings.predicted_ms) * 1000}
|
||||
<span class="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ChartNoAxesColumn class="h-3.5 w-3.5" />
|
||||
|
||||
<span>Statistics:</span>
|
||||
</span>
|
||||
|
||||
<div class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
>
|
||||
<Gauge class="h-3 w-3" />
|
||||
{tokensPerSecond.toFixed(2)} tokens/s
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
>
|
||||
<WholeWord class="h-3 w-3" />
|
||||
{message.timings.predicted_n} tokens
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
|
||||
>
|
||||
<Clock class="h-3 w-3" />
|
||||
{(message.timings.predicted_ms / 1000).toFixed(2)}s
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if message.timestamp && !isEditing}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
DialogEmptyFileAlert,
|
||||
DialogChatError,
|
||||
ServerErrorSplash,
|
||||
ServerInfo,
|
||||
ServerLoadingSplash,
|
||||
DialogConfirmation
|
||||
} from '$lib/components/app';
|
||||
|
|
@ -44,6 +43,7 @@
|
|||
import { fade, fly, slide } from 'svelte/transition';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import ChatScreenDragOverlay from './ChatScreenDragOverlay.svelte';
|
||||
import { ModelModality } from '$lib/enums/model';
|
||||
|
||||
let { showCenteredEmpty = false } = $props();
|
||||
|
||||
|
|
@ -334,14 +334,14 @@
|
|||
role="main"
|
||||
>
|
||||
<div class="w-full max-w-[48rem] px-4">
|
||||
<div class="mb-8 text-center" in:fade={{ duration: 300 }}>
|
||||
<h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
|
||||
<div class="mb-10 text-center" in:fade={{ duration: 300 }}>
|
||||
<h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
|
||||
|
||||
<p class="text-lg text-muted-foreground">How can I help you today?</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 flex justify-center" in:fly={{ y: 10, duration: 300, delay: 200 }}>
|
||||
<ServerInfo />
|
||||
<p class="text-lg text-muted-foreground">
|
||||
{serverStore.supportedModalities.includes(ModelModality.AUDIO)
|
||||
? 'Record audio, type a message '
|
||||
: 'Type a message'} or upload files to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if serverWarning()}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@
|
|||
</script>
|
||||
|
||||
<ScrollArea class="h-[100vh]">
|
||||
<Sidebar.Header class=" top-0 z-10 gap-6 bg-sidebar/50 px-4 pt-4 pb-2 backdrop-blur-lg md:sticky">
|
||||
<Sidebar.Header class=" top-0 z-10 gap-6 bg-sidebar/50 px-4 py-4 pb-2 backdrop-blur-lg md:sticky">
|
||||
<a href="#/" onclick={handleMobileSidebarItemClick}>
|
||||
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
|
||||
</a>
|
||||
|
|
@ -154,8 +154,6 @@
|
|||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
|
||||
<div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"></div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogConfirmation
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { ModelModality } from '$lib/enums/model';
|
||||
import { AttachmentType } from '$lib/enums/attachment';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
import { ChatAttachmentPreview } from '$lib/components/app';
|
||||
import { formatFileSize } from '$lib/utils/file-preview';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
// Either an uploaded file or a stored attachment
|
||||
uploadedFile?: ChatUploadedFile;
|
||||
attachment?: DatabaseMessageExtra;
|
||||
|
|
@ -18,6 +22,7 @@
|
|||
|
||||
let {
|
||||
open = $bindable(),
|
||||
onOpenChange,
|
||||
uploadedFile,
|
||||
attachment,
|
||||
preview,
|
||||
|
|
@ -32,16 +37,17 @@
|
|||
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
|
||||
|
||||
let displayType = $derived(
|
||||
uploadedFile?.type ||
|
||||
(attachment?.type === 'imageFile'
|
||||
uploadedFile
|
||||
? uploadedFile.type
|
||||
: attachment?.type === AttachmentType.IMAGE
|
||||
? 'image'
|
||||
: attachment?.type === 'textFile'
|
||||
: attachment?.type === AttachmentType.TEXT
|
||||
? 'text'
|
||||
: attachment?.type === 'audioFile'
|
||||
? attachment.mimeType || 'audio'
|
||||
: attachment?.type === 'pdfFile'
|
||||
: attachment?.type === AttachmentType.AUDIO
|
||||
? attachment.mimeType || ModelModality.AUDIO
|
||||
: attachment?.type === AttachmentType.PDF
|
||||
? 'application/pdf'
|
||||
: type || 'unknown')
|
||||
: type || 'unknown'
|
||||
);
|
||||
|
||||
let displaySize = $derived(uploadedFile?.size || size);
|
||||
|
|
@ -53,7 +59,7 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{displayName}</Dialog.Title>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { BadgeModality } from '$lib/components/app';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { ChatService } from '$lib/services/chat';
|
||||
import type { ApiModelListResponse } from '$lib/types/api';
|
||||
import { Copy } from '@lucide/svelte';
|
||||
import { copyToClipboard } from '$lib/utils/copy';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), onOpenChange }: Props = $props();
|
||||
|
||||
let serverProps = $derived(serverStore.serverProps);
|
||||
let modalities = $derived(serverStore.supportedModalities);
|
||||
|
||||
let modelsData = $state<ApiModelListResponse | null>(null);
|
||||
let isLoadingModels = $state(false);
|
||||
|
||||
// Fetch models data when dialog opens
|
||||
$effect(() => {
|
||||
if (open && !modelsData) {
|
||||
loadModelsData();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadModelsData() {
|
||||
isLoadingModels = true;
|
||||
try {
|
||||
modelsData = await ChatService.getModels();
|
||||
} catch (error) {
|
||||
console.error('Failed to load models data:', error);
|
||||
// Set empty data to prevent infinite loading
|
||||
modelsData = { object: 'list', data: [] };
|
||||
} finally {
|
||||
isLoadingModels = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Format helpers
|
||||
function formatSize(sizeBytes: number | unknown): string {
|
||||
if (typeof sizeBytes !== 'number') return 'Unknown';
|
||||
|
||||
// Convert to GB for better readability
|
||||
const sizeGB = sizeBytes / (1024 * 1024 * 1024);
|
||||
if (sizeGB >= 1) {
|
||||
return `${sizeGB.toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
// Convert to MB for smaller models
|
||||
const sizeMB = sizeBytes / (1024 * 1024);
|
||||
return `${sizeMB.toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatParameters(params: number | unknown): string {
|
||||
if (typeof params !== 'number') return 'Unknown';
|
||||
if (params >= 1e9) {
|
||||
return `${(params / 1e9).toFixed(2)}B`;
|
||||
}
|
||||
if (params >= 1e6) {
|
||||
return `${(params / 1e6).toFixed(2)}M`;
|
||||
}
|
||||
if (params >= 1e3) {
|
||||
return `${(params / 1e3).toFixed(2)}K`;
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function formatNumber(num: number | unknown): string {
|
||||
if (typeof num !== 'number') return 'Unknown';
|
||||
return num.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
<Dialog.Content class="@container z-9999 !max-w-[60rem] max-w-full">
|
||||
<style>
|
||||
@container (max-width: 56rem) {
|
||||
.resizable-text-container {
|
||||
max-width: calc(100vw - var(--threshold));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Model Information</Dialog.Title>
|
||||
<Dialog.Description>Current model details and capabilities</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-6 py-4">
|
||||
{#if isLoadingModels}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-sm text-muted-foreground">Loading model information...</div>
|
||||
</div>
|
||||
{:else if modelsData && modelsData.data.length > 0}
|
||||
{@const modelMeta = modelsData.data[0].meta}
|
||||
|
||||
{#if serverProps}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[10rem]">Model</Table.Head>
|
||||
|
||||
<Table.Head>
|
||||
<span
|
||||
class="resizable-text-container block min-w-0 flex-1 truncate"
|
||||
style:--threshold="12rem"
|
||||
>
|
||||
{serverStore.modelName}
|
||||
</span>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<!-- Model Path -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell>
|
||||
|
||||
<Table.Cell
|
||||
class="inline-flex h-10 items-center gap-2 align-middle font-mono text-xs"
|
||||
>
|
||||
<span
|
||||
class="resizable-text-container min-w-0 flex-1 truncate"
|
||||
style:--threshold="14rem"
|
||||
>
|
||||
{serverProps.model_path}
|
||||
</span>
|
||||
|
||||
<Copy
|
||||
class="h-3 w-3 flex-shrink-0"
|
||||
aria-label="Copy model path to clipboard"
|
||||
onclick={() => copyToClipboard(serverProps.model_path)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
<!-- Context Size -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
|
||||
<Table.Cell
|
||||
>{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
|
||||
<!-- Training Context -->
|
||||
{#if modelMeta?.n_ctx_train}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
|
||||
<Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Model Size -->
|
||||
{#if modelMeta?.size}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
|
||||
<Table.Cell>{formatSize(modelMeta.size)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Parameters -->
|
||||
{#if modelMeta?.n_params}
|
||||
<Table.Row>
|
||||
<Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
|
||||
<Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Embedding Size -->
|
||||
{#if modelMeta?.n_embd}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
|
||||
<Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Vocabulary Size -->
|
||||
{#if modelMeta?.n_vocab}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
|
||||
<Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Vocabulary Type -->
|
||||
{#if modelMeta?.vocab_type}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Vocabulary Type</Table.Cell>
|
||||
<Table.Cell class="align-middle capitalize">{modelMeta.vocab_type}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Total Slots -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
|
||||
<Table.Cell>{serverProps.total_slots}</Table.Cell>
|
||||
</Table.Row>
|
||||
|
||||
<!-- Modalities -->
|
||||
{#if modalities.length > 0}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<BadgeModality {modalities} />
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
|
||||
<!-- Build Info -->
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
|
||||
<Table.Cell class="align-middle font-mono text-xs"
|
||||
>{serverProps.build_info}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
|
||||
<!-- Chat Template -->
|
||||
{#if serverProps.chat_template}
|
||||
<Table.Row>
|
||||
<Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
|
||||
<Table.Cell class="py-10">
|
||||
<div class="max-h-120 overflow-y-auto rounded-md bg-muted p-4">
|
||||
<pre
|
||||
class="font-mono text-xs whitespace-pre-wrap">{serverProps.chat_template}</pre>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
{:else if !isLoadingModels}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="text-sm text-muted-foreground">No model information available</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -12,7 +12,6 @@ export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions
|
|||
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
|
||||
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
|
||||
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
|
||||
export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte';
|
||||
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
|
||||
|
||||
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
|
||||
|
|
@ -45,19 +44,22 @@ export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svel
|
|||
export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
|
||||
export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
|
||||
export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
|
||||
export { default as DialogModelInformation } from './dialogs/DialogModelInformation.svelte';
|
||||
|
||||
// Miscellanous
|
||||
|
||||
export { default as ActionButton } from './misc/ActionButton.svelte';
|
||||
export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
|
||||
export { default as BadgeModelName } from './misc/BadgeModelName.svelte';
|
||||
export { default as BadgeModality } from './misc/BadgeModality.svelte';
|
||||
export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
|
||||
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
|
||||
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
|
||||
export { default as RemoveButton } from './misc/RemoveButton.svelte';
|
||||
export { default as SelectorModel } from './misc/SelectorModel.svelte';
|
||||
|
||||
// Server
|
||||
|
||||
export { default as ServerStatus } from './server/ServerStatus.svelte';
|
||||
export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
|
||||
export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
|
||||
export { default as ServerInfo } from './server/ServerInfo.svelte';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts">
|
||||
import { Eye, Mic } from '@lucide/svelte';
|
||||
import { ModelModality } from '$lib/enums/model';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
|
||||
interface Props {
|
||||
modalities: ModelModality[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { modalities, class: className = '' }: Props = $props();
|
||||
|
||||
function getModalityIcon(modality: ModelModality) {
|
||||
switch (modality) {
|
||||
case ModelModality.VISION:
|
||||
return Eye;
|
||||
case ModelModality.AUDIO:
|
||||
return Mic;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getModalityLabel(modality: ModelModality): string {
|
||||
switch (modality) {
|
||||
case ModelModality.VISION:
|
||||
return 'Vision';
|
||||
case ModelModality.AUDIO:
|
||||
return 'Audio';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each modalities as modality, index (index)}
|
||||
{@const IconComponent = getModalityIcon(modality)}
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{#if IconComponent}
|
||||
<IconComponent class="h-3 w-3" />
|
||||
{/if}
|
||||
|
||||
{getModalityLabel(modality)}
|
||||
</span>
|
||||
{/each}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<script lang="ts">
|
||||
import { Package } from '@lucide/svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onclick?: () => void;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
let { class: className = '', onclick, showTooltip = false }: Props = $props();
|
||||
|
||||
let model = $derived(serverStore.modelName);
|
||||
let isModelMode = $derived(serverStore.isModelMode);
|
||||
</script>
|
||||
|
||||
{#snippet badge()}
|
||||
<Badge
|
||||
variant="outline"
|
||||
class={cn(
|
||||
'text-xs',
|
||||
onclick ? 'cursor-pointer transition-colors hover:bg-foreground/20' : '',
|
||||
className
|
||||
)}
|
||||
{onclick}
|
||||
>
|
||||
<div class="icons mr-0.5 flex items-center gap-1.5">
|
||||
<Package class="h-3 w-3" />
|
||||
</div>
|
||||
|
||||
<span class="block truncate">{model}</span>
|
||||
</Badge>
|
||||
{/snippet}
|
||||
|
||||
{#if model && isModelMode}
|
||||
{#if showTooltip}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger>
|
||||
{@render badge()}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
{onclick ? 'Click for model details' : 'Model name'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
{@render badge()}
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
@ -1,36 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { portalToBody } from '$lib/utils/portal-to-body';
|
||||
import {
|
||||
fetchModels,
|
||||
modelOptions,
|
||||
modelsError,
|
||||
modelsLoading,
|
||||
modelsUpdating,
|
||||
selectModel,
|
||||
selectedModelId
|
||||
} from '$lib/stores/models.svelte';
|
||||
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
|
||||
import { DialogModelInformation } from '$lib/components/app';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
currentModel?: string | null;
|
||||
onModelChange?: (modelId: string, modelName: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
let {
|
||||
class: className = '',
|
||||
currentModel = null,
|
||||
onModelChange,
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
let options = $derived(modelOptions());
|
||||
let loading = $derived(modelsLoading());
|
||||
let updating = $derived(modelsUpdating());
|
||||
let error = $derived(modelsError());
|
||||
let activeId = $derived(selectedModelId());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let serverModel = $derived(serverStore.modelName);
|
||||
|
||||
let isMounted = $state(false);
|
||||
let isOpen = $state(false);
|
||||
let showModelDialog = $state(false);
|
||||
let container: HTMLDivElement | null = null;
|
||||
let triggerButton = $state<HTMLButtonElement | null>(null);
|
||||
let menuRef = $state<HTMLDivElement | null>(null);
|
||||
let triggerButton = $state<HTMLButtonElement | null>(null);
|
||||
let menuPosition = $state<{
|
||||
top: number;
|
||||
left: number;
|
||||
|
|
@ -38,18 +48,51 @@
|
|||
placement: 'top' | 'bottom';
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
let lockedWidth: number | null = null;
|
||||
|
||||
const VIEWPORT_GUTTER = 8;
|
||||
const MENU_OFFSET = 6;
|
||||
const MENU_MAX_WIDTH = 320;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await fetchModels();
|
||||
} catch (error) {
|
||||
console.error('Unable to load models:', error);
|
||||
} finally {
|
||||
isMounted = true;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleOpen() {
|
||||
if (loading || updating) return;
|
||||
|
||||
if (isRouter) {
|
||||
// Router mode: show dropdown
|
||||
if (isOpen) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
} else {
|
||||
// Single model mode: show dialog
|
||||
showModelDialog = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
if (loading || updating) return;
|
||||
|
||||
isOpen = true;
|
||||
await tick();
|
||||
updateMenuPosition();
|
||||
requestAnimationFrame(() => updateMenuPosition());
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (!isOpen) return;
|
||||
|
||||
isOpen = false;
|
||||
menuPosition = null;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!container) return;
|
||||
|
||||
|
|
@ -72,75 +115,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handleSelect(value: string | undefined) {
|
||||
if (!value) return;
|
||||
|
||||
const option = options.find((item) => item.id === value);
|
||||
if (!option) {
|
||||
console.error('Model is no longer available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await selectModel(option.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to switch model:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const VIEWPORT_GUTTER = 8;
|
||||
const MENU_OFFSET = 6;
|
||||
const MENU_MAX_WIDTH = 320;
|
||||
|
||||
async function openMenu() {
|
||||
if (loading || updating) return;
|
||||
|
||||
isOpen = true;
|
||||
await tick();
|
||||
updateMenuPosition();
|
||||
requestAnimationFrame(() => updateMenuPosition());
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
if (loading || updating) return;
|
||||
|
||||
if (isOpen) {
|
||||
closeMenu();
|
||||
} else {
|
||||
void openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (!isOpen) return;
|
||||
|
||||
isOpen = false;
|
||||
menuPosition = null;
|
||||
lockedWidth = null;
|
||||
}
|
||||
|
||||
async function handleOptionSelect(optionId: string) {
|
||||
try {
|
||||
await handleSelect(optionId);
|
||||
} finally {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (loading || updating) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const optionCount = options.length;
|
||||
|
||||
if (!isOpen || optionCount <= 0) return;
|
||||
|
||||
queueMicrotask(() => updateMenuPosition());
|
||||
});
|
||||
|
||||
function updateMenuPosition() {
|
||||
if (!isOpen || !triggerButton || !menuRef) return;
|
||||
|
||||
|
|
@ -159,19 +133,10 @@
|
|||
constrainedMaxWidth > 0 ? constrainedMaxWidth : Math.min(MENU_MAX_WIDTH, viewportWidth);
|
||||
const desiredMinWidth = Math.min(160, safeMaxWidth || 160);
|
||||
|
||||
let width = lockedWidth;
|
||||
if (width === null) {
|
||||
const naturalWidth = Math.min(scrollWidth, safeMaxWidth);
|
||||
const baseWidth = Math.max(triggerRect.width, naturalWidth, desiredMinWidth);
|
||||
width = Math.min(baseWidth, safeMaxWidth || baseWidth);
|
||||
lockedWidth = width;
|
||||
} else {
|
||||
width = Math.min(Math.max(width, desiredMinWidth), safeMaxWidth || width);
|
||||
}
|
||||
|
||||
if (width > 0) {
|
||||
menuRef.style.width = `${width}px`;
|
||||
}
|
||||
let width = Math.min(
|
||||
Math.max(triggerRect.width, scrollWidth, desiredMinWidth),
|
||||
safeMaxWidth || 320
|
||||
);
|
||||
|
||||
const availableBelow = Math.max(
|
||||
0,
|
||||
|
|
@ -220,8 +185,6 @@
|
|||
metrics = aboveMetrics;
|
||||
}
|
||||
|
||||
menuRef.style.maxHeight = metrics.maxHeight > 0 ? `${Math.round(metrics.maxHeight)}px` : '';
|
||||
|
||||
let left = triggerRect.right - width;
|
||||
const maxLeft = viewportWidth - VIEWPORT_GUTTER - width;
|
||||
if (maxLeft < VIEWPORT_GUTTER) {
|
||||
|
|
@ -244,63 +207,85 @@
|
|||
};
|
||||
}
|
||||
|
||||
function handleSelect(modelId: string) {
|
||||
const option = options.find((opt) => opt.id === modelId);
|
||||
if (option && onModelChange) {
|
||||
// If callback provided, use it (for regenerate functionality)
|
||||
onModelChange(option.id, option.model);
|
||||
} else if (option) {
|
||||
// Otherwise, just update the global selection (for form selector)
|
||||
selectModel(option.id).catch(console.error);
|
||||
}
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function getDisplayOption(): ModelOption | undefined {
|
||||
if (!isRouter) {
|
||||
// Single model mode: create fake option from server model
|
||||
if (serverModel) {
|
||||
return {
|
||||
id: 'current',
|
||||
model: serverModel,
|
||||
name: serverModel.split('/').pop() || serverModel,
|
||||
capabilities: [] // Empty array for single model mode
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Router mode: use existing logic
|
||||
if (currentModel) {
|
||||
return options.find((option) => option.model === currentModel);
|
||||
}
|
||||
if (activeId) {
|
||||
return options.find((option) => option.id === activeId);
|
||||
}
|
||||
|
||||
return options[0];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={handleResize} />
|
||||
|
||||
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class={cn('relative z-10 flex max-w-[200px] min-w-[120px] flex-col items-end gap-1', className)}
|
||||
bind:this={container}
|
||||
>
|
||||
{#if loading && options.length === 0 && !isMounted}
|
||||
<div class={cn('relative inline-flex flex-col items-end gap-1', className)} bind:this={container}>
|
||||
{#if loading && options.length === 0 && isRouter}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if options.length === 0}
|
||||
{:else if options.length === 0 && isRouter}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
{:else}
|
||||
{@const selectedOption = getDisplayOption()}
|
||||
|
||||
<div class="relative w-full">
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'flex w-full items-center justify-end gap-2 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60',
|
||||
'inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75 text-xs text-muted-foreground transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60',
|
||||
isOpen ? 'text-foreground' : ''
|
||||
)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
style="max-width: min(calc(100vw - 2rem), 32rem)"
|
||||
aria-haspopup={isRouter ? 'listbox' : undefined}
|
||||
aria-expanded={isRouter ? isOpen : undefined}
|
||||
onclick={toggleOpen}
|
||||
bind:this={triggerButton}
|
||||
disabled={loading || updating}
|
||||
disabled={disabled || updating}
|
||||
>
|
||||
<span class="max-w-[160px] truncate text-right font-medium">
|
||||
<Package class="h-3.5 w-3.5" />
|
||||
|
||||
<span class="truncate font-medium">
|
||||
{selectedOption?.name || 'Select model'}
|
||||
</span>
|
||||
|
||||
{#if updating}
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronDown
|
||||
class={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isOpen ? 'rotate-180 text-foreground' : ''
|
||||
)}
|
||||
/>
|
||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||
{:else if isRouter}
|
||||
<ChevronDown class="h-3 w-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
{#if isOpen && isRouter}
|
||||
<div
|
||||
bind:this={menuRef}
|
||||
use:portalToBody
|
||||
|
|
@ -324,20 +309,16 @@
|
|||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'flex w-full flex-col items-start gap-0.5 px-3 py-2 text-left text-sm transition hover:bg-muted focus:bg-muted focus:outline-none',
|
||||
option.id === selectedOption?.id ? 'bg-accent text-accent-foreground' : ''
|
||||
'flex w-full cursor-pointer items-center px-3 py-2 text-left text-sm transition hover:bg-muted focus:bg-muted focus:outline-none',
|
||||
currentModel === option.model || activeId === option.id
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-popover-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={option.id === selectedOption?.id}
|
||||
onclick={() => handleOptionSelect(option.id)}
|
||||
aria-selected={currentModel === option.model || activeId === option.id}
|
||||
onclick={() => handleSelect(option.id)}
|
||||
>
|
||||
<span class="block w-full truncate font-medium" title={option.name}>
|
||||
{option.name}
|
||||
</span>
|
||||
|
||||
{#if option.description}
|
||||
<span class="text-xs text-muted-foreground">{option.description}</span>
|
||||
{/if}
|
||||
<span class="truncate">{option.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -345,8 +326,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-xs text-destructive">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showModelDialog && !isRouter}
|
||||
<DialogModelInformation bind:open={showModelDialog} />
|
||||
{/if}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Server, Eye, Mic } from '@lucide/svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
|
||||
let modalities = $derived(serverStore.supportedModalities);
|
||||
let model = $derived(serverStore.modelName);
|
||||
let props = $derived(serverStore.serverProps);
|
||||
</script>
|
||||
|
||||
{#if props}
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
{#if model}
|
||||
<Badge variant="outline" class="text-xs">
|
||||
<Server class="mr-1 h-3 w-3" />
|
||||
|
||||
<span class="block max-w-[50vw] truncate">{model}</span>
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-4">
|
||||
{#if props.default_generation_settings.n_ctx}
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
ctx: {props.default_generation_settings.n_ctx.toLocaleString()}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if modalities.length > 0}
|
||||
{#each modalities as modality (modality)}
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{#if modality === 'vision'}
|
||||
<Eye class="mr-1 h-3 w-3" />
|
||||
{:else if modality === 'audio'}
|
||||
<Mic class="mr-1 h-3 w-3" />
|
||||
{/if}
|
||||
|
||||
{modality}
|
||||
</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import Root from './table.svelte';
|
||||
import Body from './table-body.svelte';
|
||||
import Caption from './table-caption.svelte';
|
||||
import Cell from './table-cell.svelte';
|
||||
import Footer from './table-footer.svelte';
|
||||
import Head from './table-head.svelte';
|
||||
import Header from './table-header.svelte';
|
||||
import Row from './table-row.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Body,
|
||||
Caption,
|
||||
Cell,
|
||||
Footer,
|
||||
Head,
|
||||
Header,
|
||||
Row,
|
||||
//
|
||||
Root as Table,
|
||||
Body as TableBody,
|
||||
Caption as TableCaption,
|
||||
Cell as TableCell,
|
||||
Footer as TableFooter,
|
||||
Head as TableHead,
|
||||
Header as TableHeader,
|
||||
Row as TableRow
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tbody
|
||||
bind:this={ref}
|
||||
data-slot="table-body"
|
||||
class={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tbody>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<caption
|
||||
bind:this={ref}
|
||||
data-slot="table-caption"
|
||||
class={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</caption>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
|
||||
import type { HTMLTdAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTdAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<td
|
||||
bind:this={ref}
|
||||
data-slot="table-cell"
|
||||
class={cn(
|
||||
'bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</td>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tfoot
|
||||
bind:this={ref}
|
||||
data-slot="table-footer"
|
||||
class={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tfoot>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
|
||||
import type { HTMLThAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLThAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<th
|
||||
bind:this={ref}
|
||||
data-slot="table-head"
|
||||
class={cn(
|
||||
'h-10 bg-clip-padding px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pe-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</th>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<thead
|
||||
bind:this={ref}
|
||||
data-slot="table-header"
|
||||
class={cn('[&_tr]:border-b', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</thead>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tr
|
||||
bind:this={ref}
|
||||
data-slot="table-row"
|
||||
class={cn(
|
||||
'border-b transition-colors data-[state=selected]:bg-muted hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tr>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLTableAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTableAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="table-container" class="relative w-full overflow-x-auto">
|
||||
<table
|
||||
bind:this={ref}
|
||||
data-slot="table"
|
||||
class={cn('w-full caption-bottom text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Attachment type enum for database message extras
|
||||
*/
|
||||
export enum AttachmentType {
|
||||
AUDIO = 'AUDIO',
|
||||
IMAGE = 'IMAGE',
|
||||
PDF = 'PDF',
|
||||
TEXT = 'TEXT',
|
||||
LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export enum ModelModality {
|
||||
TEXT = 'TEXT',
|
||||
AUDIO = 'AUDIO',
|
||||
VISION = 'VISION'
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Server mode enum - used for single/multi-model mode
|
||||
*/
|
||||
export enum ServerMode {
|
||||
/** Single model mode - server running with a specific model loaded */
|
||||
MODEL = 'MODEL',
|
||||
/** Router mode - server managing multiple model instances */
|
||||
ROUTER = 'ROUTER'
|
||||
}
|
||||
|
||||
/**
|
||||
* Model status enum - matches tools/server/server-models.h from C++ server
|
||||
*/
|
||||
export enum ServerModelStatus {
|
||||
UNLOADED = 'UNLOADED',
|
||||
LOADING = 'LOADING',
|
||||
LOADED = 'LOADED',
|
||||
FAILED = 'FAILED'
|
||||
}
|
||||
|
|
@ -7,8 +7,10 @@ import type {
|
|||
ApiChatCompletionStreamChunk,
|
||||
ApiChatCompletionToolCall,
|
||||
ApiChatCompletionToolCallDelta,
|
||||
ApiChatMessageData
|
||||
ApiChatMessageData,
|
||||
ApiModelListResponse
|
||||
} from '$lib/types/api';
|
||||
import { AttachmentType } from '$lib/enums/attachment';
|
||||
import type {
|
||||
DatabaseMessage,
|
||||
DatabaseMessageExtra,
|
||||
|
|
@ -74,7 +76,6 @@ export class ChatService {
|
|||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onModel,
|
||||
onFirstValidChunk,
|
||||
// Generation parameters
|
||||
temperature,
|
||||
max_tokens,
|
||||
|
|
@ -223,7 +224,6 @@ export class ChatService {
|
|||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onModel,
|
||||
onFirstValidChunk,
|
||||
conversationId,
|
||||
abortController.signal
|
||||
);
|
||||
|
|
@ -298,7 +298,6 @@ export class ChatService {
|
|||
onReasoningChunk?: (chunk: string) => void,
|
||||
onToolCallChunk?: (chunk: string) => void,
|
||||
onModel?: (model: string) => void,
|
||||
onFirstValidChunk?: () => void,
|
||||
conversationId?: string,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<void> {
|
||||
|
|
@ -315,7 +314,6 @@ export class ChatService {
|
|||
let lastTimings: ChatMessageTimings | undefined;
|
||||
let streamFinished = false;
|
||||
let modelEmitted = false;
|
||||
let firstValidChunkEmitted = false;
|
||||
let toolCallIndexOffset = 0;
|
||||
let hasOpenToolCallBatch = false;
|
||||
|
||||
|
|
@ -382,15 +380,6 @@ export class ChatService {
|
|||
|
||||
try {
|
||||
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
|
||||
|
||||
if (!firstValidChunkEmitted && parsed.object === 'chat.completion.chunk') {
|
||||
firstValidChunkEmitted = true;
|
||||
|
||||
if (!abortSignal?.aborted) {
|
||||
onFirstValidChunk?.();
|
||||
}
|
||||
}
|
||||
|
||||
const content = parsed.choices[0]?.delta?.content;
|
||||
const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
|
||||
const toolCalls = parsed.choices[0]?.delta?.tool_calls;
|
||||
|
|
@ -618,7 +607,7 @@ export class ChatService {
|
|||
|
||||
const imageFiles = message.extra.filter(
|
||||
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
|
||||
extra.type === 'imageFile'
|
||||
extra.type === AttachmentType.IMAGE
|
||||
);
|
||||
|
||||
for (const image of imageFiles) {
|
||||
|
|
@ -630,7 +619,7 @@ export class ChatService {
|
|||
|
||||
const textFiles = message.extra.filter(
|
||||
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraTextFile =>
|
||||
extra.type === 'textFile'
|
||||
extra.type === AttachmentType.TEXT
|
||||
);
|
||||
|
||||
for (const textFile of textFiles) {
|
||||
|
|
@ -643,7 +632,7 @@ export class ChatService {
|
|||
// Handle legacy 'context' type from old webui (pasted content)
|
||||
const legacyContextFiles = message.extra.filter(
|
||||
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraLegacyContext =>
|
||||
extra.type === 'context'
|
||||
extra.type === AttachmentType.LEGACY_CONTEXT
|
||||
);
|
||||
|
||||
for (const legacyContextFile of legacyContextFiles) {
|
||||
|
|
@ -655,7 +644,7 @@ export class ChatService {
|
|||
|
||||
const audioFiles = message.extra.filter(
|
||||
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraAudioFile =>
|
||||
extra.type === 'audioFile'
|
||||
extra.type === AttachmentType.AUDIO
|
||||
);
|
||||
|
||||
for (const audio of audioFiles) {
|
||||
|
|
@ -670,7 +659,7 @@ export class ChatService {
|
|||
|
||||
const pdfFiles = message.extra.filter(
|
||||
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
|
||||
extra.type === 'pdfFile'
|
||||
extra.type === AttachmentType.PDF
|
||||
);
|
||||
|
||||
for (const pdfFile of pdfFiles) {
|
||||
|
|
@ -722,6 +711,33 @@ export class ChatService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model information from /models endpoint
|
||||
*/
|
||||
static async getModels(): Promise<ApiModelListResponse> {
|
||||
try {
|
||||
const currentConfig = config();
|
||||
const apiKey = currentConfig.apiKey?.toString().trim();
|
||||
|
||||
const response = await fetch(`./models`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching models:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts any ongoing chat completion request.
|
||||
* Cancels the current request and cleans up the abort controller.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { DatabaseStore } from '$lib/stores/database';
|
||||
import { chatService, slotsService } from '$lib/services';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { normalizeModelName } from '$lib/utils/model-names';
|
||||
import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/utils/branching';
|
||||
import { browser } from '$app/environment';
|
||||
|
|
@ -365,41 +364,15 @@ class ChatStore {
|
|||
|
||||
let resolvedModel: string | null = null;
|
||||
let modelPersisted = false;
|
||||
const currentConfig = config();
|
||||
const preferServerPropsModel = !currentConfig.modelSelectorEnabled;
|
||||
let serverPropsRefreshed = false;
|
||||
let updateModelFromServerProps: ((persistImmediately?: boolean) => void) | null = null;
|
||||
|
||||
const refreshServerPropsOnce = () => {
|
||||
if (serverPropsRefreshed) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverPropsRefreshed = true;
|
||||
|
||||
const hasExistingProps = serverStore.serverProps !== null;
|
||||
|
||||
serverStore
|
||||
.fetchServerProps({ silent: hasExistingProps })
|
||||
.then(() => {
|
||||
updateModelFromServerProps?.(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to refresh server props after streaming started:', error);
|
||||
});
|
||||
};
|
||||
|
||||
const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
|
||||
const serverModelName = serverStore.modelName;
|
||||
const preferredModelSource = preferServerPropsModel
|
||||
? (serverModelName ?? modelName ?? null)
|
||||
: (modelName ?? serverModelName ?? null);
|
||||
|
||||
if (!preferredModelSource) {
|
||||
if (!modelName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedModel = normalizeModelName(preferredModelSource);
|
||||
const normalizedModel = normalizeModelName(modelName);
|
||||
|
||||
console.log('Resolved model:', normalizedModel);
|
||||
|
||||
if (!normalizedModel || normalizedModel === resolvedModel) {
|
||||
return;
|
||||
|
|
@ -423,20 +396,6 @@ class ChatStore {
|
|||
}
|
||||
};
|
||||
|
||||
if (preferServerPropsModel) {
|
||||
updateModelFromServerProps = (persistImmediately = true) => {
|
||||
const currentServerModel = serverStore.modelName;
|
||||
|
||||
if (!currentServerModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordModel(currentServerModel, persistImmediately);
|
||||
};
|
||||
|
||||
updateModelFromServerProps(false);
|
||||
}
|
||||
|
||||
slotsService.startStreaming();
|
||||
slotsService.setActiveConversation(assistantMessage.convId);
|
||||
|
||||
|
|
@ -445,9 +404,6 @@ class ChatStore {
|
|||
{
|
||||
...this.getApiOptions(),
|
||||
|
||||
onFirstValidChunk: () => {
|
||||
refreshServerPropsOnce();
|
||||
},
|
||||
onChunk: (chunk: string) => {
|
||||
streamedContent += chunk;
|
||||
this.setConversationStreaming(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { browser } from '$app/environment';
|
|||
import { SERVER_PROPS_LOCALSTORAGE_KEY } from '$lib/constants/localstorage-keys';
|
||||
import { ChatService } from '$lib/services/chat';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { ServerMode } from '$lib/enums/server';
|
||||
import { ModelModality } from '$lib/enums/model';
|
||||
import { updateConfig } from '$lib/stores/settings.svelte';
|
||||
|
||||
/**
|
||||
* ServerStore - Server state management and capability detection
|
||||
|
|
@ -52,6 +55,10 @@ class ServerStore {
|
|||
private _error = $state<string | null>(null);
|
||||
private _serverWarning = $state<string | null>(null);
|
||||
private _slotsEndpointAvailable = $state<boolean | null>(null);
|
||||
private _serverMode = $state<ServerMode | null>(null);
|
||||
private _selectedModel = $state<string | null>(null);
|
||||
private _availableModels = $state<ApiRouterModelMeta[]>([]);
|
||||
private _modelLoadingStates = $state<Map<string, boolean>>(new Map());
|
||||
private fetchServerPropsPromise: Promise<void> | null = null;
|
||||
|
||||
private readCachedServerProps(): ApiLlamaCppServerProps | null {
|
||||
|
|
@ -106,13 +113,13 @@ class ServerStore {
|
|||
return this._serverProps.model_path.split(/(\\|\/)/).pop() || null;
|
||||
}
|
||||
|
||||
get supportedModalities(): string[] {
|
||||
const modalities: string[] = [];
|
||||
get supportedModalities(): ModelModality[] {
|
||||
const modalities: ModelModality[] = [];
|
||||
if (this._serverProps?.modalities?.audio) {
|
||||
modalities.push('audio');
|
||||
modalities.push(ModelModality.AUDIO);
|
||||
}
|
||||
if (this._serverProps?.modalities?.vision) {
|
||||
modalities.push('vision');
|
||||
modalities.push(ModelModality.VISION);
|
||||
}
|
||||
return modalities;
|
||||
}
|
||||
|
|
@ -135,6 +142,48 @@ class ServerStore {
|
|||
return this._serverProps?.default_generation_settings?.params || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current server mode
|
||||
*/
|
||||
get serverMode(): ServerMode | null {
|
||||
return this._serverMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if server is running in router mode (multi-model management)
|
||||
*/
|
||||
get isRouterMode(): boolean {
|
||||
return this._serverMode === ServerMode.ROUTER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if server is running in model mode (single model loaded)
|
||||
*/
|
||||
get isModelMode(): boolean {
|
||||
return this._serverMode === ServerMode.MODEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currently selected model in router mode
|
||||
*/
|
||||
get selectedModel(): string | null {
|
||||
return this._selectedModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available models
|
||||
*/
|
||||
get availableModels(): ApiRouterModelMeta[] {
|
||||
return this._availableModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific model is currently loading
|
||||
*/
|
||||
isModelLoading(modelName: string): boolean {
|
||||
return this._modelLoadingStates.get(modelName) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if slots endpoint is available based on server properties and endpoint support
|
||||
*/
|
||||
|
|
@ -198,6 +247,21 @@ class ServerStore {
|
|||
this.persistServerProps(props);
|
||||
this._error = null;
|
||||
this._serverWarning = null;
|
||||
|
||||
// Detect server mode based on model_path
|
||||
if (props.model_path === 'none') {
|
||||
this._serverMode = ServerMode.ROUTER;
|
||||
console.info('Server running in ROUTER mode (multi-model management)');
|
||||
|
||||
// Auto-enable model selector in router mode
|
||||
if (browser) {
|
||||
updateConfig('modelSelectorEnabled', true);
|
||||
}
|
||||
} else {
|
||||
this._serverMode = ServerMode.MODEL;
|
||||
console.info('Server running in MODEL mode (single model)');
|
||||
}
|
||||
|
||||
await this.checkSlotsEndpointAvailability();
|
||||
} catch (error) {
|
||||
if (isSilent && hadProps) {
|
||||
|
|
@ -312,6 +376,10 @@ class ServerStore {
|
|||
this._serverWarning = null;
|
||||
this._loading = false;
|
||||
this._slotsEndpointAvailable = null;
|
||||
this._serverMode = null;
|
||||
this._selectedModel = null;
|
||||
this._availableModels = [];
|
||||
this._modelLoadingStates.clear();
|
||||
this.fetchServerPropsPromise = null;
|
||||
this.persistServerProps(null);
|
||||
}
|
||||
|
|
@ -329,3 +397,10 @@ export const supportsVision = () => serverStore.supportsVision;
|
|||
export const supportsAudio = () => serverStore.supportsAudio;
|
||||
export const slotsEndpointAvailable = () => serverStore.slotsEndpointAvailable;
|
||||
export const serverDefaultParams = () => serverStore.serverDefaultParams;
|
||||
|
||||
// Server mode exports
|
||||
export const serverMode = () => serverStore.serverMode;
|
||||
export const isRouterMode = () => serverStore.isRouterMode;
|
||||
export const isModelMode = () => serverStore.isModelMode;
|
||||
export const selectedModel = () => serverStore.selectedModel;
|
||||
export const availableModels = () => serverStore.availableModels;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ServerModelStatus } from '$lib/enums/model';
|
||||
import type { ChatMessagePromptProgress } from './chat';
|
||||
|
||||
export interface ApiChatMessageContentPart {
|
||||
|
|
@ -314,3 +315,74 @@ export interface ApiProcessingState {
|
|||
promptTokens?: number;
|
||||
cacheTokens?: number;
|
||||
}
|
||||
|
||||
export interface ApiRouterModelMeta {
|
||||
/** Model identifier (e.g., "unsloth/phi-4-GGUF:q4_k_m") */
|
||||
name: string;
|
||||
/** Path to model file or manifest */
|
||||
path: string;
|
||||
/** Optional path to multimodal projector */
|
||||
path_mmproj?: string;
|
||||
/** Whether model is in HuggingFace cache */
|
||||
in_cache: boolean;
|
||||
/** Port where model instance is running (0 if not loaded) */
|
||||
port: number;
|
||||
/** Current status of the model */
|
||||
status: ServerModelStatus;
|
||||
/** Error message if status is FAILED */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to load a model
|
||||
*/
|
||||
export interface ApiRouterModelsLoadRequest {
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from loading a model
|
||||
*/
|
||||
export interface ApiRouterModelsLoadResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to check model status
|
||||
*/
|
||||
export interface ApiRouterModelsStatusRequest {
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response with model status
|
||||
*/
|
||||
export interface ApiRouterModelsStatusResponse {
|
||||
model: string;
|
||||
status: ModelStatus;
|
||||
port?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response with list of all models
|
||||
*/
|
||||
export interface ApiRouterModelsListResponse {
|
||||
models: ApiRouterModelMeta[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to unload a model
|
||||
*/
|
||||
export interface ApiRouterModelsUnloadRequest {
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from unloading a model
|
||||
*/
|
||||
export interface ApiRouterModelsUnloadResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ChatMessageTimings } from './chat';
|
||||
import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat';
|
||||
import { AttachmentType } from '$lib/enums/attachment';
|
||||
|
||||
export interface DatabaseConversation {
|
||||
currNode: string | null;
|
||||
|
|
@ -8,38 +9,39 @@ export interface DatabaseConversation {
|
|||
}
|
||||
|
||||
export interface DatabaseMessageExtraAudioFile {
|
||||
type: 'audioFile';
|
||||
type: AttachmentType.AUDIO;
|
||||
name: string;
|
||||
base64Data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface DatabaseMessageExtraImageFile {
|
||||
type: 'imageFile';
|
||||
type: AttachmentType.IMAGE;
|
||||
name: string;
|
||||
base64Url: string;
|
||||
}
|
||||
|
||||
export interface DatabaseMessageExtraTextFile {
|
||||
type: 'textFile';
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DatabaseMessageExtraPdfFile {
|
||||
type: 'pdfFile';
|
||||
name: string;
|
||||
content: string; // Text content extracted from PDF
|
||||
images?: string[]; // Optional: PDF pages as base64 images
|
||||
processedAsImages: boolean; // Whether PDF was processed as images
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy format from old webui - pasted content was stored as "context" type
|
||||
* @deprecated Use DatabaseMessageExtraTextFile instead
|
||||
*/
|
||||
export interface DatabaseMessageExtraLegacyContext {
|
||||
type: 'context';
|
||||
type: AttachmentType.LEGACY_CONTEXT;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DatabaseMessageExtraPdfFile {
|
||||
type: AttachmentType.PDF;
|
||||
base64Data: string;
|
||||
name: string;
|
||||
content: string; // Text content extracted from PDF
|
||||
images?: string[]; // Optional: PDF pages as base64 images
|
||||
processedAsImages: boolean; // Whether PDF was processed as images
|
||||
}
|
||||
|
||||
export interface DatabaseMessageExtraTextFile {
|
||||
type: AttachmentType.TEXT;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export interface SettingsChatServiceOptions {
|
|||
onReasoningChunk?: (chunk: string) => void;
|
||||
onToolCallChunk?: (chunk: string) => void;
|
||||
onModel?: (model: string) => void;
|
||||
onFirstValidChunk?: () => void;
|
||||
onComplete?: (
|
||||
response: string,
|
||||
reasoningContent?: string,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { convertPDFToImage, convertPDFToText } from './pdf-processing';
|
|||
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
|
||||
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
|
||||
import { FileTypeCategory } from '$lib/enums/files';
|
||||
import { AttachmentType } from '$lib/enums/attachment';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { supportsVision } from '$lib/stores/server.svelte';
|
||||
import { getFileTypeCategory } from '$lib/utils/file-type';
|
||||
|
|
@ -56,7 +57,7 @@ export async function parseFilesToMessageExtras(
|
|||
}
|
||||
|
||||
extras.push({
|
||||
type: 'imageFile',
|
||||
type: AttachmentType.IMAGE,
|
||||
name: file.name,
|
||||
base64Url
|
||||
});
|
||||
|
|
@ -67,7 +68,7 @@ export async function parseFilesToMessageExtras(
|
|||
const base64Data = await readFileAsBase64(file.file);
|
||||
|
||||
extras.push({
|
||||
type: 'audioFile',
|
||||
type: AttachmentType.AUDIO,
|
||||
name: file.name,
|
||||
base64Data: base64Data,
|
||||
mimeType: file.type
|
||||
|
|
@ -117,7 +118,7 @@ export async function parseFilesToMessageExtras(
|
|||
);
|
||||
|
||||
extras.push({
|
||||
type: 'pdfFile',
|
||||
type: AttachmentType.PDF,
|
||||
name: file.name,
|
||||
content: `PDF file with ${images.length} pages`,
|
||||
images: images,
|
||||
|
|
@ -134,7 +135,7 @@ export async function parseFilesToMessageExtras(
|
|||
const content = await convertPDFToText(file.file);
|
||||
|
||||
extras.push({
|
||||
type: 'pdfFile',
|
||||
type: AttachmentType.PDF,
|
||||
name: file.name,
|
||||
content: content,
|
||||
processedAsImages: false,
|
||||
|
|
@ -151,7 +152,7 @@ export async function parseFilesToMessageExtras(
|
|||
});
|
||||
|
||||
extras.push({
|
||||
type: 'pdfFile',
|
||||
type: AttachmentType.PDF,
|
||||
name: file.name,
|
||||
content: content,
|
||||
processedAsImages: false,
|
||||
|
|
@ -171,7 +172,7 @@ export async function parseFilesToMessageExtras(
|
|||
emptyFiles.push(file.name);
|
||||
} else if (isLikelyTextFile(content)) {
|
||||
extras.push({
|
||||
type: 'textFile',
|
||||
type: AttachmentType.TEXT,
|
||||
name: file.name,
|
||||
content: content
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
/**
|
||||
* Normalizes a model name by extracting the filename from a path.
|
||||
* Normalizes a model name by extracting the filename from a path, but preserves Hugging Face repository format.
|
||||
*
|
||||
* Handles both forward slashes (/) and backslashes (\) as path separators.
|
||||
* If the model name is just a filename (no path), returns it as-is.
|
||||
* - If the model name has exactly one slash (org/model format), preserves the full "org/model" name
|
||||
* - If the model name has no slash or multiple slashes, extracts just the filename
|
||||
* - If the model name is just a filename (no path), returns it as-is.
|
||||
*
|
||||
* @param modelName - The model name or path to normalize
|
||||
* @returns The normalized model name (filename only)
|
||||
* @returns The normalized model name
|
||||
*
|
||||
* @example
|
||||
* normalizeModelName('models/llama-3.1-8b') // Returns: 'llama-3.1-8b'
|
||||
* normalizeModelName('C:\\Models\\gpt-4') // Returns: 'gpt-4'
|
||||
* normalizeModelName('simple-model') // Returns: 'simple-model'
|
||||
* normalizeModelName('models/llama-3.1-8b') // Returns: 'llama-3.1-8b' (multiple slashes -> filename)
|
||||
* normalizeModelName('C:\\Models\\gpt-4') // Returns: 'gpt-4' (multiple slashes -> filename)
|
||||
* normalizeModelName('meta-llama/Llama-3.1-8B') // Returns: 'meta-llama/Llama-3.1-8B' (Hugging Face format)
|
||||
* normalizeModelName('simple-model') // Returns: 'simple-model' (no slash)
|
||||
* normalizeModelName(' spaced ') // Returns: 'spaced'
|
||||
* normalizeModelName('') // Returns: ''
|
||||
*/
|
||||
|
|
@ -22,6 +25,20 @@ export function normalizeModelName(modelName: string): string {
|
|||
}
|
||||
|
||||
const segments = trimmed.split(/[\\/]/);
|
||||
|
||||
// If we have exactly 2 segments (one slash), treat it as Hugging Face repo format
|
||||
// and preserve the full "org/model" format
|
||||
if (segments.length === 2) {
|
||||
const [org, model] = segments;
|
||||
const trimmedOrg = org?.trim();
|
||||
const trimmedModel = model?.trim();
|
||||
|
||||
if (trimmedOrg && trimmedModel) {
|
||||
return `${trimmedOrg}/${trimmedModel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// For other cases (no slash, or multiple slashes), extract just the filename
|
||||
const candidate = segments.pop();
|
||||
const normalized = candidate?.trim();
|
||||
|
||||
|
|
|
|||
|
|
@ -158,7 +158,8 @@ export default defineConfig({
|
|||
proxy: {
|
||||
'/v1': 'http://localhost:8080',
|
||||
'/props': 'http://localhost:8080',
|
||||
'/slots': 'http://localhost:8080'
|
||||
'/slots': 'http://localhost:8080',
|
||||
'/models': 'http://localhost:8080'
|
||||
},
|
||||
headers: {
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
|
|
|
|||
Loading…
Reference in New Issue