Pre-MCP UI and architecture cleanup (#19689)

This commit is contained in:
Aleksander Grygier
2026-02-18 12:02:02 +01:00
committed by GitHub
parent d0061be838
commit ea003229d3
52 changed files with 5553 additions and 4325 deletions
Binary file not shown.
+3 -1
View File
@@ -27,7 +27,9 @@ export default ts.config(
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off', 'no-undef': 'off',
'svelte/no-at-html-tags': 'off' 'svelte/no-at-html-tags': 'off',
// This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
'svelte/no-navigation-without-resolve': 'off'
} }
}, },
{ {
+1053 -487
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -23,31 +23,32 @@
"cleanup": "rm -rf .svelte-kit build node_modules test-results" "cleanup": "rm -rf .svelte-kit build node_modules test-results"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^4.1.2", "@chromatic-com/storybook": "^5.0.0",
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@internationalized/date": "^3.10.1", "@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.515.0", "@lucide/svelte": "^0.515.0",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@storybook/addon-a11y": "^10.0.7", "@storybook/addon-a11y": "^10.2.4",
"@storybook/addon-docs": "^10.0.7", "@storybook/addon-docs": "^10.2.4",
"@storybook/addon-svelte-csf": "^5.0.10", "@storybook/addon-svelte-csf": "^5.0.10",
"@storybook/addon-vitest": "^10.0.7", "@storybook/addon-vitest": "^10.2.4",
"@storybook/sveltekit": "^10.0.7", "@storybook/sveltekit": "^10.2.4",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.48.4", "@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^22", "@types/node": "^24",
"@vitest/browser": "^3.2.3", "@vitest/browser": "^3.2.3",
"@vitest/coverage-v8": "^3.2.3",
"bits-ui": "^2.14.4", "bits-ui": "^2.14.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.11", "dexie": "^4.0.11",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-storybook": "^10.0.7", "eslint-plugin-storybook": "^10.2.4",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"globals": "^16.0.0", "globals": "^16.0.0",
@@ -61,7 +62,7 @@
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"sass": "^1.93.3", "sass": "^1.93.3",
"storybook": "^10.0.7", "storybook": "^10.2.4",
"svelte": "^5.38.2", "svelte": "^5.38.2",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@@ -8,7 +8,8 @@
isImageFile, isImageFile,
isPdfFile, isPdfFile,
isAudioFile, isAudioFile,
getLanguageFromFilename getLanguageFromFilename,
createBase64DataUrl
} from '$lib/utils'; } from '$lib/utils';
import { convertPDFToImage } from '$lib/utils/browser-only'; import { convertPDFToImage } from '$lib/utils/browser-only';
import { modelsStore } from '$lib/stores/models.svelte'; import { modelsStore } from '$lib/stores/models.svelte';
@@ -255,7 +256,7 @@
<audio <audio
controls controls
class="mb-4 w-full" class="mb-4 w-full"
src={`data:${attachment.mimeType};base64,${attachment.base64Data}`} src={createBase64DataUrl(attachment.mimeType, attachment.base64Data)}
> >
Your browser does not support the audio element. Your browser does not support the audio element.
</audio> </audio>
@@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app'; import {
ChatAttachmentThumbnailImage,
ChatAttachmentThumbnailFile,
HorizontalScrollCarousel,
DialogChatAttachmentPreview,
DialogChatAttachmentsViewAll
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
import { getAttachmentDisplayItems } from '$lib/utils'; import { getAttachmentDisplayItems } from '$lib/utils';
interface Props { interface Props {
@@ -41,12 +45,10 @@
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments })); let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
let canScrollLeft = $state(false); let carouselRef: HorizontalScrollCarousel | undefined = $state();
let canScrollRight = $state(false);
let isScrollable = $state(false); let isScrollable = $state(false);
let previewDialogOpen = $state(false); let previewDialogOpen = $state(false);
let previewItem = $state<ChatAttachmentPreviewItem | null>(null); let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
let scrollContainer: HTMLDivElement | undefined = $state();
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable); let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
let viewAllDialogOpen = $state(false); let viewAllDialogOpen = $state(false);
@@ -65,41 +67,9 @@
previewDialogOpen = true; previewDialogOpen = true;
} }
function scrollLeft(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
}
function scrollRight(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
}
function updateScrollButtons() {
if (!scrollContainer) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
canScrollLeft = scrollLeft > 0;
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
isScrollable = scrollWidth > clientWidth;
}
$effect(() => { $effect(() => {
if (scrollContainer && displayItems.length) { if (carouselRef && displayItems.length) {
scrollContainer.scrollLeft = 0; carouselRef.resetScroll();
setTimeout(() => {
updateScrollButtons();
}, 0);
} }
}); });
</script> </script>
@@ -107,67 +77,40 @@
{#if displayItems.length > 0} {#if displayItems.length > 0}
<div class={className} {style}> <div class={className} {style}>
{#if limitToSingleRow} {#if limitToSingleRow}
<div class="relative"> <HorizontalScrollCarousel
<button bind:this={carouselRef}
class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollLeft onScrollableChange={(scrollable) => (isScrollable = scrollable)}
? 'opacity-100' >
: 'pointer-events-none opacity-0'}" {#each displayItems as item (item.id)}
onclick={scrollLeft} {#if item.isImage && item.preview}
aria-label="Scroll left" <ChatAttachmentThumbnailImage
> class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
<ChevronLeft class="h-4 w-4" /> id={item.id}
</button> name={item.name}
preview={item.preview}
<div {readonly}
class="scrollbar-hide flex items-start gap-3 overflow-x-auto" onRemove={onFileRemove}
bind:this={scrollContainer} height={imageHeight}
onscroll={updateScrollButtons} width={imageWidth}
> {imageClass}
{#each displayItems as item (item.id)} onClick={(event) => openPreview(item, event)}
{#if item.isImage && item.preview} />
<ChatAttachmentThumbnailImage {:else}
class="flex-shrink-0 cursor-pointer {limitToSingleRow <ChatAttachmentThumbnailFile
? 'first:ml-4 last:mr-4' class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
: ''}" id={item.id}
id={item.id} name={item.name}
name={item.name} size={item.size}
preview={item.preview} {readonly}
{readonly} onRemove={onFileRemove}
onRemove={onFileRemove} textContent={item.textContent}
height={imageHeight} attachment={item.attachment}
width={imageWidth} uploadedFile={item.uploadedFile}
{imageClass} onClick={(event) => openPreview(item, event)}
onClick={(event) => openPreview(item, event)} />
/> {/if}
{:else} {/each}
<ChatAttachmentThumbnailFile </HorizontalScrollCarousel>
class="flex-shrink-0 cursor-pointer {limitToSingleRow
? 'first:ml-4 last:mr-4'
: ''}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onClick={(event) => openPreview(item, event)}
/>
{/if}
{/each}
</div>
<button
class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={scrollRight}
aria-label="Scroll right"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
{#if showViewAll} {#if showViewAll}
<div class="mt-2 -mr-2 flex justify-end px-4"> <div class="mt-2 -mr-2 flex justify-end px-4">
@@ -1,20 +1,19 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate } from '$app/navigation';
import { import {
ChatAttachmentsList, ChatAttachmentsList,
ChatFormActions, ChatFormActions,
ChatFormFileInputInvisible, ChatFormFileInputInvisible,
ChatFormHelperText,
ChatFormTextarea ChatFormTextarea
} from '$lib/components/app'; } from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/input-classes'; import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { CLIPBOARD_CONTENT_QUOTE_PREFIX } from '$lib/constants/chat-form';
import { KeyboardKey, MimeTypeText } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte'; import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte'; import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte'; import { activeMessages } from '$lib/stores/conversations.svelte';
import { MimeTypeText } from '$lib/enums';
import { isIMEComposing, parseClipboardContent } from '$lib/utils'; import { isIMEComposing, parseClipboardContent } from '$lib/utils';
import { import {
AudioRecorder, AudioRecorder,
@@ -25,68 +24,82 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
interface Props { interface Props {
// Data
attachments?: DatabaseMessageExtra[];
uploadedFiles?: ChatUploadedFile[];
value?: string;
// UI State
class?: string; class?: string;
disabled?: boolean; disabled?: boolean;
initialMessage?: string;
isLoading?: boolean; isLoading?: boolean;
onFileRemove?: (fileId: string) => void; placeholder?: string;
onFileUpload?: (files: File[]) => void;
onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>; // Event Handlers
onAttachmentRemove?: (index: number) => void;
onFilesAdd?: (files: File[]) => void;
onStop?: () => void; onStop?: () => void;
onSystemPromptAdd?: (draft: { message: string; files: ChatUploadedFile[] }) => void; onSubmit?: () => void;
showHelperText?: boolean; onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
uploadedFiles?: ChatUploadedFile[]; onUploadedFileRemove?: (fileId: string) => void;
onValueChange?: (value: string) => void;
} }
let { let {
class: className, attachments = [],
class: className = '',
disabled = false, disabled = false,
initialMessage = '',
isLoading = false, isLoading = false,
onFileRemove, placeholder = 'Type a message...',
onFileUpload, uploadedFiles = $bindable([]),
onSend, value = $bindable(''),
onAttachmentRemove,
onFilesAdd,
onStop, onStop,
onSystemPromptAdd, onSubmit,
showHelperText = true, onSystemPromptClick,
uploadedFiles = $bindable([]) onUploadedFileRemove,
onValueChange
}: Props = $props(); }: Props = $props();
/**
*
*
* STATE
*
*
*/
// Component References
let audioRecorder: AudioRecorder | undefined; let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined); let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let currentConfig = $derived(config());
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined); let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
// Audio Recording State
let isRecording = $state(false); let isRecording = $state(false);
let message = $derived(initialMessage); let recordingSupported = $state(false);
/**
*
*
* DERIVED STATE
*
*
*/
// Configuration
let currentConfig = $derived(config());
let pasteLongTextToFileLength = $derived.by(() => { let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen); const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n; return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
}); });
let previousIsLoading = $derived(isLoading);
let previousInitialMessage = $derived(initialMessage);
let recordingSupported = $state(false);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
// Sync message when initialMessage prop changes (e.g., after draft restoration) // Model Selection Logic
$effect(() => { let isRouter = $derived(isRouterMode());
if (initialMessage !== previousInitialMessage) {
message = initialMessage;
previousInitialMessage = initialMessage;
}
});
function handleSystemPromptClick() {
onSystemPromptAdd?.({ message, files: uploadedFiles });
}
// Check if model is selected (in ROUTER mode)
let conversationModel = $derived( let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[]) chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
); );
let isRouter = $derived(isRouterMode());
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
// Get active model ID for capability detection
let activeModelId = $derived.by(() => { let activeModelId = $derived.by(() => {
const options = modelOptions(); const options = modelOptions();
@@ -94,14 +107,12 @@
return options.length > 0 ? options[0].model : null; return options.length > 0 ? options[0].model : null;
} }
// First try user-selected model
const selectedId = selectedModelId(); const selectedId = selectedModelId();
if (selectedId) { if (selectedId) {
const model = options.find((m) => m.id === selectedId); const model = options.find((m) => m.id === selectedId);
if (model) return model.model; if (model) return model.model;
} }
// Fallback to conversation model
if (conversationModel) { if (conversationModel) {
const model = options.find((m) => m.model === conversationModel); const model = options.find((m) => m.model === conversationModel);
if (model) return model.model; if (model) return model.model;
@@ -110,46 +121,101 @@
return null; return null;
}); });
function checkModelSelected(): boolean { // Form Validation State
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let hasAttachments = $derived(
(attachments && attachments.length > 0) || (uploadedFiles && uploadedFiles.length > 0)
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
/**
*
*
* LIFECYCLE
*
*
*/
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
/**
*
*
* PUBLIC API
*
*
*/
export function focus() {
textareaRef?.focus();
}
export function resetTextareaHeight() {
textareaRef?.resetHeight();
}
export function openModelSelector() {
chatFormActionsRef?.openModelSelector();
}
/**
* Check if a model is selected, open selector if not
* @returns true if model is selected, false otherwise
*/
export function checkModelSelected(): boolean {
if (!hasModelSelected) { if (!hasModelSelected) {
// Open the model selector
chatFormActionsRef?.openModelSelector(); chatFormActionsRef?.openModelSelector();
return false; return false;
} }
return true; return true;
} }
/**
*
*
* EVENT HANDLERS - File Management
*
*
*/
function handleFileSelect(files: File[]) { function handleFileSelect(files: File[]) {
onFileUpload?.(files); onFilesAdd?.(files);
} }
function handleFileUpload() { function handleFileUpload() {
fileInputRef?.click(); fileInputRef?.click();
} }
async function handleKeydown(event: KeyboardEvent) { function handleFileRemove(fileId: string) {
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) { if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index);
}
} else {
onUploadedFileRemove?.(fileId);
}
}
/**
*
*
* EVENT HANDLERS - Input & Keyboard
*
*
*/
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault(); event.preventDefault();
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return; if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
if (!checkModelSelected()) return; onSubmit?.();
const messageToSend = message.trim();
const filesToSend = [...uploadedFiles];
message = '';
uploadedFiles = [];
textareaRef?.resetHeight();
const success = await onSend?.(messageToSend, filesToSend);
if (!success) {
message = messageToSend;
uploadedFiles = filesToSend;
}
} }
} }
@@ -163,29 +229,30 @@
if (files.length > 0) { if (files.length > 0) {
event.preventDefault(); event.preventDefault();
onFileUpload?.(files); onFilesAdd?.(files);
return; return;
} }
const text = event.clipboardData.getData(MimeTypeText.PLAIN); const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith('"')) { if (text.startsWith(CLIPBOARD_CONTENT_QUOTE_PREFIX)) {
const parsed = parseClipboardContent(text); const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) { if (parsed.textAttachments.length > 0) {
event.preventDefault(); event.preventDefault();
value = parsed.message;
onValueChange?.(parsed.message);
message = parsed.message; // Handle text attachments as files
if (parsed.textAttachments.length > 0) {
const attachmentFiles = parsed.textAttachments.map( const attachmentFiles = parsed.textAttachments.map(
(att) => (att) =>
new File([att.content], att.name, { new File([att.content], att.name, {
type: MimeTypeText.PLAIN type: MimeTypeText.PLAIN
}) })
); );
onFilesAdd?.(attachmentFiles);
onFileUpload?.(attachmentFiles); }
setTimeout(() => { setTimeout(() => {
textareaRef?.focus(); textareaRef?.focus();
@@ -206,14 +273,21 @@
type: MimeTypeText.PLAIN type: MimeTypeText.PLAIN
}); });
onFileUpload?.([textFile]); onFilesAdd?.([textFile]);
} }
} }
/**
*
*
* EVENT HANDLERS - Audio Recording
*
*
*/
async function handleMicClick() { async function handleMicClick() {
if (!audioRecorder || !recordingSupported) { if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported'); console.warn('Audio recording not supported');
return; return;
} }
@@ -223,7 +297,7 @@
const wavBlob = await convertToWav(audioBlob); const wavBlob = await convertToWav(audioBlob);
const audioFile = createAudioFile(wavBlob); const audioFile = createAudioFile(wavBlob);
onFileUpload?.([audioFile]); onFilesAdd?.([audioFile]);
isRecording = false; isRecording = false;
} catch (error) { } catch (error) {
console.error('Failed to stop recording:', error); console.error('Failed to stop recording:', error);
@@ -238,98 +312,64 @@
} }
} }
} }
function handleStop() {
onStop?.();
}
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
// Check if model is selected first
if (!checkModelSelected()) return;
const messageToSend = message.trim();
const filesToSend = [...uploadedFiles];
message = '';
uploadedFiles = [];
textareaRef?.resetHeight();
const success = await onSend?.(messageToSend, filesToSend);
if (!success) {
message = messageToSend;
uploadedFiles = filesToSend;
}
}
onMount(() => {
setTimeout(() => textareaRef?.focus(), 10);
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
afterNavigate(() => {
setTimeout(() => textareaRef?.focus(), 10);
});
$effect(() => {
if (previousIsLoading && !isLoading) {
setTimeout(() => textareaRef?.focus(), 10);
}
previousIsLoading = isLoading;
});
</script> </script>
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} /> <ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
<form <form
onsubmit={handleSubmit} class="relative {className}"
class="relative {INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled onsubmit={(e) => {
? 'cursor-not-allowed opacity-60' e.preventDefault();
: ''} {className}" if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
data-slot="chat-form" onSubmit?.();
}}
> >
<ChatAttachmentsList
bind:uploadedFiles
{onFileRemove}
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
/>
<div <div
class="flex-column relative min-h-[48px] items-center rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!py-3" class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
onpaste={handlePaste} ? 'cursor-not-allowed opacity-60'
: ''}"
data-slot="input-area"
> >
<ChatFormTextarea <ChatAttachmentsList
class="px-5 py-1.5 md:pt-0" {attachments}
bind:this={textareaRef} bind:uploadedFiles
bind:value={message} onFileRemove={handleFileRemove}
onKeydown={handleKeydown} limitToSingleRow
{disabled} class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
/> />
<ChatFormActions <div
class="px-3" class="flex-column relative min-h-[48px] items-center rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!py-3"
bind:this={chatFormActionsRef} onpaste={handlePaste}
canSend={message.trim().length > 0 || uploadedFiles.length > 0} >
hasText={message.trim().length > 0} <ChatFormTextarea
{disabled} class="px-5 py-1.5 md:pt-0"
{isLoading} bind:this={textareaRef}
{isRecording} bind:value
{uploadedFiles} onKeydown={handleKeydown}
onFileUpload={handleFileUpload} onInput={() => {
onMicClick={handleMicClick} onValueChange?.(value);
onStop={handleStop} }}
onSystemPromptClick={handleSystemPromptClick} {disabled}
/> {placeholder}
/>
<ChatFormActions
class="px-3"
bind:this={chatFormActionsRef}
canSend={canSubmit}
hasText={value.trim().length > 0}
{disabled}
{isLoading}
{isRecording}
{uploadedFiles}
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
/>
</div>
</div> </div>
</form> </form>
<ChatFormHelperText show={showHelperText} />
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { MessageSquare, Plus } from '@lucide/svelte'; import { Plus, MessageSquare } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
@@ -16,16 +16,6 @@
onSystemPromptClick?: () => void; onSystemPromptClick?: () => void;
} }
type AttachmentActionId = 'images' | 'audio' | 'text' | 'pdf' | 'system';
interface AttachmentAction {
id: AttachmentActionId;
label: string;
disabled?: boolean;
disabledReason?: string;
tooltip?: string;
}
let { let {
class: className = '', class: className = '',
disabled = false, disabled = false,
@@ -36,62 +26,20 @@
}: Props = $props(); }: Props = $props();
let isNewChat = $derived(!page.params.id); let isNewChat = $derived(!page.params.id);
let systemMessageTooltip = $derived( let systemMessageTooltip = $derived(
isNewChat isNewChat
? 'Add custom system message for a new conversation' ? 'Add custom system message for a new conversation'
: 'Inject custom system message at the beginning of the conversation' : 'Inject custom system message at the beginning of the conversation'
); );
let actions = $derived.by<AttachmentAction[]>(() => [ let dropdownOpen = $state(false);
{
id: 'images',
label: 'Images',
disabled: !hasVisionModality,
disabledReason: !hasVisionModality
? 'Images require vision models to be processed'
: undefined
},
{
id: 'audio',
label: 'Audio Files',
disabled: !hasAudioModality,
disabledReason: !hasAudioModality
? 'Audio files require audio models to be processed'
: undefined
},
{
id: 'text',
label: 'Text Files'
},
{
id: 'pdf',
label: 'PDF Files',
tooltip: !hasVisionModality
? 'PDFs will be converted to text. Image-based PDFs may not work properly.'
: undefined
},
{
id: 'system',
label: 'System Message',
tooltip: systemMessageTooltip
}
]);
function handleActionClick(id: AttachmentActionId) { const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
if (id === 'system') {
onSystemPromptClick?.();
return;
}
onFileUpload?.();
}
const triggerTooltipText = 'Add files or system message';
const itemClass = 'flex cursor-pointer items-center gap-2';
</script> </script>
<div class="flex items-center gap-1 {className}"> <div class="flex items-center gap-1 {className}">
<DropdownMenu.Root> <DropdownMenu.Root bind:open={dropdownOpen}>
<DropdownMenu.Trigger name="Attach files" {disabled}> <DropdownMenu.Trigger name="Attach files" {disabled}>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger class="w-full"> <Tooltip.Trigger class="w-full">
@@ -101,89 +49,125 @@
variant="secondary" variant="secondary"
type="button" type="button"
> >
<span class="sr-only">{triggerTooltipText}</span> <span class="sr-only">{fileUploadTooltipText}</span>
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
<p>{triggerTooltipText}</p> <p>{fileUploadTooltipText}</p>
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-56"> <DropdownMenu.Content align="start" class="w-48">
{#each actions as item (item.id)} {#if hasVisionModality}
{@const hasDisabledTooltip = !!item.disabled && !!item.disabledReason} <DropdownMenu.Item
{@const hasEnabledTooltip = !item.disabled && !!item.tooltip} class="images-button flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.image class="h-4 w-4" />
{#if hasDisabledTooltip} <span>Images</span>
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}> </DropdownMenu.Item>
<Tooltip.Trigger class="w-full"> {:else}
<DropdownMenu.Item class={itemClass} disabled> <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
{#if item.id === 'images'} <Tooltip.Trigger class="w-full">
<FILE_TYPE_ICONS.image class="h-4 w-4" /> <DropdownMenu.Item
{:else if item.id === 'audio'} class="images-button flex cursor-pointer items-center gap-2"
<FILE_TYPE_ICONS.audio class="h-4 w-4" /> disabled
{:else if item.id === 'text'} >
<FILE_TYPE_ICONS.text class="h-4 w-4" />
{:else if item.id === 'pdf'}
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
{:else}
<MessageSquare class="h-4 w-4" />
{/if}
<span>{item.label}</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{item.disabledReason}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else if hasEnabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item class={itemClass} onclick={() => handleActionClick(item.id)}>
{#if item.id === 'images'}
<FILE_TYPE_ICONS.image class="h-4 w-4" />
{:else if item.id === 'audio'}
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
{:else if item.id === 'text'}
<FILE_TYPE_ICONS.text class="h-4 w-4" />
{:else if item.id === 'pdf'}
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
{:else}
<MessageSquare class="h-4 w-4" />
{/if}
<span>{item.label}</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{item.tooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<DropdownMenu.Item class={itemClass} onclick={() => handleActionClick(item.id)}>
{#if item.id === 'images'}
<FILE_TYPE_ICONS.image class="h-4 w-4" /> <FILE_TYPE_ICONS.image class="h-4 w-4" />
{:else if item.id === 'audio'}
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
{:else if item.id === 'text'}
<FILE_TYPE_ICONS.text class="h-4 w-4" />
{:else if item.id === 'pdf'}
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
{:else}
<MessageSquare class="h-4 w-4" />
{/if}
<span>{item.label}</span> <span>Images</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>Images require vision models to be processed</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasAudioModality}
<DropdownMenu.Item
class="audio-button flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
<span>Audio Files</span>
</DropdownMenu.Item>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item class="audio-button flex cursor-pointer items-center gap-2" disabled>
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
<span>Audio Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>Audio files require audio models to be processed</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.text class="h-4 w-4" />
<span>Text Files</span>
</DropdownMenu.Item>
{#if hasVisionModality}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
<span>PDF Files</span>
</DropdownMenu.Item>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
<span>PDF Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onSystemPromptClick?.()}
>
<MessageSquare class="h-4 w-4" />
<span>System Message</span>
</DropdownMenu.Item> </DropdownMenu.Item>
{/if} </Tooltip.Trigger>
{/each}
<Tooltip.Content side="right">
<p>{systemMessageTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
</div> </div>
@@ -1,143 +0,0 @@
<script lang="ts">
import { Paperclip } from '@lucide/svelte';
import { MessageSquare } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
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';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
onFileUpload,
onSystemPromptClick
}: Props = $props();
const fileUploadTooltipText = $derived.by(() => {
return !hasVisionModality
? 'Text files and PDFs supported. Images, audio, and video require vision models.'
: 'Attach files';
});
</script>
<div class="flex items-center gap-1 {className}">
<DropdownMenu.Root>
<DropdownMenu.Trigger name="Attach files" {disabled}>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
{disabled}
type="button"
>
<span class="sr-only">Attach files</span>
<Paperclip class="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{fileUploadTooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-48">
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="images-button flex cursor-pointer items-center gap-2"
disabled={!hasVisionModality}
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.image class="h-4 w-4" />
<span>Images</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
{#if !hasVisionModality}
<Tooltip.Content>
<p>Images require vision models to be processed</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="audio-button flex cursor-pointer items-center gap-2"
disabled={!hasAudioModality}
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
<span>Audio Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
{#if !hasAudioModality}
<Tooltip.Content>
<p>Audio files require audio models to be processed</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.text class="h-4 w-4" />
<span>Text Files</span>
</DropdownMenu.Item>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
<span>PDF Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
{#if !hasVisionModality}
<Tooltip.Content>
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
<DropdownMenu.Separator />
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onSystemPromptClick?.()}
>
<MessageSquare class="h-4 w-4" />
<span>System Prompt</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Add a custom system message for this conversation</p>
</Tooltip.Content>
</Tooltip.Root>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -13,8 +13,7 @@
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte'; import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte'; import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages, usedModalities } from '$lib/stores/conversations.svelte'; import { activeMessages } from '$lib/stores/conversations.svelte';
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
interface Props { interface Props {
canSend?: boolean; canSend?: boolean;
@@ -154,15 +153,6 @@
export function openModelSelector() { export function openModelSelector() {
selectorModelRef?.open(); selectorModelRef?.open();
} }
const { handleModelChange } = useModelChangeValidation({
getRequiredModalities: () => usedModalities(),
onValidationFailure: async (previousModelId: string | null) => {
if (previousModelId) {
await modelsStore.selectModelById(previousModelId);
}
}
});
</script> </script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size"> <div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
@@ -183,7 +173,6 @@
currentModel={conversationModel} currentModel={conversationModel}
forceForegroundText={true} forceForegroundText={true}
useGlobalSelection={true} useGlobalSelection={true}
onModelChange={handleModelChange}
/> />
</div> </div>
@@ -1,61 +1,35 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { base } from '$app/paths'; import { base } from '$app/paths';
import { import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
chatStore, import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
pendingEditMessageId,
clearPendingEditMessageId,
removeSystemPromptPlaceholder
} from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services'; import { DatabaseService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui'; import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils'; import { MessageRole } from '$lib/enums';
import ChatMessageAssistant from './ChatMessageAssistant.svelte'; import {
import ChatMessageUser from './ChatMessageUser.svelte'; ChatMessageAssistant,
import ChatMessageSystem from './ChatMessageSystem.svelte'; ChatMessageUser,
ChatMessageSystem
} from '$lib/components/app/chat';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
interface Props { interface Props {
class?: string; class?: string;
message: DatabaseMessage; message: DatabaseMessage;
onCopy?: (message: DatabaseMessage) => void; isLastAssistantMessage?: boolean;
onContinueAssistantMessage?: (message: DatabaseMessage) => void;
onDelete?: (message: DatabaseMessage) => void;
onEditWithBranching?: (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => void;
onEditWithReplacement?: (
message: DatabaseMessage,
newContent: string,
shouldBranch: boolean
) => void;
onEditUserMessagePreserveResponses?: (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => void;
onNavigateToSibling?: (siblingId: string) => void;
onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
siblingInfo?: ChatMessageSiblingInfo | null; siblingInfo?: ChatMessageSiblingInfo | null;
} }
let { let {
class: className = '', class: className = '',
message, message,
onCopy, isLastAssistantMessage = false,
onContinueAssistantMessage,
onDelete,
onEditWithBranching,
onEditWithReplacement,
onEditUserMessagePreserveResponses,
onNavigateToSibling,
onRegenerateWithBranching,
siblingInfo = null siblingInfo = null
}: Props = $props(); }: Props = $props();
const chatActions = getChatActionsContext();
let deletionInfo = $state<{ let deletionInfo = $state<{
totalCount: number; totalCount: number;
userMessages: number; userMessages: number;
@@ -70,45 +44,51 @@
let shouldBranchAfterEdit = $state(false); let shouldBranchAfterEdit = $state(false);
let textareaElement: HTMLTextAreaElement | undefined = $state(); let textareaElement: HTMLTextAreaElement | undefined = $state();
let thinkingContent = $derived.by(() => { let showSaveOnlyOption = $derived(message.role === MessageRole.USER);
if (message.role === 'assistant') {
const trimmedThinking = message.thinking?.trim();
return trimmedThinking ? trimmedThinking : null; setMessageEditContext({
} get isEditing() {
return null; return isEditing;
},
get editedContent() {
return editedContent;
},
get editedExtras() {
return editedExtras;
},
get editedUploadedFiles() {
return editedUploadedFiles;
},
get originalContent() {
return message.content;
},
get originalExtras() {
return message.extra || [];
},
get showSaveOnlyOption() {
return showSaveOnlyOption;
},
setContent: (content: string) => {
editedContent = content;
},
setExtras: (extras: DatabaseMessageExtra[]) => {
editedExtras = extras;
},
setUploadedFiles: (files: ChatUploadedFile[]) => {
editedUploadedFiles = files;
},
save: handleSaveEdit,
saveOnly: handleSaveEditOnly,
cancel: handleCancelEdit,
startEdit: handleEdit
}); });
let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
if (message.role === 'assistant') {
const trimmedToolCalls = message.toolCalls?.trim();
if (!trimmedToolCalls) {
return null;
}
try {
const parsed = JSON.parse(trimmedToolCalls);
if (Array.isArray(parsed)) {
return parsed as ApiChatCompletionToolCall[];
}
} catch {
// Harmony-only path: fall back to the raw string so issues surface visibly.
}
return trimmedToolCalls;
}
return null;
});
// Auto-start edit mode if this message is the pending edit target
$effect(() => { $effect(() => {
const pendingId = pendingEditMessageId(); const pendingId = pendingEditMessageId();
if (pendingId && pendingId === message.id && !isEditing) { if (pendingId && pendingId === message.id && !isEditing) {
handleEdit(); handleEdit();
clearPendingEditMessageId(); chatStore.clearPendingEditMessageId();
} }
}); });
@@ -116,8 +96,8 @@
isEditing = false; isEditing = false;
// If canceling a new system message with placeholder content, remove it without deleting children // If canceling a new system message with placeholder content, remove it without deleting children
if (message.role === 'system') { if (message.role === MessageRole.SYSTEM) {
const conversationDeleted = await removeSystemPromptPlaceholder(message.id); const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) { if (conversationDeleted) {
goto(`${base}/`); goto(`${base}/`);
@@ -131,30 +111,19 @@
editedUploadedFiles = []; editedUploadedFiles = [];
} }
function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) { function handleCopy() {
editedExtras = extras; chatActions.copy(message);
}
function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
editedUploadedFiles = files;
}
async function handleCopy() {
const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
onCopy?.(message);
} }
async function handleConfirmDelete() { async function handleConfirmDelete() {
if (message.role === 'system') { if (message.role === MessageRole.SYSTEM) {
const conversationDeleted = await removeSystemPromptPlaceholder(message.id); const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) { if (conversationDeleted) {
goto('/'); goto(`${base}/`);
} }
} else { } else {
onDelete?.(message); chatActions.delete(message);
} }
showDeleteDialog = false; showDeleteDialog = false;
@@ -167,9 +136,9 @@
function handleEdit() { function handleEdit() {
isEditing = true; isEditing = true;
// Clear placeholder content for system messages // Clear temporary placeholder content for system messages
editedContent = editedContent =
message.role === 'system' && message.content === SYSTEM_MESSAGE_PLACEHOLDER message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER
? '' ? ''
: message.content; : message.content;
textareaElement?.focus(); textareaElement?.focus();
@@ -187,38 +156,26 @@
}, 0); }, 0);
} }
function handleEditedContentChange(content: string) {
editedContent = content;
}
function handleEditKeydown(event: KeyboardEvent) {
// Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
// This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault();
handleSaveEdit();
} else if (event.key === 'Escape') {
event.preventDefault();
handleCancelEdit();
}
}
function handleRegenerate(modelOverride?: string) { function handleRegenerate(modelOverride?: string) {
onRegenerateWithBranching?.(message, modelOverride); chatActions.regenerateWithBranching(message, modelOverride);
} }
function handleContinue() { function handleContinue() {
onContinueAssistantMessage?.(message); chatActions.continueAssistantMessage(message);
}
function handleNavigateToSibling(siblingId: string) {
chatActions.navigateToSibling(siblingId);
} }
async function handleSaveEdit() { async function handleSaveEdit() {
if (message.role === 'system') { if (message.role === MessageRole.SYSTEM) {
// System messages: update in place without branching // System messages: update in place without branching
const newContent = editedContent.trim(); const newContent = editedContent.trim();
// If content is empty or still the placeholder, remove without deleting children // If content is empty, remove without deleting children
if (!newContent) { if (!newContent) {
const conversationDeleted = await removeSystemPromptPlaceholder(message.id); const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
isEditing = false; isEditing = false;
if (conversationDeleted) { if (conversationDeleted) {
goto(`${base}/`); goto(`${base}/`);
@@ -231,13 +188,13 @@
if (index !== -1) { if (index !== -1) {
conversationsStore.updateMessageAtIndex(index, { content: newContent }); conversationsStore.updateMessageAtIndex(index, { content: newContent });
} }
} else if (message.role === 'user') { } else if (message.role === MessageRole.USER) {
const finalExtras = await getMergedExtras(); const finalExtras = await getMergedExtras();
onEditWithBranching?.(message, editedContent.trim(), finalExtras); chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
} else { } else {
// For assistant messages, preserve exact content including trailing whitespace // For assistant messages, preserve exact content including trailing whitespace
// This is important for the Continue feature to work properly // This is important for the Continue feature to work properly
onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit); chatActions.editWithReplacement(message, editedContent, shouldBranchAfterEdit);
} }
isEditing = false; isEditing = false;
@@ -246,10 +203,10 @@
} }
async function handleSaveEditOnly() { async function handleSaveEditOnly() {
if (message.role === 'user') { if (message.role === MessageRole.USER) {
// For user messages, trim to avoid accidental whitespace // For user messages, trim to avoid accidental whitespace
const finalExtras = await getMergedExtras(); const finalExtras = await getMergedExtras();
onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras); chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
} }
isEditing = false; isEditing = false;
@@ -261,8 +218,8 @@
return editedExtras; return editedExtras;
} }
const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only'); const plainFiles = $state.snapshot(editedUploadedFiles);
const result = await parseFilesToMessageExtras(editedUploadedFiles); const result = await parseFilesToMessageExtras(plainFiles);
const newExtras = result?.extras || []; const newExtras = result?.extras || [];
return [...editedExtras, ...newExtras]; return [...editedExtras, ...newExtras];
@@ -273,49 +230,31 @@
} }
</script> </script>
{#if message.role === 'system'} {#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem <ChatMessageSystem
bind:textareaElement bind:textareaElement
class={className} class={className}
{deletionInfo} {deletionInfo}
{editedContent}
{isEditing}
{message} {message}
onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete} onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy} onCopy={handleCopy}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={handleEdit} onEdit={handleEdit}
onEditKeydown={handleEditKeydown} onNavigateToSibling={handleNavigateToSibling}
onEditedContentChange={handleEditedContentChange}
{onNavigateToSibling}
onSaveEdit={handleSaveEdit}
onShowDeleteDialogChange={handleShowDeleteDialogChange} onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog} {showDeleteDialog}
{siblingInfo} {siblingInfo}
/> />
{:else if message.role === 'user'} {:else if message.role === MessageRole.USER}
<ChatMessageUser <ChatMessageUser
bind:textareaElement
class={className} class={className}
{deletionInfo} {deletionInfo}
{editedContent}
{editedExtras}
{editedUploadedFiles}
{isEditing}
{message} {message}
onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete} onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy} onCopy={handleCopy}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={handleEdit} onEdit={handleEdit}
onEditKeydown={handleEditKeydown} onNavigateToSibling={handleNavigateToSibling}
onEditedContentChange={handleEditedContentChange}
onEditedExtrasChange={handleEditedExtrasChange}
onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
{onNavigateToSibling}
onSaveEdit={handleSaveEdit}
onSaveEditOnly={handleSaveEditOnly}
onShowDeleteDialogChange={handleShowDeleteDialogChange} onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog} {showDeleteDialog}
{siblingInfo} {siblingInfo}
@@ -325,27 +264,18 @@
bind:textareaElement bind:textareaElement
class={className} class={className}
{deletionInfo} {deletionInfo}
{editedContent} {isLastAssistantMessage}
{isEditing}
{message} {message}
messageContent={message.content} messageContent={message.content}
onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete} onConfirmDelete={handleConfirmDelete}
onContinue={handleContinue} onContinue={handleContinue}
onCopy={handleCopy} onCopy={handleCopy}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={handleEdit} onEdit={handleEdit}
onEditKeydown={handleEditKeydown} onNavigateToSibling={handleNavigateToSibling}
onEditedContentChange={handleEditedContentChange}
{onNavigateToSibling}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
onSaveEdit={handleSaveEdit}
onShowDeleteDialogChange={handleShowDeleteDialogChange} onShowDeleteDialogChange={handleShowDeleteDialogChange}
{shouldBranchAfterEdit}
onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
{showDeleteDialog} {showDeleteDialog}
{siblingInfo} {siblingInfo}
{thinkingContent}
{toolCallContent}
/> />
{/if} {/if}
@@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte'; import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
import { import {
ActionButton, ActionIcon,
ChatMessageBranchingControls, ChatMessageBranchingControls,
DialogConfirmation DialogConfirmation
} from '$lib/components/app'; } from '$lib/components/app';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import { MessageRole } from '$lib/enums';
interface Props { interface Props {
role: 'user' | 'assistant'; role: MessageRole.USER | MessageRole.ASSISTANT;
justify: 'start' | 'end'; justify: 'start' | 'end';
actionsPosition: 'left' | 'right'; actionsPosition: 'left' | 'right';
siblingInfo?: ChatMessageSiblingInfo | null; siblingInfo?: ChatMessageSiblingInfo | null;
@@ -71,21 +72,21 @@
<div <div
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150" class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
> >
<ActionButton icon={Copy} tooltip="Copy" onclick={onCopy} /> <ActionIcon icon={Copy} tooltip="Copy" onclick={onCopy} />
{#if onEdit} {#if onEdit}
<ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} /> <ActionIcon icon={Edit} tooltip="Edit" onclick={onEdit} />
{/if} {/if}
{#if role === 'assistant' && onRegenerate} {#if role === MessageRole.ASSISTANT && onRegenerate}
<ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} /> <ActionIcon icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
{/if} {/if}
{#if role === 'assistant' && onContinue} {#if role === MessageRole.ASSISTANT && onContinue}
<ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} /> <ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
{/if} {/if}
<ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} /> <ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
</div> </div>
</div> </div>
@@ -1,26 +1,29 @@
<script lang="ts"> <script lang="ts">
import { import {
ModelBadge,
ChatMessageActions, ChatMessageActions,
ChatMessageStatistics, ChatMessageStatistics,
ChatMessageThinkingBlock,
CopyToClipboardIcon,
MarkdownContent, MarkdownContent,
ModelBadge,
ModelsSelector ModelsSelector
} from '$lib/components/app'; } from '$lib/components/app';
import ChatMessageThinkingBlock from './ChatMessageThinkingBlock.svelte';
import { getMessageEditContext } from '$lib/contexts';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte'; import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { isLoading } from '$lib/stores/chat.svelte'; import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
import { autoResizeTextarea, copyToClipboard } from '$lib/utils'; import { tick } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { Check, X, Wrench } from '@lucide/svelte'; import { Check, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { INPUT_CLASSES } from '$lib/constants/input-classes'; import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { MessageRole, KeyboardKey } from '$lib/enums';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte'; import { isRouterMode } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums';
import { REASONING_TAGS } from '$lib/constants/agentic';
interface Props { interface Props {
class?: string; class?: string;
@@ -30,153 +33,198 @@
assistantMessages: number; assistantMessages: number;
messageTypes: string[]; messageTypes: string[];
} | null; } | null;
editedContent?: string; isLastAssistantMessage?: boolean;
isEditing?: boolean;
message: DatabaseMessage; message: DatabaseMessage;
messageContent: string | undefined; messageContent: string | undefined;
onCancelEdit?: () => void;
onCopy: () => void; onCopy: () => void;
onConfirmDelete: () => void; onConfirmDelete: () => void;
onContinue?: () => void; onContinue?: () => void;
onDelete: () => void; onDelete: () => void;
onEdit?: () => void; onEdit?: () => void;
onEditKeydown?: (event: KeyboardEvent) => void;
onEditedContentChange?: (content: string) => void;
onNavigateToSibling?: (siblingId: string) => void; onNavigateToSibling?: (siblingId: string) => void;
onRegenerate: (modelOverride?: string) => void; onRegenerate: (modelOverride?: string) => void;
onSaveEdit?: () => void;
onShowDeleteDialogChange: (show: boolean) => void; onShowDeleteDialogChange: (show: boolean) => void;
onShouldBranchAfterEditChange?: (value: boolean) => void;
showDeleteDialog: boolean; showDeleteDialog: boolean;
shouldBranchAfterEdit?: boolean;
siblingInfo?: ChatMessageSiblingInfo | null; siblingInfo?: ChatMessageSiblingInfo | null;
textareaElement?: HTMLTextAreaElement; textareaElement?: HTMLTextAreaElement;
thinkingContent: string | null; }
toolCallContent: ApiChatCompletionToolCall[] | string | null;
interface ParsedReasoningContent {
content: string;
reasoningContent: string | null;
hasReasoningMarkers: boolean;
}
function parseReasoningContent(content: string | undefined): ParsedReasoningContent {
if (!content) {
return {
content: '',
reasoningContent: null,
hasReasoningMarkers: false
};
}
const plainParts: string[] = [];
const reasoningParts: string[] = [];
const { START, END } = REASONING_TAGS;
let cursor = 0;
let hasReasoningMarkers = false;
while (cursor < content.length) {
const startIndex = content.indexOf(START, cursor);
if (startIndex === -1) {
plainParts.push(content.slice(cursor));
break;
}
hasReasoningMarkers = true;
plainParts.push(content.slice(cursor, startIndex));
const reasoningStart = startIndex + START.length;
const endIndex = content.indexOf(END, reasoningStart);
if (endIndex === -1) {
reasoningParts.push(content.slice(reasoningStart));
cursor = content.length;
break;
}
reasoningParts.push(content.slice(reasoningStart, endIndex));
cursor = endIndex + END.length;
}
return {
content: plainParts.join(''),
reasoningContent: reasoningParts.length > 0 ? reasoningParts.join('\n\n') : null,
hasReasoningMarkers
};
} }
let { let {
class: className = '', class: className = '',
deletionInfo, deletionInfo,
editedContent = '', isLastAssistantMessage = false,
isEditing = false,
message, message,
messageContent, messageContent,
onCancelEdit,
onConfirmDelete, onConfirmDelete,
onContinue, onContinue,
onCopy, onCopy,
onDelete, onDelete,
onEdit, onEdit,
onEditKeydown,
onEditedContentChange,
onNavigateToSibling, onNavigateToSibling,
onRegenerate, onRegenerate,
onSaveEdit,
onShowDeleteDialogChange, onShowDeleteDialogChange,
onShouldBranchAfterEditChange,
showDeleteDialog, showDeleteDialog,
shouldBranchAfterEdit = false,
siblingInfo = null, siblingInfo = null,
textareaElement = $bindable(), textareaElement = $bindable()
thinkingContent,
toolCallContent = null
}: Props = $props(); }: Props = $props();
const toolCalls = $derived( // Get edit context
Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null const editCtx = getMessageEditContext();
);
const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
// Local state for assistant-specific editing
let shouldBranchAfterEdit = $state(false);
function handleEditKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault();
editCtx.save();
} else if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
editCtx.cancel();
}
}
const parsedMessageContent = $derived.by(() => parseReasoningContent(messageContent));
const visibleMessageContent = $derived(parsedMessageContent.content);
const thinkingContent = $derived(parsedMessageContent.reasoningContent);
const hasReasoningMarkers = $derived(parsedMessageContent.hasReasoningMarkers);
const processingState = useProcessingState(); const processingState = useProcessingState();
// Local state for raw output toggle (per message)
let showRawOutput = $state(false);
let currentConfig = $derived(config()); let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode()); let isRouter = $derived(isRouterMode());
let displayedModel = $derived((): string | null => { let showRawOutput = $state(false);
if (message.model) { let statsContainerEl: HTMLDivElement | undefined = $state();
return message.model;
function getScrollParent(el: HTMLElement): HTMLElement | null {
let parent = el.parentElement;
while (parent) {
const style = getComputedStyle(parent);
if (/(auto|scroll)/.test(style.overflowY)) {
return parent;
}
parent = parent.parentElement;
}
return null;
}
async function handleStatsViewChange() {
const el = statsContainerEl;
if (!el) {
return;
} }
return null; const scrollParent = getScrollParent(el);
}); if (!scrollParent) {
return;
}
const { handleModelChange } = useModelChangeValidation({ const yBefore = el.getBoundingClientRect().top;
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
onSuccess: (modelName: string) => onRegenerate(modelName) await tick();
});
const delta = el.getBoundingClientRect().top - yBefore;
if (delta !== 0) {
scrollParent.scrollTop += delta;
}
// Correct any drift after browser paint
requestAnimationFrame(() => {
const drift = el.getBoundingClientRect().top - yBefore;
if (Math.abs(drift) > 1) {
scrollParent.scrollTop += drift;
}
});
}
let displayedModel = $derived(message.model ?? null);
let isCurrentlyLoading = $derived(isLoading());
let isStreaming = $derived(isChatStreaming());
let hasNoContent = $derived(!visibleMessageContent?.trim());
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
let showProcessingInfoTop = $derived(
message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing &&
hasNoContent &&
isLastAssistantMessage
);
let showProcessingInfoBottom = $derived(
message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing &&
!hasNoContent &&
isLastAssistantMessage
);
function handleCopyModel() { function handleCopyModel() {
const model = displayedModel(); void copyToClipboard(displayedModel ?? '');
void copyToClipboard(model ?? '');
} }
$effect(() => { $effect(() => {
if (isEditing && textareaElement) { if (editCtx.isEditing && textareaElement) {
autoResizeTextarea(textareaElement); autoResizeTextarea(textareaElement);
} }
}); });
$effect(() => { $effect(() => {
if (isLoading() && !message?.content?.trim()) { if (showProcessingInfoTop || showProcessingInfoBottom) {
processingState.startMonitoring(); processingState.startMonitoring();
} }
}); });
function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
const callNumber = index + 1;
const functionName = toolCall.function?.name?.trim();
const label = functionName || `Call #${callNumber}`;
const payload: Record<string, unknown> = {};
const id = toolCall.id?.trim();
if (id) {
payload.id = id;
}
const type = toolCall.type?.trim();
if (type) {
payload.type = type;
}
if (toolCall.function) {
const fnPayload: Record<string, unknown> = {};
const name = toolCall.function.name?.trim();
if (name) {
fnPayload.name = name;
}
const rawArguments = toolCall.function.arguments?.trim();
if (rawArguments) {
try {
fnPayload.arguments = JSON.parse(rawArguments);
} catch {
fnPayload.arguments = rawArguments;
}
}
if (Object.keys(fnPayload).length > 0) {
payload.function = fnPayload;
}
}
const formattedPayload = JSON.stringify(payload, null, 2);
return {
label,
tooltip: formattedPayload,
copyValue: formattedPayload
};
}
function handleCopyToolCall(payload: string) {
void copyToClipboard(payload, 'Tool call copied to clipboard');
}
</script> </script>
<div <div
@@ -184,34 +232,36 @@
role="group" role="group"
aria-label="Assistant message with actions" aria-label="Assistant message with actions"
> >
{#if thinkingContent} {#if !editCtx.isEditing && thinkingContent}
<ChatMessageThinkingBlock <ChatMessageThinkingBlock
reasoningContent={thinkingContent} reasoningContent={thinkingContent}
isStreaming={!message.timestamp} isStreaming={!message.timestamp}
hasRegularContent={!!messageContent?.trim()} hasRegularContent={!!visibleMessageContent?.trim()}
/> />
{/if} {/if}
{#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()} {#if showProcessingInfoTop}
<div class="mt-6 w-full max-w-[48rem]" in:fade> <div class="mt-6 w-full max-w-[48rem]" in:fade>
<div class="processing-container"> <div class="processing-container">
<span class="processing-text"> <span class="processing-text">
{processingState.getPromptProgressText() ?? processingState.getProcessingMessage()} {processingState.getPromptProgressText() ??
processingState.getProcessingMessage() ??
'Processing...'}
</span> </span>
</div> </div>
</div> </div>
{/if} {/if}
{#if isEditing} {#if editCtx.isEditing}
<div class="w-full"> <div class="w-full">
<textarea <textarea
bind:this={textareaElement} bind:this={textareaElement}
bind:value={editedContent} value={editCtx.editedContent}
class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}" class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
onkeydown={onEditKeydown} onkeydown={handleEditKeydown}
oninput={(e) => { oninput={(e) => {
autoResizeTextarea(e.currentTarget); autoResizeTextarea(e.currentTarget);
onEditedContentChange?.(e.currentTarget.value); editCtx.setContent(e.currentTarget.value);
}} }}
placeholder="Edit assistant message..." placeholder="Edit assistant message..."
></textarea> ></textarea>
@@ -221,30 +271,35 @@
<Checkbox <Checkbox
id="branch-after-edit" id="branch-after-edit"
bind:checked={shouldBranchAfterEdit} bind:checked={shouldBranchAfterEdit}
onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)} onCheckedChange={(checked) => (shouldBranchAfterEdit = checked === true)}
/> />
<Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground"> <Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
Branch conversation after edit Branch conversation after edit
</Label> </Label>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline"> <Button class="h-8 px-3" onclick={editCtx.cancel} size="sm" variant="outline">
<X class="mr-1 h-3 w-3" /> <X class="mr-1 h-3 w-3" />
Cancel Cancel
</Button> </Button>
<Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm"> <Button
class="h-8 px-3"
onclick={editCtx.save}
disabled={!editCtx.editedContent?.trim()}
size="sm"
>
<Check class="mr-1 h-3 w-3" /> <Check class="mr-1 h-3 w-3" />
Save Save
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
{:else if message.role === 'assistant'} {:else if message.role === MessageRole.ASSISTANT}
{#if showRawOutput} {#if showRawOutput}
<pre class="raw-output">{messageContent || ''}</pre> <pre class="raw-output">{messageContent || ''}</pre>
{:else} {:else}
<MarkdownContent content={messageContent || ''} /> <MarkdownContent content={visibleMessageContent || ''} attachments={message.extra} />
{/if} {/if}
{:else} {:else}
<div class="text-sm whitespace-pre-wrap"> <div class="text-sm whitespace-pre-wrap">
@@ -252,18 +307,41 @@
</div> </div>
{/if} {/if}
{#if showProcessingInfoBottom}
<div class="mt-4 w-full max-w-[48rem]" in:fade>
<div class="processing-container">
<span class="processing-text">
{processingState.getPromptProgressText() ??
processingState.getProcessingMessage() ??
'Processing...'}
</span>
</div>
</div>
{/if}
<div class="info my-6 grid gap-4 tabular-nums"> <div class="info my-6 grid gap-4 tabular-nums">
{#if displayedModel()} {#if displayedModel}
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"> <div
bind:this={statsContainerEl}
class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
>
{#if isRouter} {#if isRouter}
<ModelsSelector <ModelsSelector
currentModel={displayedModel()} currentModel={displayedModel}
onModelChange={handleModelChange}
disabled={isLoading()} disabled={isLoading()}
upToMessageId={message.id} onModelChange={async (modelId, modelName) => {
const status = modelsStore.getModelStatus(modelId);
if (status !== ServerModelStatus.LOADED) {
await modelsStore.loadModel(modelId);
}
onRegenerate(modelName);
return true;
}}
/> />
{:else} {:else}
<ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} /> <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
{/if} {/if}
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms} {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
@@ -272,6 +350,7 @@
promptMs={message.timings.prompt_ms} promptMs={message.timings.prompt_ms}
predictedTokens={message.timings.predicted_n} predictedTokens={message.timings.predicted_n}
predictedMs={message.timings.predicted_ms} predictedMs={message.timings.predicted_ms}
onActiveViewChange={handleStatsViewChange}
/> />
{:else if isLoading() && currentConfig.showMessageStats} {:else if isLoading() && currentConfig.showMessageStats}
{@const liveStats = processingState.getLiveProcessingStats()} {@const liveStats = processingState.getLiveProcessingStats()}
@@ -293,53 +372,11 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if config().showToolCalls}
{#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
<span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1">
<Wrench class="h-3.5 w-3.5" />
<span>Tool calls:</span>
</span>
{#if toolCalls && toolCalls.length > 0}
{#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
{@const badge = formatToolCallBadge(toolCall, index)}
<button
type="button"
class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
title={badge.tooltip}
aria-label={`Copy tool call ${badge.label}`}
onclick={() => handleCopyToolCall(badge.copyValue)}
>
{badge.label}
<CopyToClipboardIcon
text={badge.copyValue}
ariaLabel={`Copy tool call ${badge.label}`}
/>
</button>
{/each}
{:else if fallbackToolCalls}
<button
type="button"
class="tool-call-badge tool-call-badge--fallback inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
title={fallbackToolCalls}
aria-label="Copy tool call payload"
onclick={() => handleCopyToolCall(fallbackToolCalls)}
>
{fallbackToolCalls}
<CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" />
</button>
{/if}
</span>
{/if}
{/if}
</div> </div>
{#if message.timestamp && !isEditing} {#if message.timestamp && !editCtx.isEditing}
<ChatMessageActions <ChatMessageActions
role="assistant" role={MessageRole.ASSISTANT}
justify="start" justify="start"
actionsPosition="left" actionsPosition="left"
{siblingInfo} {siblingInfo}
@@ -348,7 +385,7 @@
{onCopy} {onCopy}
{onEdit} {onEdit}
{onRegenerate} {onRegenerate}
onContinue={currentConfig.enableContinueGeneration && !thinkingContent onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
? onContinue ? onContinue
: undefined} : undefined}
{onDelete} {onDelete}
@@ -408,17 +445,4 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.tool-call-badge {
max-width: 12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-call-badge--fallback {
max-width: 20rem;
white-space: normal;
word-break: break-word;
}
</style> </style>
@@ -1,79 +1,26 @@
<script lang="ts"> <script lang="ts">
import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte'; import { X, AlertTriangle } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app'; import { ChatForm, DialogConfirmation } from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/input-classes'; import { getMessageEditContext } from '$lib/contexts';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; import { KeyboardKey } from '$lib/enums';
import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums'; import { chatStore } from '$lib/stores/chat.svelte';
import { config } from '$lib/stores/settings.svelte'; import { processFilesToChatUploaded } from '$lib/utils/browser-only';
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import {
autoResizeTextarea,
getFileTypeCategory,
getFileTypeCategoryByExtension,
parseClipboardContent
} from '$lib/utils';
interface Props { const editCtx = getMessageEditContext();
messageId: string;
editedContent: string;
editedExtras?: DatabaseMessageExtra[];
editedUploadedFiles?: ChatUploadedFile[];
originalContent: string;
originalExtras?: DatabaseMessageExtra[];
showSaveOnlyOption?: boolean;
onCancelEdit: () => void;
onSaveEdit: () => void;
onSaveEditOnly?: () => void;
onEditKeydown: (event: KeyboardEvent) => void;
onEditedContentChange: (content: string) => void;
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
textareaElement?: HTMLTextAreaElement;
}
let { let inputAreaRef: ChatForm | undefined = $state(undefined);
messageId,
editedContent,
editedExtras = [],
editedUploadedFiles = [],
originalContent,
originalExtras = [],
showSaveOnlyOption = false,
onCancelEdit,
onSaveEdit,
onSaveEditOnly,
onEditKeydown,
onEditedContentChange,
onEditedExtrasChange,
onEditedUploadedFilesChange,
textareaElement = $bindable()
}: Props = $props();
let fileInputElement: HTMLInputElement | undefined = $state();
let saveWithoutRegenerate = $state(false); let saveWithoutRegenerate = $state(false);
let showDiscardDialog = $state(false); let showDiscardDialog = $state(false);
let isRouter = $derived(isRouterMode());
let currentConfig = $derived(config());
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
let hasUnsavedChanges = $derived.by(() => { let hasUnsavedChanges = $derived.by(() => {
if (editedContent !== originalContent) return true; if (editCtx.editedContent !== editCtx.originalContent) return true;
if (editedUploadedFiles.length > 0) return true; if (editCtx.editedUploadedFiles.length > 0) return true;
const extrasChanged = const extrasChanged =
editedExtras.length !== originalExtras.length || editCtx.editedExtras.length !== editCtx.originalExtras.length ||
editedExtras.some((extra, i) => extra !== originalExtras[i]); editCtx.editedExtras.some((extra, i) => extra !== editCtx.originalExtras[i]);
if (extrasChanged) return true; if (extrasChanged) return true;
@@ -81,77 +28,14 @@
}); });
let hasAttachments = $derived( let hasAttachments = $derived(
(editedExtras && editedExtras.length > 0) || (editCtx.editedExtras && editCtx.editedExtras.length > 0) ||
(editedUploadedFiles && editedUploadedFiles.length > 0) (editCtx.editedUploadedFiles && editCtx.editedUploadedFiles.length > 0)
); );
let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments); let canSubmit = $derived(editCtx.editedContent.trim().length > 0 || hasAttachments);
function getEditedAttachmentsModalities(): ModelModalities {
const modalities: ModelModalities = { vision: false, audio: false };
for (const extra of editedExtras) {
if (extra.type === AttachmentType.IMAGE) {
modalities.vision = true;
}
if (
extra.type === AttachmentType.PDF &&
'processedAsImages' in extra &&
extra.processedAsImages
) {
modalities.vision = true;
}
if (extra.type === AttachmentType.AUDIO) {
modalities.audio = true;
}
}
for (const file of editedUploadedFiles) {
const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
if (category === FileTypeCategory.IMAGE) {
modalities.vision = true;
}
if (category === FileTypeCategory.AUDIO) {
modalities.audio = true;
}
}
return modalities;
}
function getRequiredModalities(): ModelModalities {
const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
const editedModalities = getEditedAttachmentsModalities();
return {
vision: beforeModalities.vision || editedModalities.vision,
audio: beforeModalities.audio || editedModalities.audio
};
}
const { handleModelChange } = useModelChangeValidation({
getRequiredModalities,
onValidationFailure: async (previousModelId: string | null) => {
if (previousModelId) {
await modelsStore.selectModelById(previousModelId);
}
}
});
function handleFileInputChange(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const files = Array.from(input.files);
processNewFiles(files);
input.value = '';
}
function handleGlobalKeydown(event: KeyboardEvent) { function handleGlobalKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault(); event.preventDefault();
attemptCancel(); attemptCancel();
} }
@@ -161,205 +45,66 @@
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
showDiscardDialog = true; showDiscardDialog = true;
} else { } else {
onCancelEdit(); editCtx.cancel();
} }
} }
function handleRemoveExistingAttachment(index: number) {
if (!onEditedExtrasChange) return;
const newExtras = [...editedExtras];
newExtras.splice(index, 1);
onEditedExtrasChange(newExtras);
}
function handleRemoveUploadedFile(fileId: string) {
if (!onEditedUploadedFilesChange) return;
const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
onEditedUploadedFilesChange(newFiles);
}
function handleSubmit() { function handleSubmit() {
if (!canSubmit) return; if (!canSubmit) return;
if (saveWithoutRegenerate && onSaveEditOnly) { if (saveWithoutRegenerate && editCtx.showSaveOnlyOption) {
onSaveEditOnly(); editCtx.saveOnly();
} else { } else {
onSaveEdit(); editCtx.save();
} }
saveWithoutRegenerate = false; saveWithoutRegenerate = false;
} }
async function processNewFiles(files: File[]) { function handleAttachmentRemove(index: number) {
if (!onEditedUploadedFilesChange) return; const newExtras = [...editCtx.editedExtras];
newExtras.splice(index, 1);
editCtx.setExtras(newExtras);
}
const { processFilesToChatUploaded } = await import('$lib/utils/browser-only'); function handleUploadedFileRemove(fileId: string) {
const newFiles = editCtx.editedUploadedFiles.filter((f) => f.id !== fileId);
editCtx.setUploadedFiles(newFiles);
}
async function handleFilesAdd(files: File[]) {
const processed = await processFilesToChatUploaded(files); const processed = await processFilesToChatUploaded(files);
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
processNewFiles(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith('"')) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) {
event.preventDefault();
onEditedContentChange(parsed.message);
const attachmentFiles = parsed.textAttachments.map(
(att) =>
new File([att.content], att.name, {
type: MimeTypeText.PLAIN
})
);
processNewFiles(attachmentFiles);
setTimeout(() => {
textareaElement?.focus();
}, 10);
return;
}
}
if (
text.length > 0 &&
pasteLongTextToFileLength > 0 &&
text.length > pasteLongTextToFileLength
) {
event.preventDefault();
const textFile = new File([text], 'Pasted', {
type: MimeTypeText.PLAIN
});
processNewFiles([textFile]);
}
} }
$effect(() => { $effect(() => {
if (textareaElement) { chatStore.setEditModeActive(handleFilesAdd);
autoResizeTextarea(textareaElement);
}
});
$effect(() => {
setEditModeActive(processNewFiles);
return () => { return () => {
clearEditMode(); chatStore.clearEditMode();
}; };
}); });
</script> </script>
<svelte:window onkeydown={handleGlobalKeydown} /> <svelte:window onkeydown={handleGlobalKeydown} />
<input <div class="relative w-full max-w-[80%]">
bind:this={fileInputElement} <ChatForm
type="file" bind:this={inputAreaRef}
multiple value={editCtx.editedContent}
class="hidden" attachments={editCtx.editedExtras}
onchange={handleFileInputChange} uploadedFiles={editCtx.editedUploadedFiles}
/> placeholder="Edit your message..."
onValueChange={editCtx.setContent}
<div onAttachmentRemove={handleAttachmentRemove}
class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md" onUploadedFileRemove={handleUploadedFileRemove}
data-slot="edit-form" onFilesAdd={handleFilesAdd}
> onSubmit={handleSubmit}
<ChatAttachmentsList
attachments={editedExtras}
uploadedFiles={editedUploadedFiles}
readonly={false}
onFileRemove={(fileId) => {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
handleRemoveExistingAttachment(index);
}
} else {
handleRemoveUploadedFile(fileId);
}
}}
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
/> />
<div class="relative min-h-[48px] px-5 py-3">
<textarea
bind:this={textareaElement}
bind:value={editedContent}
class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
onkeydown={onEditKeydown}
oninput={(e) => {
autoResizeTextarea(e.currentTarget);
onEditedContentChange(e.currentTarget.value);
}}
onpaste={handlePaste}
placeholder="Edit your message..."
></textarea>
<div class="flex w-full items-center gap-3" style="container-type: inline-size">
<Button
class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
onclick={() => fileInputElement?.click()}
type="button"
title="Add attachment"
>
<span class="sr-only">Attach files</span>
<Paperclip class="h-4 w-4" />
</Button>
<div class="flex-1"></div>
{#if isRouter}
<ModelsSelector
forceForegroundText={true}
useGlobalSelection={true}
onModelChange={handleModelChange}
/>
{/if}
<Button
class="h-8 w-8 shrink-0 rounded-full p-0"
onclick={handleSubmit}
disabled={!canSubmit}
type="button"
title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
>
<span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
<ArrowUp class="h-5 w-5" />
</Button>
</div>
</div>
</div> </div>
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between"> <div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
{#if showSaveOnlyOption && onSaveEditOnly} {#if editCtx.showSaveOnlyOption}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" /> <Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
@@ -386,6 +131,6 @@
cancelText="Keep editing" cancelText="Keep editing"
variant="destructive" variant="destructive"
icon={AlertTriangle} icon={AlertTriangle}
onConfirm={onCancelEdit} onConfirm={editCtx.cancel}
onCancel={() => (showDiscardDialog = false)} onCancel={() => (showDiscardDialog = false)}
/> />
@@ -3,19 +3,18 @@
import { BadgeChatStatistic } from '$lib/components/app'; import { BadgeChatStatistic } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums'; import { ChatMessageStatsView } from '$lib/enums';
import { formatPerformanceTime } from '$lib/utils/formatters'; import { formatPerformanceTime } from '$lib/utils';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants/formatters';
interface Props { interface Props {
predictedTokens?: number; predictedTokens?: number;
predictedMs?: number; predictedMs?: number;
promptTokens?: number; promptTokens?: number;
promptMs?: number; promptMs?: number;
// Live mode: when true, shows stats during streaming
isLive?: boolean; isLive?: boolean;
// Whether prompt processing is still in progress
isProcessingPrompt?: boolean; isProcessingPrompt?: boolean;
// Initial view to show (defaults to READING in live mode)
initialView?: ChatMessageStatsView; initialView?: ChatMessageStatsView;
onActiveViewChange?: (view: ChatMessageStatsView) => void;
} }
let { let {
@@ -25,12 +24,17 @@
promptMs, promptMs,
isLive = false, isLive = false,
isProcessingPrompt = false, isProcessingPrompt = false,
initialView = ChatMessageStatsView.GENERATION initialView = ChatMessageStatsView.GENERATION,
onActiveViewChange
}: Props = $props(); }: Props = $props();
let activeView: ChatMessageStatsView = $derived(initialView); let activeView: ChatMessageStatsView = $derived(initialView);
let hasAutoSwitchedToGeneration = $state(false); let hasAutoSwitchedToGeneration = $state(false);
$effect(() => {
onActiveViewChange?.(activeView);
});
// In live mode: auto-switch to GENERATION tab when prompt processing completes // In live mode: auto-switch to GENERATION tab when prompt processing completes
$effect(() => { $effect(() => {
if (isLive) { if (isLive) {
@@ -57,14 +61,16 @@
predictedMs > 0 predictedMs > 0
); );
let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0); let tokensPerSecond = $derived(
hasGenerationStats ? (predictedTokens! / predictedMs!) * MS_PER_SECOND : 0
);
let formattedTime = $derived( let formattedTime = $derived(
predictedMs !== undefined ? formatPerformanceTime(predictedMs) : '0s' predictedMs !== undefined ? formatPerformanceTime(predictedMs) : DEFAULT_PERFORMANCE_TIME
); );
let promptTokensPerSecond = $derived( let promptTokensPerSecond = $derived(
promptTokens !== undefined && promptMs !== undefined && promptMs > 0 promptTokens !== undefined && promptMs !== undefined && promptMs > 0
? (promptTokens / promptMs) * 1000 ? (promptTokens / promptMs) * MS_PER_SECOND
: undefined : undefined
); );
@@ -97,9 +103,11 @@
onclick={() => (activeView = ChatMessageStatsView.READING)} onclick={() => (activeView = ChatMessageStatsView.READING)}
> >
<BookOpenText class="h-3 w-3" /> <BookOpenText class="h-3 w-3" />
<span class="sr-only">Reading</span> <span class="sr-only">Reading</span>
</button> </button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
<p>Reading (prompt processing)</p> <p>Reading (prompt processing)</p>
</Tooltip.Content> </Tooltip.Content>
@@ -119,9 +127,11 @@
disabled={isGenerationDisabled} disabled={isGenerationDisabled}
> >
<Sparkles class="h-3 w-3" /> <Sparkles class="h-3 w-3" />
<span class="sr-only">Generation</span> <span class="sr-only">Generation</span>
</button> </button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
<p> <p>
{isGenerationDisabled {isGenerationDisabled
@@ -140,16 +150,18 @@
value="{predictedTokens?.toLocaleString()} tokens" value="{predictedTokens?.toLocaleString()} tokens"
tooltipLabel="Generated tokens" tooltipLabel="Generated tokens"
/> />
<BadgeChatStatistic <BadgeChatStatistic
class="bg-transparent" class="bg-transparent"
icon={Clock} icon={Clock}
value={formattedTime} value={formattedTime}
tooltipLabel="Generation time" tooltipLabel="Generation time"
/> />
<BadgeChatStatistic <BadgeChatStatistic
class="bg-transparent" class="bg-transparent"
icon={Gauge} icon={Gauge}
value="{tokensPerSecond.toFixed(2)} tokens/s" value="{tokensPerSecond.toFixed(2)} t/s"
tooltipLabel="Generation speed" tooltipLabel="Generation speed"
/> />
{:else if hasPromptStats} {:else if hasPromptStats}
@@ -159,12 +171,14 @@
value="{promptTokens} tokens" value="{promptTokens} tokens"
tooltipLabel="Prompt tokens" tooltipLabel="Prompt tokens"
/> />
<BadgeChatStatistic <BadgeChatStatistic
class="bg-transparent" class="bg-transparent"
icon={Clock} icon={Clock}
value={formattedPromptTime ?? '0s'} value={formattedPromptTime ?? '0s'}
tooltipLabel="Prompt processing time" tooltipLabel="Prompt processing time"
/> />
<BadgeChatStatistic <BadgeChatStatistic
class="bg-transparent" class="bg-transparent"
icon={Gauge} icon={Gauge}
@@ -3,15 +3,16 @@
import { Card } from '$lib/components/ui/card'; import { Card } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { MarkdownContent } from '$lib/components/app'; import { MarkdownContent } from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/input-classes'; import { getMessageEditContext } from '$lib/contexts';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { isIMEComposing } from '$lib/utils';
import ChatMessageActions from './ChatMessageActions.svelte'; import ChatMessageActions from './ChatMessageActions.svelte';
import { KeyboardKey, MessageRole } from '$lib/enums';
interface Props { interface Props {
class?: string; class?: string;
message: DatabaseMessage; message: DatabaseMessage;
isEditing: boolean;
editedContent: string;
siblingInfo?: ChatMessageSiblingInfo | null; siblingInfo?: ChatMessageSiblingInfo | null;
showDeleteDialog: boolean; showDeleteDialog: boolean;
deletionInfo: { deletionInfo: {
@@ -20,10 +21,6 @@
assistantMessages: number; assistantMessages: number;
messageTypes: string[]; messageTypes: string[];
} | null; } | null;
onCancelEdit: () => void;
onSaveEdit: () => void;
onEditKeydown: (event: KeyboardEvent) => void;
onEditedContentChange: (content: string) => void;
onCopy: () => void; onCopy: () => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
@@ -36,15 +33,9 @@
let { let {
class: className = '', class: className = '',
message, message,
isEditing,
editedContent,
siblingInfo = null, siblingInfo = null,
showDeleteDialog, showDeleteDialog,
deletionInfo, deletionInfo,
onCancelEdit,
onSaveEdit,
onEditKeydown,
onEditedContentChange,
onCopy, onCopy,
onEdit, onEdit,
onDelete, onDelete,
@@ -54,10 +45,25 @@
textareaElement = $bindable() textareaElement = $bindable()
}: Props = $props(); }: Props = $props();
const editCtx = getMessageEditContext();
function handleEditKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault();
editCtx.save();
} else if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
editCtx.cancel();
}
}
let isMultiline = $state(false); let isMultiline = $state(false);
let messageElement: HTMLElement | undefined = $state(); let messageElement: HTMLElement | undefined = $state();
let isExpanded = $state(false); let isExpanded = $state(false);
let contentHeight = $state(0); let contentHeight = $state(0);
const MAX_HEIGHT = 200; // pixels const MAX_HEIGHT = 200; // pixels
const currentConfig = config(); const currentConfig = config();
@@ -97,25 +103,32 @@
class="group flex flex-col items-end gap-3 md:gap-2 {className}" class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group" role="group"
> >
{#if isEditing} {#if editCtx.isEditing}
<div class="w-full max-w-[80%]"> <div class="w-full max-w-[80%]">
<textarea <textarea
bind:this={textareaElement} bind:this={textareaElement}
bind:value={editedContent} value={editCtx.editedContent}
class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}" class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
onkeydown={onEditKeydown} onkeydown={handleEditKeydown}
oninput={(e) => onEditedContentChange(e.currentTarget.value)} oninput={(e) => editCtx.setContent(e.currentTarget.value)}
placeholder="Edit system message..." placeholder="Edit system message..."
></textarea> ></textarea>
<div class="mt-2 flex justify-end gap-2"> <div class="mt-2 flex justify-end gap-2">
<Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline"> <Button class="h-8 px-3" onclick={editCtx.cancel} size="sm" variant="outline">
<X class="mr-1 h-3 w-3" /> <X class="mr-1 h-3 w-3" />
Cancel Cancel
</Button> </Button>
<Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm"> <Button
class="h-8 px-3"
onclick={editCtx.save}
disabled={!editCtx.editedContent.trim()}
size="sm"
>
<Check class="mr-1 h-3 w-3" /> <Check class="mr-1 h-3 w-3" />
Save Save
</Button> </Button>
</div> </div>
@@ -131,12 +144,12 @@
type="button" type="button"
> >
<Card <Card
class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5" class="overflow-y-auto rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
data-multiline={isMultiline ? '' : undefined} data-multiline={isMultiline ? '' : undefined}
style="border: 2px dashed hsl(var(--border));" style="border: 2px dashed hsl(var(--border)); max-height: var(--max-message-height);"
> >
<div <div
class="relative overflow-hidden transition-all duration-300 {isExpanded class="relative transition-all duration-300 {isExpanded
? 'cursor-text select-text' ? 'cursor-text select-text'
: 'select-none'}" : 'select-none'}"
style={!isExpanded && showExpandButton style={!isExpanded && showExpandButton
@@ -145,7 +158,10 @@
> >
{#if currentConfig.renderUserContentAsMarkdown} {#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}"> <div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
<MarkdownContent class="markdown-system-content" content={message.content} /> <MarkdownContent
class="markdown-system-content overflow-auto"
content={message.content}
/>
</div> </div>
{:else} {:else}
<span <span
@@ -160,6 +176,7 @@
<div <div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent" class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
></div> ></div>
<div <div
class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100" class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
> >
@@ -208,7 +225,7 @@
{onShowDeleteDialogChange} {onShowDeleteDialogChange}
{siblingInfo} {siblingInfo}
{showDeleteDialog} {showDeleteDialog}
role="user" role={MessageRole.USER}
/> />
</div> </div>
{/if} {/if}
@@ -1,67 +1,48 @@
<script lang="ts"> <script lang="ts">
import { Card } from '$lib/components/ui/card'; import { Card } from '$lib/components/ui/card';
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app'; import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import ChatMessageActions from './ChatMessageActions.svelte'; import ChatMessageActions from './ChatMessageActions.svelte';
import ChatMessageEditForm from './ChatMessageEditForm.svelte'; import ChatMessageEditForm from './ChatMessageEditForm.svelte';
import { MessageRole } from '$lib/enums';
interface Props { interface Props {
class?: string; class?: string;
message: DatabaseMessage; message: DatabaseMessage;
isEditing: boolean;
editedContent: string;
editedExtras?: DatabaseMessageExtra[];
editedUploadedFiles?: ChatUploadedFile[];
siblingInfo?: ChatMessageSiblingInfo | null; siblingInfo?: ChatMessageSiblingInfo | null;
showDeleteDialog: boolean;
deletionInfo: { deletionInfo: {
totalCount: number; totalCount: number;
userMessages: number; userMessages: number;
assistantMessages: number; assistantMessages: number;
messageTypes: string[]; messageTypes: string[];
} | null; } | null;
onCancelEdit: () => void; showDeleteDialog: boolean;
onSaveEdit: () => void;
onSaveEditOnly?: () => void;
onEditKeydown: (event: KeyboardEvent) => void;
onEditedContentChange: (content: string) => void;
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
onCopy: () => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onConfirmDelete: () => void; onConfirmDelete: () => void;
onNavigateToSibling?: (siblingId: string) => void;
onShowDeleteDialogChange: (show: boolean) => void; onShowDeleteDialogChange: (show: boolean) => void;
textareaElement?: HTMLTextAreaElement; onNavigateToSibling?: (siblingId: string) => void;
onCopy: () => void;
} }
let { let {
class: className = '', class: className = '',
message, message,
isEditing,
editedContent,
editedExtras = [],
editedUploadedFiles = [],
siblingInfo = null, siblingInfo = null,
showDeleteDialog,
deletionInfo, deletionInfo,
onCancelEdit, showDeleteDialog,
onSaveEdit,
onSaveEditOnly,
onEditKeydown,
onEditedContentChange,
onEditedExtrasChange,
onEditedUploadedFilesChange,
onCopy,
onEdit, onEdit,
onDelete, onDelete,
onConfirmDelete, onConfirmDelete,
onNavigateToSibling,
onShowDeleteDialogChange, onShowDeleteDialogChange,
textareaElement = $bindable() onNavigateToSibling,
onCopy
}: Props = $props(); }: Props = $props();
// Get contexts
const editCtx = getMessageEditContext();
let isMultiline = $state(false); let isMultiline = $state(false);
let messageElement: HTMLElement | undefined = $state(); let messageElement: HTMLElement | undefined = $state();
const currentConfig = config(); const currentConfig = config();
@@ -96,24 +77,8 @@
class="group flex flex-col items-end gap-3 md:gap-2 {className}" class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group" role="group"
> >
{#if isEditing} {#if editCtx.isEditing}
<ChatMessageEditForm <ChatMessageEditForm />
bind:textareaElement
messageId={message.id}
{editedContent}
{editedExtras}
{editedUploadedFiles}
originalContent={message.content}
originalExtras={message.extra}
showSaveOnlyOption={!!onSaveEditOnly}
{onCancelEdit}
{onSaveEdit}
{onSaveEditOnly}
{onEditKeydown}
{onEditedContentChange}
{onEditedExtrasChange}
{onEditedUploadedFilesChange}
/>
{:else} {:else}
{#if message.extra && message.extra.length > 0} {#if message.extra && message.extra.length > 0}
<div class="mb-2 max-w-[80%]"> <div class="mb-2 max-w-[80%]">
@@ -123,15 +88,13 @@
{#if message.content.trim()} {#if message.content.trim()}
<Card <Card
class="max-w-[80%] rounded-[1.125rem] border-none bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5" class="max-w-[80%] overflow-y-auto rounded-[1.125rem] border-none bg-primary/5 px-3.75 py-1.5 text-foreground backdrop-blur-md data-[multiline]:py-2.5 dark:bg-primary/15"
data-multiline={isMultiline ? '' : undefined} data-multiline={isMultiline ? '' : undefined}
style="max-height: var(--max-message-height); overflow-wrap: anywhere; word-break: break-word;"
> >
{#if currentConfig.renderUserContentAsMarkdown} {#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class="text-md"> <div bind:this={messageElement}>
<MarkdownContent <MarkdownContent class="markdown-user-content -my-4" content={message.content} />
class="markdown-user-content text-primary-foreground"
content={message.content}
/>
</div> </div>
{:else} {:else}
<span bind:this={messageElement} class="text-md whitespace-pre-wrap"> <span bind:this={messageElement} class="text-md whitespace-pre-wrap">
@@ -155,7 +118,7 @@
{onShowDeleteDialogChange} {onShowDeleteDialogChange}
{siblingInfo} {siblingInfo}
{showDeleteDialog} {showDeleteDialog}
role="user" role={MessageRole.USER}
/> />
</div> </div>
{/if} {/if}
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { ChatMessage } from '$lib/components/app'; import { ChatMessage } from '$lib/components/app';
import { setChatActionsContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte'; import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { getMessageSiblings } from '$lib/utils'; import { copyToClipboard, formatMessageForClipboard, getMessageSiblings } from '$lib/utils';
interface Props { interface Props {
class?: string; class?: string;
@@ -16,6 +18,69 @@
let allConversationMessages = $state<DatabaseMessage[]>([]); let allConversationMessages = $state<DatabaseMessage[]>([]);
const currentConfig = config(); const currentConfig = config();
setChatActionsContext({
copy: async (message: DatabaseMessage) => {
const asPlainText = Boolean(currentConfig.copyTextAttachmentsAsPlainText);
const clipboardContent = formatMessageForClipboard(
message.content,
message.extra,
asPlainText
);
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
},
delete: async (message: DatabaseMessage) => {
await chatStore.deleteMessage(message.id);
refreshAllMessages();
},
navigateToSibling: async (siblingId: string) => {
await conversationsStore.navigateToSibling(siblingId);
},
editWithBranching: async (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => {
onUserAction?.();
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
refreshAllMessages();
},
editWithReplacement: async (
message: DatabaseMessage,
newContent: string,
shouldBranch: boolean
) => {
onUserAction?.();
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
refreshAllMessages();
},
editUserMessagePreserveResponses: async (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => {
onUserAction?.();
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
refreshAllMessages();
},
regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
onUserAction?.();
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages();
},
continueAssistantMessage: async (message: DatabaseMessage) => {
onUserAction?.();
await chatStore.continueAssistantMessage(message.id);
refreshAllMessages();
}
});
function refreshAllMessages() { function refreshAllMessages() {
const conversation = activeConversation(); const conversation = activeConversation();
@@ -42,16 +107,28 @@
return []; return [];
} }
// Filter out system messages if showSystemMessage is false
const filteredMessages = currentConfig.showSystemMessage const filteredMessages = currentConfig.showSystemMessage
? messages ? messages
: messages.filter((msg) => msg.type !== 'system'); : messages.filter((msg) => msg.type !== MessageRole.SYSTEM);
return filteredMessages.map((message) => { let lastAssistantIndex = -1;
for (let i = filteredMessages.length - 1; i >= 0; i--) {
if (filteredMessages[i].role === MessageRole.ASSISTANT) {
lastAssistantIndex = i;
break;
}
}
return filteredMessages.map((message, index) => {
const siblingInfo = getMessageSiblings(allConversationMessages, message.id); const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
const isLastAssistantMessage =
message.role === MessageRole.ASSISTANT && index === lastAssistantIndex;
return { return {
message, message,
isLastAssistantMessage,
siblingInfo: siblingInfo || { siblingInfo: siblingInfo || {
message, message,
siblingIds: [message.id], siblingIds: [message.id],
@@ -61,83 +138,15 @@
}; };
}); });
}); });
async function handleNavigateToSibling(siblingId: string) {
await conversationsStore.navigateToSibling(siblingId);
}
async function handleEditWithBranching(
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) {
onUserAction?.();
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
refreshAllMessages();
}
async function handleEditWithReplacement(
message: DatabaseMessage,
newContent: string,
shouldBranch: boolean
) {
onUserAction?.();
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
refreshAllMessages();
}
async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
onUserAction?.();
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages();
}
async function handleContinueAssistantMessage(message: DatabaseMessage) {
onUserAction?.();
await chatStore.continueAssistantMessage(message.id);
refreshAllMessages();
}
async function handleEditUserMessagePreserveResponses(
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) {
onUserAction?.();
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
refreshAllMessages();
}
async function handleDeleteMessage(message: DatabaseMessage) {
await chatStore.deleteMessage(message.id);
refreshAllMessages();
}
</script> </script>
<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; "> <div class="flex h-full flex-col space-y-10 pt-24 {className}" style="height: auto; ">
{#each displayMessages as { message, siblingInfo } (message.id)} {#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)}
<ChatMessage <ChatMessage
class="mx-auto w-full max-w-[48rem]" class="mx-auto w-full max-w-[48rem]"
{message} {message}
{isLastAssistantMessage}
{siblingInfo} {siblingInfo}
onDelete={handleDeleteMessage}
onNavigateToSibling={handleNavigateToSibling}
onEditWithBranching={handleEditWithBranching}
onEditWithReplacement={handleEditWithReplacement}
onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
onRegenerateWithBranching={handleRegenerateWithBranching}
onContinueAssistantMessage={handleContinueAssistantMessage}
/> />
{/each} {/each}
</div> </div>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate } from '$app/navigation'; import { afterNavigate } from '$app/navigation';
import { import {
ChatForm, ChatScreenForm,
ChatScreenHeader, ChatScreenHeader,
ChatMessages, ChatMessages,
ChatScreenProcessingInfo, ChatScreenProcessingInfo,
@@ -12,11 +12,9 @@
} from '$lib/components/app'; } from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert'; import * as Alert from '$lib/components/ui/alert';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { import { INITIAL_SCROLL_DELAY } from '$lib/constants/auto-scroll';
AUTO_SCROLL_AT_BOTTOM_THRESHOLD, import { KeyboardKey } from '$lib/enums';
AUTO_SCROLL_INTERVAL, import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
INITIAL_SCROLL_DELAY
} from '$lib/constants/auto-scroll';
import { import {
chatStore, chatStore,
errorDialog, errorDialog,
@@ -44,16 +42,13 @@
let { showCenteredEmpty = false } = $props(); let { showCenteredEmpty = false } = $props();
let disableAutoScroll = $derived(Boolean(config().disableAutoScroll)); let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
let autoScrollEnabled = $state(true);
let chatScrollContainer: HTMLDivElement | undefined = $state(); let chatScrollContainer: HTMLDivElement | undefined = $state();
let dragCounter = $state(0); let dragCounter = $state(0);
let isDragOver = $state(false); let isDragOver = $state(false);
let lastScrollTop = $state(0);
let scrollInterval: ReturnType<typeof setInterval> | undefined;
let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
let showFileErrorDialog = $state(false); let showFileErrorDialog = $state(false);
let uploadedFiles = $state<ChatUploadedFile[]>([]); let uploadedFiles = $state<ChatUploadedFile[]>([]);
let userScrolledUp = $state(false);
const autoScroll = createAutoScrollController();
let fileErrorData = $state<{ let fileErrorData = $state<{
generallyUnsupported: File[]; generallyUnsupported: File[];
@@ -217,7 +212,11 @@
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
const isCtrlOrCmd = event.ctrlKey || event.metaKey; const isCtrlOrCmd = event.ctrlKey || event.metaKey;
if (isCtrlOrCmd && event.shiftKey && (event.key === 'd' || event.key === 'D')) { if (
isCtrlOrCmd &&
event.shiftKey &&
(event.key === KeyboardKey.D_LOWER || event.key === KeyboardKey.D_UPPER)
) {
event.preventDefault(); event.preventDefault();
if (activeConversation()) { if (activeConversation()) {
showDeleteDialog = true; showDeleteDialog = true;
@@ -234,37 +233,13 @@
} }
function handleScroll() { function handleScroll() {
if (disableAutoScroll || !chatScrollContainer) return; autoScroll.handleScroll();
const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
if (scrollTop < lastScrollTop && !isAtBottom) {
userScrolledUp = true;
autoScrollEnabled = false;
} else if (isAtBottom && userScrolledUp) {
userScrolledUp = false;
autoScrollEnabled = true;
}
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
scrollTimeout = setTimeout(() => {
if (isAtBottom) {
userScrolledUp = false;
autoScrollEnabled = true;
}
}, AUTO_SCROLL_INTERVAL);
lastScrollTop = scrollTop;
} }
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> { async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
const result = files const plainFiles = files ? $state.snapshot(files) : undefined;
? await parseFilesToMessageExtras(files, activeModelId ?? undefined) const result = plainFiles
? await parseFilesToMessageExtras(plainFiles, activeModelId ?? undefined)
: undefined; : undefined;
if (result?.emptyFiles && result.emptyFiles.length > 0) { if (result?.emptyFiles && result.emptyFiles.length > 0) {
@@ -281,12 +256,9 @@
const extras = result?.extras; const extras = result?.extras;
// Enable autoscroll for user-initiated message sending // Enable autoscroll for user-initiated message sending
if (!disableAutoScroll) { autoScroll.enable();
userScrolledUp = false;
autoScrollEnabled = true;
}
await chatStore.sendMessage(message, extras); await chatStore.sendMessage(message, extras);
scrollChatToBottom(); autoScroll.scrollToBottom();
return true; return true;
} }
@@ -336,24 +308,15 @@
} }
} }
function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') {
if (disableAutoScroll) return;
chatScrollContainer?.scrollTo({
top: chatScrollContainer?.scrollHeight,
behavior
});
}
afterNavigate(() => { afterNavigate(() => {
if (!disableAutoScroll) { if (!disableAutoScroll) {
setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY); setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
} }
}); });
onMount(() => { onMount(() => {
if (!disableAutoScroll) { if (!disableAutoScroll) {
setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY); setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
} }
const pendingDraft = chatStore.consumePendingDraft(); const pendingDraft = chatStore.consumePendingDraft();
@@ -364,21 +327,15 @@
}); });
$effect(() => { $effect(() => {
if (disableAutoScroll) { autoScroll.setContainer(chatScrollContainer);
autoScrollEnabled = false; });
if (scrollInterval) {
clearInterval(scrollInterval);
scrollInterval = undefined;
}
return;
}
if (isCurrentConversationLoading && autoScrollEnabled) { $effect(() => {
scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL); autoScroll.setDisabled(disableAutoScroll);
} else if (scrollInterval) { });
clearInterval(scrollInterval);
scrollInterval = undefined; $effect(() => {
} autoScroll.updateInterval(isCurrentConversationLoading);
}); });
</script> </script>
@@ -406,11 +363,8 @@
class="mb-16 md:mb-24" class="mb-16 md:mb-24"
messages={activeMessages()} messages={activeMessages()}
onUserAction={() => { onUserAction={() => {
if (!disableAutoScroll) { autoScroll.enable();
userScrolledUp = false; autoScroll.scrollToBottom();
autoScrollEnabled = true;
scrollChatToBottom();
}
}} }}
/> />
@@ -444,7 +398,7 @@
{/if} {/if}
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4"> <div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
<ChatForm <ChatScreenForm
disabled={hasPropsError || isEditing()} disabled={hasPropsError || isEditing()}
{initialMessage} {initialMessage}
isLoading={isCurrentConversationLoading} isLoading={isCurrentConversationLoading}
@@ -474,7 +428,7 @@
> >
<div class="w-full max-w-[48rem] px-4"> <div class="w-full max-w-[48rem] px-4">
<div class="mb-10 text-center" in:fade={{ duration: 300 }}> <div class="mb-10 text-center" in:fade={{ duration: 300 }}>
<h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1> <h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
<p class="text-lg text-muted-foreground"> <p class="text-lg text-muted-foreground">
{serverStore.props?.modalities?.audio {serverStore.props?.modalities?.audio
@@ -504,7 +458,7 @@
{/if} {/if}
<div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}> <div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
<ChatForm <ChatScreenForm
disabled={hasPropsError} disabled={hasPropsError}
{initialMessage} {initialMessage}
isLoading={isCurrentConversationLoading} isLoading={isCurrentConversationLoading}
@@ -617,7 +571,7 @@
contextInfo={activeErrorDialog?.contextInfo} contextInfo={activeErrorDialog?.contextInfo}
onOpenChange={handleErrorDialogOpenChange} onOpenChange={handleErrorDialogOpenChange}
open={Boolean(activeErrorDialog)} open={Boolean(activeErrorDialog)}
type={(activeErrorDialog?.type as ErrorDialogType) ?? ErrorDialogType.SERVER} type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}
/> />
<style> <style>
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte'; import { afterNavigate } from '$app/navigation';
import { ChatFormHelperText, ChatForm } from '$lib/components/app';
import { onMount } from 'svelte';
interface Props { interface Props {
class?: string; class?: string;
@@ -28,20 +30,92 @@
showHelperText = true, showHelperText = true,
uploadedFiles = $bindable([]) uploadedFiles = $bindable([])
}: Props = $props(); }: Props = $props();
let chatFormRef: ChatForm | undefined = $state(undefined);
let message = $derived(initialMessage);
let previousIsLoading = $derived(isLoading);
let previousInitialMessage = $derived(initialMessage);
// Sync message when initialMessage prop changes (e.g., after draft restoration)
$effect(() => {
if (initialMessage !== previousInitialMessage) {
message = initialMessage;
previousInitialMessage = initialMessage;
}
});
function handleSystemPromptClick() {
onSystemPromptAdd?.({ message, files: uploadedFiles });
}
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
async function handleSubmit() {
if (
(!message.trim() && uploadedFiles.length === 0) ||
disabled ||
isLoading ||
hasLoadingAttachments
)
return;
if (!chatFormRef?.checkModelSelected()) return;
const messageToSend = message.trim();
const filesToSend = [...uploadedFiles];
message = '';
uploadedFiles = [];
chatFormRef?.resetTextareaHeight();
const success = await onSend?.(messageToSend, filesToSend);
if (!success) {
message = messageToSend;
uploadedFiles = filesToSend;
}
}
function handleFilesAdd(files: File[]) {
onFileUpload?.(files);
}
function handleUploadedFileRemove(fileId: string) {
onFileRemove?.(fileId);
}
onMount(() => {
setTimeout(() => chatFormRef?.focus(), 10);
});
afterNavigate(() => {
setTimeout(() => chatFormRef?.focus(), 10);
});
$effect(() => {
if (previousIsLoading && !isLoading) {
setTimeout(() => chatFormRef?.focus(), 10);
}
previousIsLoading = isLoading;
});
</script> </script>
<div class="relative mx-auto max-w-[48rem]"> <div class="relative mx-auto max-w-[48rem]">
<ChatForm <ChatForm
bind:this={chatFormRef}
bind:value={message}
bind:uploadedFiles
class={className} class={className}
{disabled} {disabled}
{initialMessage}
{isLoading} {isLoading}
{onFileRemove} onFilesAdd={handleFilesAdd}
{onFileUpload}
{onSend}
{onStop} {onStop}
{onSystemPromptAdd} onSubmit={handleSubmit}
{showHelperText} onSystemPromptClick={handleSystemPromptClick}
bind:uploadedFiles onUploadedFileRemove={handleUploadedFileRemove}
/> />
</div> </div>
<ChatFormHelperText show={showHelperText} />
@@ -5,8 +5,6 @@
AlertTriangle, AlertTriangle,
Code, Code,
Monitor, Monitor,
Sun,
Moon,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Database Database
@@ -23,7 +21,12 @@
type SettingsSectionTitle type SettingsSectionTitle
} from '$lib/constants/settings-sections'; } from '$lib/constants/settings-sections';
import { setMode } from 'mode-watcher'; import { setMode } from 'mode-watcher';
import { ColorMode } from '$lib/enums/ui';
import { SettingsFieldType } from '$lib/enums/settings';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
import { NUMERIC_FIELDS, POSITIVE_INTEGER_FIELDS } from '$lib/constants/settings-fields';
import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
interface Props { interface Props {
onSave?: () => void; onSave?: () => void;
@@ -38,240 +41,231 @@
title: SettingsSectionTitle; title: SettingsSectionTitle;
}> = [ }> = [
{ {
title: 'General', title: SETTINGS_SECTION_TITLES.GENERAL,
icon: Settings, icon: Settings,
fields: [ fields: [
{ {
key: 'theme', key: SETTINGS_KEYS.THEME,
label: 'Theme', label: 'Theme',
type: 'select', type: SettingsFieldType.SELECT,
options: [ options: SETTINGS_COLOR_MODES_CONFIG
{ value: 'system', label: 'System', icon: Monitor },
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon }
]
}, },
{ key: 'apiKey', label: 'API Key', type: 'input' }, { key: SETTINGS_KEYS.API_KEY, label: 'API Key', type: SettingsFieldType.INPUT },
{ {
key: 'systemMessage', key: SETTINGS_KEYS.SYSTEM_MESSAGE,
label: 'System Message', label: 'System Message',
type: 'textarea' type: SettingsFieldType.TEXTAREA
}, },
{ {
key: 'pasteLongTextToFileLen', key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
label: 'Paste long text to file length', label: 'Paste long text to file length',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'copyTextAttachmentsAsPlainText', key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
label: 'Copy text attachments as plain text', label: 'Copy text attachments as plain text',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
}, },
{ {
key: 'enableContinueGeneration', key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
label: 'Enable "Continue" button', label: 'Enable "Continue" button',
type: 'checkbox', type: SettingsFieldType.CHECKBOX,
isExperimental: true isExperimental: true
}, },
{ {
key: 'pdfAsImage', key: SETTINGS_KEYS.PDF_AS_IMAGE,
label: 'Parse PDF as image', label: 'Parse PDF as image',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
}, },
{ {
key: 'askForTitleConfirmation', key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
label: 'Ask for confirmation before changing conversation title', label: 'Ask for confirmation before changing conversation title',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
} }
] ]
}, },
{ {
title: 'Display', title: SETTINGS_SECTION_TITLES.DISPLAY,
icon: Monitor, icon: Monitor,
fields: [ fields: [
{ {
key: 'showMessageStats', key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
label: 'Show message generation statistics', label: 'Show message generation statistics',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
}, },
{ {
key: 'showThoughtInProgress', key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
label: 'Show thought in progress', label: 'Show thought in progress',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
}, },
{ {
key: 'keepStatsVisible', key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
label: 'Keep stats visible after generation', label: 'Keep stats visible after generation',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
}, },
{ {
key: 'autoMicOnEmpty', key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input', label: 'Show microphone on empty input',
type: 'checkbox', type: SettingsFieldType.CHECKBOX,
isExperimental: true isExperimental: true
}, },
{ {
key: 'renderUserContentAsMarkdown', key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
label: 'Render user content as Markdown', label: 'Render user content as Markdown',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
}, },
{ {
key: 'disableAutoScroll', key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
label: 'Disable automatic scroll', label: 'Disable automatic scroll',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
}, },
{ {
key: 'alwaysShowSidebarOnDesktop', key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
label: 'Always show sidebar on desktop', label: 'Always show sidebar on desktop',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
}, },
{ {
key: 'autoShowSidebarOnNewChat', key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT,
label: 'Auto-show sidebar on new chat', label: 'Auto-show sidebar on new chat',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
} }
] ]
}, },
{ {
title: 'Sampling', title: SETTINGS_SECTION_TITLES.SAMPLING,
icon: Funnel, icon: Funnel,
fields: [ fields: [
{ {
key: 'temperature', key: SETTINGS_KEYS.TEMPERATURE,
label: 'Temperature', label: 'Temperature',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'dynatemp_range', key: SETTINGS_KEYS.DYNATEMP_RANGE,
label: 'Dynamic temperature range', label: 'Dynamic temperature range',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'dynatemp_exponent', key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
label: 'Dynamic temperature exponent', label: 'Dynamic temperature exponent',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'top_k', key: SETTINGS_KEYS.TOP_K,
label: 'Top K', label: 'Top K',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'top_p', key: SETTINGS_KEYS.TOP_P,
label: 'Top P', label: 'Top P',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'min_p', key: SETTINGS_KEYS.MIN_P,
label: 'Min P', label: 'Min P',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'xtc_probability', key: SETTINGS_KEYS.XTC_PROBABILITY,
label: 'XTC probability', label: 'XTC probability',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'xtc_threshold', key: SETTINGS_KEYS.XTC_THRESHOLD,
label: 'XTC threshold', label: 'XTC threshold',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'typ_p', key: SETTINGS_KEYS.TYP_P,
label: 'Typical P', label: 'Typical P',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'max_tokens', key: SETTINGS_KEYS.MAX_TOKENS,
label: 'Max tokens', label: 'Max tokens',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'samplers', key: SETTINGS_KEYS.SAMPLERS,
label: 'Samplers', label: 'Samplers',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'backend_sampling', key: SETTINGS_KEYS.BACKEND_SAMPLING,
label: 'Backend sampling', label: 'Backend sampling',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
} }
] ]
}, },
{ {
title: 'Penalties', title: SETTINGS_SECTION_TITLES.PENALTIES,
icon: AlertTriangle, icon: AlertTriangle,
fields: [ fields: [
{ {
key: 'repeat_last_n', key: SETTINGS_KEYS.REPEAT_LAST_N,
label: 'Repeat last N', label: 'Repeat last N',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'repeat_penalty', key: SETTINGS_KEYS.REPEAT_PENALTY,
label: 'Repeat penalty', label: 'Repeat penalty',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'presence_penalty', key: SETTINGS_KEYS.PRESENCE_PENALTY,
label: 'Presence penalty', label: 'Presence penalty',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'frequency_penalty', key: SETTINGS_KEYS.FREQUENCY_PENALTY,
label: 'Frequency penalty', label: 'Frequency penalty',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'dry_multiplier', key: SETTINGS_KEYS.DRY_MULTIPLIER,
label: 'DRY multiplier', label: 'DRY multiplier',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'dry_base', key: SETTINGS_KEYS.DRY_BASE,
label: 'DRY base', label: 'DRY base',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'dry_allowed_length', key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
label: 'DRY allowed length', label: 'DRY allowed length',
type: 'input' type: SettingsFieldType.INPUT
}, },
{ {
key: 'dry_penalty_last_n', key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
label: 'DRY penalty last N', label: 'DRY penalty last N',
type: 'input' type: SettingsFieldType.INPUT
} }
] ]
}, },
{ {
title: 'Import/Export', title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
icon: Database, icon: Database,
fields: [] fields: []
}, },
{ {
title: 'Developer', title: SETTINGS_SECTION_TITLES.DEVELOPER,
icon: Code, icon: Code,
fields: [ fields: [
{ {
key: 'showToolCalls', key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
label: 'Show tool call labels',
type: 'checkbox'
},
{
key: 'disableReasoningParsing',
label: 'Disable reasoning content parsing', label: 'Disable reasoning content parsing',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
}, },
{ {
key: 'showRawOutputSwitch', key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
label: 'Enable raw output toggle', label: 'Enable raw output toggle',
type: 'checkbox' type: SettingsFieldType.CHECKBOX
}, },
{ {
key: 'custom', key: SETTINGS_KEYS.CUSTOM,
label: 'Custom JSON', label: 'Custom JSON',
type: 'textarea' type: SettingsFieldType.TEXTAREA
} }
] ]
} }
@@ -303,11 +297,7 @@
let scrollContainer: HTMLDivElement | undefined = $state(); let scrollContainer: HTMLDivElement | undefined = $state();
$effect(() => { $effect(() => {
if (!initialSection) { if (initialSection) {
return;
}
if (settingSections.some((section) => section.title === initialSection)) {
activeSection = initialSection; activeSection = initialSection;
} }
}); });
@@ -315,7 +305,7 @@
function handleThemeChange(newTheme: string) { function handleThemeChange(newTheme: string) {
localConfig.theme = newTheme; localConfig.theme = newTheme;
setMode(newTheme as 'light' | 'dark' | 'system'); setMode(newTheme as ColorMode);
} }
function handleConfigChange(key: string, value: string | boolean) { function handleConfigChange(key: string, value: string | boolean) {
@@ -325,7 +315,7 @@
function handleReset() { function handleReset() {
localConfig = { ...config() }; localConfig = { ...config() };
setMode(localConfig.theme as 'light' | 'dark' | 'system'); setMode(localConfig.theme as ColorMode);
} }
function handleSave() { function handleSave() {
@@ -341,33 +331,16 @@
// Convert numeric strings to numbers for numeric fields // Convert numeric strings to numbers for numeric fields
const processedConfig = { ...localConfig }; const processedConfig = { ...localConfig };
const numericFields = [
'temperature',
'top_k',
'top_p',
'min_p',
'max_tokens',
'pasteLongTextToFileLen',
'dynatemp_range',
'dynatemp_exponent',
'typ_p',
'xtc_probability',
'xtc_threshold',
'repeat_last_n',
'repeat_penalty',
'presence_penalty',
'frequency_penalty',
'dry_multiplier',
'dry_base',
'dry_allowed_length',
'dry_penalty_last_n'
];
for (const field of numericFields) { for (const field of NUMERIC_FIELDS) {
if (processedConfig[field] !== undefined && processedConfig[field] !== '') { if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
const numValue = Number(processedConfig[field]); const numValue = Number(processedConfig[field]);
if (!isNaN(numValue)) { if (!isNaN(numValue)) {
processedConfig[field] = numValue; if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
processedConfig[field] = Math.max(1, Math.round(numValue));
} else {
processedConfig[field] = numValue;
}
} else { } else {
alert(`Invalid numeric value for ${field}. Please enter a valid number.`); alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
return; return;
@@ -506,7 +479,7 @@
<h3 class="text-lg font-semibold">{currentSection.title}</h3> <h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div> </div>
{#if currentSection.title === 'Import/Export'} {#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
<ChatSettingsImportExportTab /> <ChatSettingsImportExportTab />
{:else} {:else}
<div class="space-y-6"> <div class="space-y-6">
@@ -6,6 +6,8 @@
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config'; import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import { SettingsFieldType } from '$lib/enums/settings';
import { settingsStore } from '$lib/stores/settings.svelte'; import { settingsStore } from '$lib/stores/settings.svelte';
import { ChatSettingsParameterSourceIndicator } from '$lib/components/app'; import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
@@ -31,7 +33,7 @@
{#each fields as field (field.key)} {#each fields as field (field.key)}
<div class="space-y-2"> <div class="space-y-2">
{#if field.type === 'input'} {#if field.type === SettingsFieldType.INPUT}
{@const paramInfo = getParameterSourceInfo(field.key)} {@const paramInfo = getParameterSourceInfo(field.key)}
{@const currentValue = String(localConfig[field.key] ?? '')} {@const currentValue = String(localConfig[field.key] ?? '')}
{@const propsDefault = paramInfo?.serverDefault} {@const propsDefault = paramInfo?.serverDefault}
@@ -98,7 +100,7 @@
{@html field.help || SETTING_CONFIG_INFO[field.key]} {@html field.help || SETTING_CONFIG_INFO[field.key]}
</p> </p>
{/if} {/if}
{:else if field.type === 'textarea'} {:else if field.type === SettingsFieldType.TEXTAREA}
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium"> <Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
{field.label} {field.label}
@@ -121,7 +123,7 @@
</p> </p>
{/if} {/if}
{#if field.key === 'systemMessage'} {#if field.key === SETTINGS_KEYS.SYSTEM_MESSAGE}
<div class="mt-3 flex items-center gap-2"> <div class="mt-3 flex items-center gap-2">
<Checkbox <Checkbox
id="showSystemMessage" id="showSystemMessage"
@@ -134,7 +136,7 @@
</Label> </Label>
</div> </div>
{/if} {/if}
{:else if field.type === 'select'} {:else if field.type === SettingsFieldType.SELECT}
{@const selectedOption = field.options?.find( {@const selectedOption = field.options?.find(
(opt: { value: string; label: string; icon?: Component }) => (opt: { value: string; label: string; icon?: Component }) =>
opt.value === localConfig[field.key] opt.value === localConfig[field.key]
@@ -166,7 +168,7 @@
type="single" type="single"
value={currentValue} value={currentValue}
onValueChange={(value) => { onValueChange={(value) => {
if (field.key === 'theme' && value && onThemeChange) { if (field.key === SETTINGS_KEYS.THEME && value && onThemeChange) {
onThemeChange(value); onThemeChange(value);
} else { } else {
onConfigChange(field.key, value); onConfigChange(field.key, value);
@@ -222,7 +224,7 @@
{field.help || SETTING_CONFIG_INFO[field.key]} {field.help || SETTING_CONFIG_INFO[field.key]}
</p> </p>
{/if} {/if}
{:else if field.type === 'checkbox'} {:else if field.type === SettingsFieldType.CHECKBOX}
<div class="flex items-start space-x-3"> <div class="flex items-start space-x-3">
<Checkbox <Checkbox
id={field.key} id={field.key}
@@ -0,0 +1,597 @@
/**
*
* ATTACHMENTS
*
* Components for displaying and managing different attachment types in chat messages.
* Supports two operational modes:
* - **Readonly mode**: For displaying stored attachments in sent messages (DatabaseMessageExtra[])
* - **Editable mode**: For managing pending uploads in the input form (ChatUploadedFile[])
*
* The attachment system uses `getAttachmentDisplayItems()` utility to normalize both
* data sources into a unified display format, enabling consistent rendering regardless
* of the attachment origin.
*
*/
/**
* **ChatAttachmentsList** - Unified display for file attachments in chat
*
* Central component for rendering file attachments in both ChatMessage (readonly)
* and ChatForm (editable) contexts.
*
* **Architecture:**
* - Delegates rendering to specialized thumbnail components based on attachment type
* - Manages scroll state and navigation arrows for horizontal overflow
* - Integrates with DialogChatAttachmentPreview for full-size viewing
* - Validates vision modality support via `activeModelId` prop
*
* **Features:**
* - Horizontal scroll with smooth navigation arrows
* - Image thumbnails with lazy loading and error fallback
* - File type icons for non-image files (PDF, text, audio, etc.)
* - Click-to-preview with full-size dialog and download option
* - "View All" button when `limitToSingleRow` is enabled and content overflows
* - Vision modality validation to warn about unsupported image uploads
* - Customizable thumbnail dimensions via `imageHeight`/`imageWidth` props
*
* @example
* ```svelte
* <!-- Readonly mode (in ChatMessage) -->
* <ChatAttachmentsList attachments={message.extra} readonly />
*
* <!-- Editable mode (in ChatForm) -->
* <ChatAttachmentsList
* bind:uploadedFiles
* onFileRemove={(id) => removeFile(id)}
* limitToSingleRow
* activeModelId={selectedModel}
* />
* ```
*/
export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachmentsList.svelte';
/**
* Thumbnail for non-image file attachments. Displays file type icon based on extension,
* file name (truncated), and file size.
* Handles text files, PDFs, audio, and other document types.
*/
export { default as ChatAttachmentThumbnailFile } from './ChatAttachments/ChatAttachmentThumbnailFile.svelte';
/**
* Thumbnail for image attachments with lazy loading and error fallback.
* Displays image preview with configurable dimensions. Falls back to placeholder
* on load error.
*/
export { default as ChatAttachmentThumbnailImage } from './ChatAttachments/ChatAttachmentThumbnailImage.svelte';
/**
* Grid view of all attachments for "View All" dialog. Displays all attachments
* in a responsive grid layout when there are too many to show inline.
* Triggered by "+X more" button in ChatAttachmentsList.
*/
export { default as ChatAttachmentsViewAll } from './ChatAttachments/ChatAttachmentsViewAll.svelte';
/**
* Full-size preview dialog for attachments. Opens when clicking on any attachment
* thumbnail. Shows the attachment in full size with options to download or close.
* Handles both image and non-image attachments with appropriate rendering.
*/
export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachmentPreview.svelte';
/**
*
* FORM
*
* Components for the chat input area. The form handles user input, file attachments,
* audio recording. It integrates with multiple stores:
* - `chatStore` for message submission and generation control
* - `modelsStore` for model selection and validation
*
* The form exposes a public API for programmatic control from parent components
* (focus, height reset, model selector, validation).
*
*/
/**
* **ChatForm** - Main chat input component with rich features
*
* The primary input interface for composing and sending chat messages.
* Orchestrates text input, file attachments, audio recording.
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
*
* **Architecture:**
* - Composes ChatFormTextarea, ChatFormActions, and ChatFormPromptPicker
* - Manages file upload state via `uploadedFiles` bindable prop
* - Integrates with ModelsSelector for model selection in router mode
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
*
* **Input Handling:**
* - IME-safe Enter key handling (waits for composition end)
* - Shift+Enter for newline, Enter for submit
* - Paste handler for files and long text (> {pasteLongTextToFileLen} chars file conversion)
*
* **Features:**
* - Auto-resizing textarea with placeholder
* - File upload via button dropdown (images/text/PDF), drag-drop, or paste
* - Audio recording with WAV conversion (when model supports audio)
* - Model selector integration (router mode)
* - Loading state with stop button, disabled state for errors
*
* **Exported API:**
* - `focus()` - Focus the textarea programmatically
* - `resetTextareaHeight()` - Reset textarea to default height after submit
* - `openModelSelector()` - Open model selection dropdown
* - `checkModelSelected(): boolean` - Validate model selection, show error if none
*
* @example
* ```svelte
* <ChatForm
* bind:this={chatFormRef}
* bind:value={message}
* bind:uploadedFiles
* {isLoading}
* onSubmit={handleSubmit}
* onFilesAdd={processFiles}
* onStop={handleStop}
* />
* ```
*/
export { default as ChatForm } from './ChatForm/ChatForm.svelte';
/**
* Dropdown button for file attachment selection. Opens a menu with options for
* Images, Text Files, and PDF Files. Each option filters the file picker to
* appropriate types. Images option is disabled when model lacks vision modality.
*/
export { default as ChatFormActionAttachmentsDropdown } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
/**
* Audio recording button with real-time recording indicator. Records audio
* and converts to WAV format for upload. Only visible when the active model
* supports audio modality and setting for automatic audio input is enabled. Shows recording duration while active.
*/
export { default as ChatFormActionRecord } from './ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
/**
* Container for chat form action buttons. Arranges file attachment, audio record,
* and submit/stop buttons in a horizontal layout. Handles conditional visibility
* based on model capabilities and loading state.
*/
export { default as ChatFormActions } from './ChatForm/ChatFormActions/ChatFormActions.svelte';
/**
* Submit/stop button with loading state. Shows send icon normally, transforms
* to stop icon during generation. Disabled when input is empty or form is disabled.
* Triggers onSubmit or onStop callbacks based on current state.
*/
export { default as ChatFormActionSubmit } from './ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
/**
* Hidden file input element for programmatic file selection.
*/
export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileInputInvisible.svelte';
/**
* Helper text display below chat.
*/
export { default as ChatFormHelperText } from './ChatForm/ChatFormHelperText.svelte';
/**
* Auto-resizing textarea with IME composition support. Automatically adjusts
* height based on content. Handles IME input correctly (waits for composition
* end before processing Enter key). Exposes focus() and resetHeight() methods.
*/
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
/**
*
* MESSAGES
*
* Components for displaying chat messages. The message system supports:
* - **Conversation branching**: Messages can have siblings (alternative versions)
* created by editing or regenerating. Users can navigate between branches.
* - **Role-based rendering**: Different layouts for user, assistant, and system messages
* - **Streaming support**: Real-time display of assistant responses as they generate
* - **Agentic workflows**: Special rendering for tool calls and reasoning blocks
*
* The branching system uses `getMessageSiblings()` utility to compute sibling info
* for each message based on the full conversation tree stored in the database.
*
*/
/**
* **ChatMessages** - Message list container with branching support
*
* Container component that renders the list of messages in a conversation.
* Computes sibling information for each message to enable branch navigation.
* Integrates with conversationsStore for message operations.
*
* **Architecture:**
* - Fetches all conversation messages to compute sibling relationships
* - Filters system messages based on user config (`showSystemMessage`)
* - Delegates rendering to ChatMessage for each message
* - Propagates all message operations to chatStore via callbacks
*
* **Branching Logic:**
* - Uses `getMessageSiblings()` to find all messages with same parent
* - Computes `siblingInfo: { currentIndex, totalSiblings, siblingIds }`
* - Enables navigation between alternative message versions
*
* **Message Operations (delegated to chatStore):**
* - Edit with branching: Creates new message branch, preserves original
* - Edit with replacement: Modifies message in place
* - Regenerate: Creates new assistant response as sibling
* - Delete: Removes message and all descendants (cascade)
* - Continue: Appends to incomplete assistant message
*
* @example
* ```svelte
* <ChatMessages
* messages={activeMessages()}
* onUserAction={resetAutoScroll}
* />
* ```
*/
export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
/**
* **ChatMessage** - Single message display with actions
*
* Renders a single chat message with role-specific styling and full action
* support. Delegates to specialized components based on message role:
* ChatMessageUser, ChatMessageAssistant, or ChatMessageSystem.
*
* **Architecture:**
* - Routes to role-specific component based on `message.type`
* - Manages edit mode state and inline editing UI
* - Handles action callbacks (copy, edit, delete, regenerate)
* - Displays branching controls when message has siblings
*
* **User Messages:**
* - Shows attachments via ChatAttachmentsList
* - Edit creates new branch or preserves responses
*
* **Assistant Messages:**
* - Renders content via MarkdownContent or ChatMessageAgenticContent
* - Shows model info badge (when enabled)
* - Regenerate creates sibling with optional model override
* - Continue action for incomplete responses
*
* **Features:**
* - Inline editing with file attachments support
* - Copy formatted content to clipboard
* - Delete with confirmation (shows cascade delete count)
* - Branching controls for sibling navigation
* - Statistics display (tokens, timing)
*
* @example
* ```svelte
* <ChatMessage
* {message}
* {siblingInfo}
* onEditWithBranching={handleEdit}
* onRegenerateWithBranching={handleRegenerate}
* onNavigateToSibling={handleNavigate}
* />
* ```
*/
export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
/**
* Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate
* buttons based on message role. Includes branching controls when message has siblings.
* Shows delete confirmation dialog with cascade delete count. Handles raw output toggle
* for assistant messages.
*/
export { default as ChatMessageActions } from './ChatMessages/ChatMessageActions.svelte';
/**
* Navigation controls for message siblings (conversation branches). Displays
* prev/next arrows with current position counter (e.g., "2/5"). Enables users
* to navigate between alternative versions of a message created by editing
* or regenerating. Uses `conversationsStore.navigateToSibling()` for navigation.
*/
export { default as ChatMessageBranchingControls } from './ChatMessages/ChatMessageBranchingControls.svelte';
/**
* Statistics display for assistant messages. Shows token counts (prompt/completion),
* generation timing, tokens per second, and model name (when enabled in settings).
* Data sourced from message.timings stored during generation.
*/
export { default as ChatMessageStatistics } from './ChatMessages/ChatMessageStatistics.svelte';
/**
* System message display component. Renders system messages with distinct styling.
* Visibility controlled by `showSystemMessage` config setting.
*/
export { default as ChatMessageSystem } from './ChatMessages/ChatMessageSystem.svelte';
/**
* User message display component. Renders user messages with right-aligned bubble styling.
* Shows message content, attachments via ChatAttachmentsList.
* Supports inline editing mode with ChatMessageEditForm integration.
*/
export { default as ChatMessageUser } from './ChatMessages/ChatMessageUser.svelte';
/**
* Assistant message display component. Renders assistant responses with left-aligned styling.
* Supports both plain markdown content (via MarkdownContent) and agentic content with tool calls
* (via ChatMessageAgenticContent). Shows model info badge, statistics, and action buttons.
* Handles streaming state with real-time content updates.
*/
export { default as ChatMessageAssistant } from './ChatMessages/ChatMessageAssistant.svelte';
/**
* Inline message editing form. Provides textarea for editing message content with
* attachment management. Shows save/cancel buttons and optional "Save only" button
* for editing without regenerating responses. Used within ChatMessage components
* when user enters edit mode.
*/
export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditForm.svelte';
/**
*
* SCREEN
*
* Top-level chat interface components. ChatScreen is the main container that
* orchestrates all chat functionality. It integrates with multiple stores:
* - `chatStore` for message operations and generation control
* - `conversationsStore` for conversation management
* - `serverStore` for server connection state
* - `modelsStore` for model capabilities (vision, audio modalities)
*
* The screen handles the complete chat lifecycle from empty state to active
* conversation with streaming responses.
*
*/
/**
* **ChatScreen** - Main chat interface container
*
* Top-level component that orchestrates the entire chat interface. Manages
* messages display, input form, file handling, auto-scroll, error dialogs,
* and server state. Used as the main content area in chat routes.
*
* **Architecture:**
* - Composes ChatMessages, ChatScreenForm, ChatScreenHeader, and dialogs
* - Manages auto-scroll via `createAutoScrollController()` hook
* - Handles file upload pipeline (validation processing state update)
* - Integrates with serverStore for loading/error/warning states
* - Tracks active model for modality validation (vision, audio)
*
* **File Upload Pipeline:**
* 1. Files received via drag-drop, paste, or file picker
* 2. Validated against supported types (`isFileTypeSupported()`)
* 3. Filtered by model modalities (`filterFilesByModalities()`)
* 4. Empty files detected and reported via DialogEmptyFileAlert
* 5. Valid files processed to ChatUploadedFile[] format
* 6. Unsupported files shown in error dialog with reasons
*
* **State Management:**
* - `isEmpty`: Shows centered welcome UI when no conversation active
* - `isCurrentConversationLoading`: Tracks generation state for current chat
* - `activeModelId`: Determines available modalities for file validation
* - `uploadedFiles`: Pending file attachments for next message
*
* **Features:**
* - Messages display with smart auto-scroll (pauses on user scroll up)
* - File drag-drop with visual overlay indicator
* - File validation with detailed error messages
* - Error dialog management (chat errors, model unavailable)
* - Server loading/error/warning states with appropriate UI
* - Conversation deletion with confirmation dialog
* - Processing info display (tokens/sec, timing) during generation
* - Keyboard shortcuts (Ctrl+Shift+Backspace to delete conversation)
*
* @example
* ```svelte
* <!-- In chat route -->
* <ChatScreen showCenteredEmpty={true} />
*
* <!-- In conversation route -->
* <ChatScreen showCenteredEmpty={false} />
* ```
*/
export { default as ChatScreen } from './ChatScreen/ChatScreen.svelte';
/**
* Visual overlay displayed when user drags files over the chat screen.
* Shows drop zone indicator to guide users where to release files.
* Integrated with ChatScreen's drag-drop file upload handling.
*/
export { default as ChatScreenDragOverlay } from './ChatScreen/ChatScreenDragOverlay.svelte';
/**
* Chat form wrapper within ChatScreen. Positions the ChatForm component at the
* bottom of the screen with proper padding and max-width constraints. Handles
* the visual container styling for the input area.
*/
export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte';
/**
* Header bar for chat screen. Displays conversation title (or "New Chat"),
* model selector (in router mode), and action buttons (delete conversation).
* Sticky positioned at the top of the chat area.
*/
export { default as ChatScreenHeader } from './ChatScreen/ChatScreenHeader.svelte';
/**
* Processing info display during generation. Shows real-time statistics:
* tokens per second, prompt/completion token counts, and elapsed time.
* Data sourced from slotsService polling during active generation.
* Only visible when `isCurrentConversationLoading` is true.
*/
export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte';
/**
*
* SETTINGS
*
* Application settings components. Settings are persisted to localStorage via
* the config store and synchronized with server `/props` endpoint for sampling
* parameters. The settings panel uses a tabbed interface with mobile-responsive
* horizontal scrolling tabs.
*
* **Parameter Sync System:**
* Sampling parameters (temperature, top_p, etc.) can come from three sources:
* 1. **Server Props**: Default values from `/props` endpoint
* 2. **User Custom**: Values explicitly set by user (overrides server)
* 3. **App Default**: Fallback when server props unavailable
*
* The `ChatSettingsParameterSourceIndicator` badge shows which source is active.
*
*/
/**
* **ChatSettings** - Application settings panel
*
* Comprehensive settings interface with categorized sections. Manages all
* user preferences and sampling parameters. Integrates with config store
* for persistence and ParameterSyncService for server synchronization.
*
* **Architecture:**
* - Uses tabbed navigation with category sections
* - Maintains local form state, commits on save
* - Tracks user overrides vs server defaults for sampling params
* - Exposes reset() method for dialog close without save
*
* **Categories:**
* - **General**: API key, system message, show system messages toggle
* - **Display**: Theme selection, message actions visibility, model info badge
* - **Sampling**: Temperature, top_p, top_k, min_p, repeat_penalty, etc.
* - **Penalties**: Frequency penalty, presence penalty, repeat last N
* - **Import/Export**: Conversation backup and restore
* - **Developer**: Debug options, disable auto-scroll
*
* **Parameter Sync:**
* - Fetches defaults from server `/props` endpoint
* - Shows source indicator badge (Custom/Server Props/Default)
* - Real-time badge updates as user types
* - Tracks which parameters user has explicitly overridden
*
* **Features:**
* - Mobile-responsive layout with horizontal scrolling tabs
* - Form validation with error messages
* - Secure API key storage (masked input)
* - Import/export conversations as JSON
* - Reset to defaults option per parameter
*
* **Exported API:**
* - `reset()` - Reset form fields to currently saved values (for cancel action)
*
* @example
* ```svelte
* <ChatSettings
* bind:this={settingsRef}
* onSave={() => dialogOpen = false}
* onCancel={() => { settingsRef.reset(); dialogOpen = false; }}
* />
* ```
*/
export { default as ChatSettings } from './ChatSettings/ChatSettings.svelte';
/**
* Footer with save/cancel buttons for settings panel. Positioned at bottom
* of settings dialog. Save button commits form state to config store,
* cancel button triggers reset and close.
*/
export { default as ChatSettingsFooter } from './ChatSettings/ChatSettingsFooter.svelte';
/**
* Form fields renderer for individual settings. Generates appropriate input
* components based on field type (text, number, select, checkbox, textarea).
* Handles validation, help text display, and parameter source indicators.
*/
export { default as ChatSettingsFields } from './ChatSettings/ChatSettingsFields.svelte';
/**
* Import/export tab content for conversation data management. Provides buttons
* to export all conversations as JSON file and import from JSON file.
* Handles file download/upload and data validation.
*/
export { default as ChatSettingsImportExportTab } from './ChatSettings/ChatSettingsImportExportTab.svelte';
/**
* Badge indicating parameter source for sampling settings. Shows one of:
* - **Custom**: User has explicitly set this value (orange badge)
* - **Server Props**: Using default from `/props` endpoint (blue badge)
* - **Default**: Using app default, server props unavailable (gray badge)
* Updates in real-time as user types to show immediate feedback.
*/
export { default as ChatSettingsParameterSourceIndicator } from './ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
/**
*
* SIDEBAR
*
* The sidebar integrates with ShadCN's sidebar component system
* for consistent styling and mobile responsiveness.
* Conversations are loaded from conversationsStore and displayed in reverse
* chronological order (most recent first).
*
*/
/**
* **ChatSidebar** - Chat Sidebar with actions menu and conversation list
*
* Collapsible sidebar displaying conversation history with search and
* management actions. Integrates with ShadCN sidebar component for
* consistent styling and mobile responsiveness.
*
* **Architecture:**
* - Uses ShadCN Sidebar.* components for structure
* - Fetches conversations from conversationsStore
* - Manages search state and filtered results locally
* - Handles conversation CRUD operations via conversationsStore
*
* **Navigation:**
* - Click conversation to navigate to `/chat/[id]`
* - New chat button navigates to `/` (root)
* - Active conversation highlighted based on route params
*
* **Conversation Management:**
* - Right-click or menu button for context menu
* - Rename: Opens inline edit dialog
* - Delete: Shows confirmation with conversation preview
* - Delete All: Removes all conversations with confirmation
*
* **Features:**
* - Search/filter conversations by title
* - Conversation list with message previews (first message truncated)
* - Active conversation highlighting
* - Mobile-responsive collapse/expand via ShadCN sidebar
* - New chat button in header
* - Settings button opens DialogChatSettings
*
* **Exported API:**
* - `handleMobileSidebarItemClick()` - Close sidebar on mobile after item selection
* - `activateSearchMode()` - Focus search input programmatically
* - `editActiveConversation()` - Open rename dialog for current conversation
*
* @example
* ```svelte
* <ChatSidebar bind:this={sidebarRef} />
* ```
*/
export { default as ChatSidebar } from './ChatSidebar/ChatSidebar.svelte';
/**
* Action buttons for sidebar header. Contains new chat button, settings button,
* and delete all conversations button. Manages dialog states for settings and
* delete confirmation.
*/
export { default as ChatSidebarActions } from './ChatSidebar/ChatSidebarActions.svelte';
/**
* Single conversation item in sidebar. Displays conversation title (truncated),
* last message preview, and timestamp. Shows context menu on right-click with
* rename and delete options. Highlights when active (matches current route).
* Handles click to navigate and keyboard accessibility.
*/
export { default as ChatSidebarConversationItem } from './ChatSidebar/ChatSidebarConversationItem.svelte';
/**
* Search input for filtering conversations in sidebar. Filters conversation
* list by title as user types. Shows clear button when query is not empty.
* Integrated into sidebar header with proper styling.
*/
export { default as ChatSidebarSearch } from './ChatSidebar/ChatSidebarSearch.svelte';
@@ -0,0 +1,416 @@
/**
*
* DIALOGS
*
* Modal dialog components for the chat application.
*
* All dialogs use ShadCN Dialog or AlertDialog components for consistent
* styling, accessibility, and animation. They integrate with application
* stores for state management and data access.
*
*/
/**
*
* SETTINGS DIALOGS
*
* Dialogs for application and server configuration.
*
*/
/**
* **DialogChatSettings** - Settings dialog wrapper
*
* Modal dialog containing ChatSettings component with proper
* open/close state management and automatic form reset on open.
*
* **Architecture:**
* - Wraps ChatSettings component in ShadCN Dialog
* - Manages open/close state via bindable `open` prop
* - Resets form state when dialog opens to discard unsaved changes
*
* @example
* ```svelte
* <DialogChatSettings bind:open={showSettings} />
* ```
*/
export { default as DialogChatSettings } from './DialogChatSettings.svelte';
/**
*
* CONFIRMATION DIALOGS
*
* Dialogs for user action confirmations. Use AlertDialog for blocking
* confirmations that require explicit user decision before proceeding.
*
*/
/**
* **DialogConfirmation** - Generic confirmation dialog
*
* Reusable confirmation dialog with customizable title, description,
* and action buttons. Supports destructive action styling and custom icons.
* Used for delete confirmations, irreversible actions, and important decisions.
*
* **Architecture:**
* - Uses ShadCN AlertDialog
* - Supports variant styling (default, destructive)
* - Customizable button labels and callbacks
*
* **Features:**
* - Customizable title and description text
* - Destructive variant with red styling for dangerous actions
* - Custom icon support in header
* - Cancel and confirm button callbacks
* - Keyboard accessible (Escape to cancel, Enter to confirm)
*
* @example
* ```svelte
* <DialogConfirmation
* bind:open={showDelete}
* title="Delete conversation?"
* description="This action cannot be undone."
* variant="destructive"
* onConfirm={handleDelete}
* onCancel={() => showDelete = false}
* />
* ```
*/
export { default as DialogConfirmation } from './DialogConfirmation.svelte';
/**
* **DialogConversationTitleUpdate** - Conversation rename confirmation
*
* Confirmation dialog shown when editing the first user message in a conversation.
* Asks user whether to update the conversation title to match the new message content.
*
* **Architecture:**
* - Uses ShadCN AlertDialog
* - Shows current vs proposed title comparison
* - Triggered by ChatMessages when first message is edited
*
* **Features:**
* - Side-by-side display of current and new title
* - "Keep Current Title" and "Update Title" action buttons
* - Styled title previews in muted background boxes
*
* @example
* ```svelte
* <DialogConversationTitleUpdate
* bind:open={showTitleUpdate}
* currentTitle={conversation.name}
* newTitle={truncatedMessageContent}
* onConfirm={updateTitle}
* onCancel={() => showTitleUpdate = false}
* />
* ```
*/
export { default as DialogConversationTitleUpdate } from './DialogConversationTitleUpdate.svelte';
/**
*
* CONTENT PREVIEW DIALOGS
*
* Dialogs for previewing and displaying content in full-screen or modal views.
*
*/
/**
* **DialogCodePreview** - Full-screen code/HTML preview
*
* Full-screen dialog for previewing HTML or code in an isolated iframe.
* Used by MarkdownContent component for previewing rendered HTML blocks
* from code blocks in chat messages.
*
* **Architecture:**
* - Uses ShadCN Dialog with full viewport layout
* - Sandboxed iframe execution (allow-scripts only)
* - Clears content when closed for security
*
* **Features:**
* - Full viewport iframe preview
* - Sandboxed execution environment
* - Close button with mix-blend-difference for visibility over any content
* - Automatic content cleanup on close
* - Supports HTML preview with proper isolation
*
* @example
* ```svelte
* <DialogCodePreview
* bind:open={showPreview}
* code={htmlContent}
* language="html"
* />
* ```
*/
export { default as DialogCodePreview } from './DialogCodePreview.svelte';
/**
*
* ATTACHMENT DIALOGS
*
* Dialogs for viewing and managing file attachments. Support both
* uploaded files (pending) and stored attachments (in messages).
*
*/
/**
* **DialogChatAttachmentPreview** - Full-size attachment preview
*
* Modal dialog for viewing file attachments at full size. Supports different
* file types with appropriate preview modes: images, text files, PDFs, and audio.
*
* **Architecture:**
* - Wraps ChatAttachmentPreview component in ShadCN Dialog
* - Accepts either uploaded file or stored attachment as data source
* - Resets preview state when dialog opens
*
* **Features:**
* - Full-size image display with proper scaling
* - Text file content with syntax highlighting
* - PDF preview with text/image view toggle
* - Audio file placeholder with download option
* - File name and size display in header
* - Download button for all file types
* - Vision modality check for image attachments
*
* @example
* ```svelte
* <!-- Preview uploaded file -->
* <DialogChatAttachmentPreview
* bind:open={showPreview}
* uploadedFile={selectedFile}
* activeModelId={currentModel}
* />
*
* <!-- Preview stored attachment -->
* <DialogChatAttachmentPreview
* bind:open={showPreview}
* attachment={selectedAttachment}
* />
* ```
*/
export { default as DialogChatAttachmentPreview } from './DialogChatAttachmentPreview.svelte';
/**
* **DialogChatAttachmentsViewAll** - Grid view of all attachments
*
* Dialog showing all attachments in a responsive grid layout. Triggered by
* "+X more" button in ChatAttachmentsList when there are too many attachments
* to display inline.
*
* **Architecture:**
* - Wraps ChatAttachmentsViewAll component in ShadCN Dialog
* - Supports both readonly (message view) and editable (form) modes
* - Displays total attachment count in header
*
* **Features:**
* - Responsive grid layout for all attachments
* - Thumbnail previews with click-to-expand
* - Remove button in editable mode
* - Configurable thumbnail dimensions
* - Vision modality validation for images
*
* @example
* ```svelte
* <DialogChatAttachmentsViewAll
* bind:open={showAllAttachments}
* attachments={message.extra}
* readonly
* />
* ```
*/
export { default as DialogChatAttachmentsViewAll } from './DialogChatAttachmentsViewAll.svelte';
/**
*
* ERROR & ALERT DIALOGS
*
* Dialogs for displaying errors, warnings, and alerts to users.
* Provide context about what went wrong and recovery options.
*
*/
/**
* **DialogChatError** - Chat/generation error display
*
* Alert dialog for displaying chat and generation errors with context
* information. Supports different error types with appropriate styling
* and messaging.
*
* **Architecture:**
* - Uses ShadCN AlertDialog for modal display
* - Differentiates between timeout and server errors
* - Shows context info when available (token counts)
*
* **Error Types:**
* - **timeout**: TCP timeout with timer icon, red destructive styling
* - **server**: Server error with warning icon, amber warning styling
*
* **Features:**
* - Type-specific icons (TimerOff for timeout, AlertTriangle for server)
* - Error message display in styled badge
* - Context info showing prompt tokens and context size
* - Close button to dismiss
*
* @example
* ```svelte
* <DialogChatError
* bind:open={showError}
* type="server"
* message={errorMessage}
* contextInfo={{ n_prompt_tokens: 1024, n_ctx: 4096 }}
* />
* ```
*/
export { default as DialogChatError } from './DialogChatError.svelte';
/**
* **DialogEmptyFileAlert** - Empty file upload warning
*
* Alert dialog shown when user attempts to upload empty files. Lists the
* empty files that were detected and removed from attachments, with
* explanation of why empty files cannot be processed.
*
* **Architecture:**
* - Uses ShadCN AlertDialog for modal display
* - Receives list of empty file names from ChatScreen
* - Triggered during file upload validation
*
* **Features:**
* - FileX icon indicating file error
* - List of empty file names in monospace font
* - Explanation of what happened and why
* - Single "Got it" dismiss button
*
* @example
* ```svelte
* <DialogEmptyFileAlert
* bind:open={showEmptyAlert}
* emptyFiles={['empty.txt', 'blank.md']}
* />
* ```
*/
export { default as DialogEmptyFileAlert } from './DialogEmptyFileAlert.svelte';
/**
* **DialogModelNotAvailable** - Model unavailable error
*
* Alert dialog shown when the requested model (from URL params or selection)
* is not available on the server. Displays the requested model name and
* offers selection from available models.
*
* **Architecture:**
* - Uses ShadCN AlertDialog for modal display
* - Integrates with SvelteKit navigation for model switching
* - Receives available models list from modelsStore
*
* **Features:**
* - Warning icon with amber styling
* - Requested model name display in styled badge
* - Scrollable list of available models
* - Click model to navigate with updated URL params
* - Cancel button to dismiss without selection
*
* @example
* ```svelte
* <DialogModelNotAvailable
* bind:open={showModelError}
* modelName={requestedModel}
* availableModels={modelsList}
* />
* ```
*/
export { default as DialogModelNotAvailable } from './DialogModelNotAvailable.svelte';
/**
*
* DATA MANAGEMENT DIALOGS
*
* Dialogs for managing conversation data, including import/export
* and selection operations.
*
*/
/**
* **DialogConversationSelection** - Conversation picker for import/export
*
* Dialog for selecting conversations during import or export operations.
* Displays list of conversations with checkboxes for multi-selection.
* Used by ChatSettingsImportExportTab for data management.
*
* **Architecture:**
* - Wraps ConversationSelection component in ShadCN Dialog
* - Supports export mode (select from local) and import mode (select from file)
* - Resets selection state when dialog opens
* - High z-index to appear above settings dialog
*
* **Features:**
* - Multi-select with checkboxes
* - Conversation title and message count display
* - Select all / deselect all controls
* - Mode-specific descriptions (export vs import)
* - Cancel and confirm callbacks with selected conversations
*
* @example
* ```svelte
* <DialogConversationSelection
* bind:open={showExportSelection}
* conversations={allConversations}
* messageCountMap={messageCounts}
* mode="export"
* onConfirm={handleExport}
* onCancel={() => showExportSelection = false}
* />
* ```
*/
export { default as DialogConversationSelection } from './DialogConversationSelection.svelte';
/**
*
* MODEL INFORMATION DIALOGS
*
* Dialogs for displaying model and server information.
*
*/
/**
* **DialogModelInformation** - Model details display
*
* Dialog showing comprehensive information about the currently loaded model
* and server configuration. Displays model metadata, capabilities, and
* server settings in a structured table format.
*
* **Architecture:**
* - Uses ShadCN Dialog with wide layout for table display
* - Fetches data from serverStore (props) and modelsStore (metadata)
* - Auto-fetches models when dialog opens if not loaded
*
* **Information Displayed:**
* - **Model**: Name with copy button
* - **File Path**: Full path to model file with copy button
* - **Context Size**: Current context window size
* - **Training Context**: Original training context (if available)
* - **Model Size**: File size in human-readable format
* - **Parameters**: Parameter count (e.g., "7B", "70B")
* - **Embedding Size**: Embedding dimension
* - **Vocabulary Size**: Token vocabulary size
* - **Vocabulary Type**: Tokenizer type (BPE, etc.)
* - **Parallel Slots**: Number of concurrent request slots
* - **Modalities**: Supported input types (text, vision, audio)
* - **Build Info**: Server build information
* - **Chat Template**: Full Jinja template in scrollable code block
*
* **Features:**
* - Copy buttons for model name and path
* - Modality badges with icons
* - Responsive table layout with container queries
* - Loading state while fetching model info
* - Scrollable chat template display
*
* @example
* ```svelte
* <DialogModelInformation bind:open={showModelInfo} />
* ```
*/
export { default as DialogModelInformation } from './DialogModelInformation.svelte';
@@ -1,68 +1,10 @@
export * from './actions'; export * from './actions';
export * from './badges'; export * from './badges';
export * from './chat';
export * from './content'; export * from './content';
export * from './dialogs';
export * from './forms'; export * from './forms';
export * from './misc'; export * from './misc';
export * from './models'; export * from './models';
export * from './navigation'; export * from './navigation';
export * from './server'; export * from './server';
// Chat
export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
export { default as ChatFormActionAttachmentsDropdown } from './chat/ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
export { default as ChatMessageAssistant } from './chat/ChatMessages/ChatMessageAssistant.svelte';
export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
export { default as ChatMessageEditForm } from './chat/ChatMessages/ChatMessageEditForm.svelte';
export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
export { default as ChatMessageUser } from './chat/ChatMessages/ChatMessageUser.svelte';
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
export { default as ChatScreenDragOverlay } from './chat/ChatScreen/ChatScreenDragOverlay.svelte';
export { default as ChatScreenForm } from './chat/ChatScreen/ChatScreenForm.svelte';
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
export { default as ChatSidebarActions } from './chat/ChatSidebar/ChatSidebarActions.svelte';
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
// Dialogs
export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte';
export { default as DialogCodePreview } from './dialogs/DialogCodePreview.svelte';
export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
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';
export { default as DialogModelNotAvailable } from './dialogs/DialogModelNotAvailable.svelte';
// Compatibility aliases
export { default as ActionButton } from './actions/ActionIcon.svelte';
export { default as ActionDropdown } from './navigation/DropdownMenuActions.svelte';
export { default as CopyToClipboardIcon } from './actions/ActionIconCopyToClipboard.svelte';
export { default as RemoveButton } from './actions/ActionIconRemove.svelte';
@@ -31,8 +31,6 @@
forceForegroundText?: boolean; forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */ /** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean; useGlobalSelection?: boolean;
/** Optional compatibility prop for context-aware selectors. */
upToMessageId?: string;
} }
let { let {
@@ -41,9 +39,7 @@
onModelChange, onModelChange,
disabled = false, disabled = false,
forceForegroundText = false, forceForegroundText = false,
useGlobalSelection = false, useGlobalSelection = false
// eslint-disable-next-line @typescript-eslint/no-unused-vars
upToMessageId: _upToMessageId = undefined
}: Props = $props(); }: Props = $props();
let options = $derived(modelOptions()); let options = $derived(modelOptions());
@@ -0,0 +1,37 @@
// Agentic tool call tag markers
export const AGENTIC_TAGS = {
TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>',
TOOL_CALL_END: '<<<AGENTIC_TOOL_CALL_END>>>',
TOOL_NAME_PREFIX: '<<<TOOL_NAME:',
TOOL_ARGS_START: '<<<TOOL_ARGS_START>>>',
TOOL_ARGS_END: '<<<TOOL_ARGS_END>>>',
TAG_SUFFIX: '>>>'
} as const;
export const REASONING_TAGS = {
START: '<<<reasoning_content_start>>>',
END: '<<<reasoning_content_end>>>'
} as const;
// Regex patterns for parsing agentic content
export const AGENTIC_REGEX = {
// Matches completed tool calls (with END marker)
COMPLETED_TOOL_CALL:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*?)<<<AGENTIC_TOOL_CALL_END>>>/g,
// Matches pending tool call (has NAME and ARGS but no END)
PENDING_TOOL_CALL:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*)$/,
// Matches partial tool call (has START and NAME, ARGS still streaming)
PARTIAL_WITH_NAME:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*)$/,
// Matches early tool call (just START marker)
EARLY_MATCH: /<<<AGENTIC_TOOL_CALL_START>>>([\s\S]*)$/,
// Matches partial marker at end of content
PARTIAL_MARKER: /<<<[A-Za-z_]*$/,
// Matches reasoning content blocks (including tags)
REASONING_BLOCK: /<<<reasoning_content_start>>>[\s\S]*?<<<reasoning_content_end>>>/g,
// Matches an opening reasoning tag and any remaining content (unterminated)
REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/,
// Matches tool name inside content
TOOL_NAME_EXTRACT: /<<<TOOL_NAME:([^>]+)>>>/
} as const;
@@ -0,0 +1,2 @@
export const ATTACHMENT_LABEL_FILE = 'File';
export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File';
+15 -6
View File
@@ -3,31 +3,40 @@
*/ */
/** /**
* Default TTL (Time-To-Live) for cache entries in milliseconds. * Default TTL (Time-To-Live) for cache entries in milliseconds
* @default 5 minutes
*/ */
export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
/** /**
* Default maximum number of entries in a cache. * Default maximum number of entries in a cache
* @default 100
*/ */
export const DEFAULT_CACHE_MAX_ENTRIES = 100; export const DEFAULT_CACHE_MAX_ENTRIES = 100;
/** /**
* TTL for model props cache in milliseconds. * TTL for model props cache in milliseconds
* Props don't change frequently, so we can cache them longer
* @default 10 minutes
*/ */
export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000; export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000;
/** /**
* Maximum number of model props to cache. * Maximum number of model props to cache
* @default 50
*/ */
export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50; export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50;
/** /**
* Maximum number of inactive conversation states to keep in memory. * Maximum number of inactive conversation states to keep in memory
* States for conversations beyond this limit will be cleaned up
* @default 10
*/ */
export const MAX_INACTIVE_CONVERSATION_STATES = 10; export const MAX_INACTIVE_CONVERSATION_STATES = 10;
/** /**
* Maximum age (in ms) for inactive conversation states before cleanup. * Maximum age (in ms) for inactive conversation states before cleanup
* States older than this will be removed during cleanup
* @default 30 minutes
*/ */
export const INACTIVE_CONVERSATION_STATE_MAX_AGE_MS = 30 * 60 * 1000; export const INACTIVE_CONVERSATION_STATE_MAX_AGE_MS = 30 * 60 * 1000;
@@ -1 +0,0 @@
export const DEFAULT_CONTEXT = 4096;
@@ -1 +0,0 @@
export { INPUT_CLASSES } from './css-classes';
@@ -1,12 +1,14 @@
import { ColorMode } from '$lib/enums/ui';
import { Monitor, Moon, Sun } from '@lucide/svelte';
export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> = { export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> = {
// Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value. // Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value. Do not use null or undefined for default value.
// Do not use nested objects, keep it single level. Prefix the key if you need to group them. // Do not use nested objects, keep it single level. Prefix the key if you need to group them.
apiKey: '', apiKey: '',
systemMessage: '', systemMessage: '',
showSystemMessage: true, showSystemMessage: true,
theme: 'system', theme: ColorMode.SYSTEM,
showThoughtInProgress: false, showThoughtInProgress: false,
showToolCalls: false,
disableReasoningParsing: false, disableReasoningParsing: false,
showRawOutputSwitch: false, showRawOutputSwitch: false,
keepStatsVisible: false, keepStatsVisible: false,
@@ -91,8 +93,6 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).', max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).',
custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.', custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
showThoughtInProgress: 'Expand thought process by default when generating messages.', showThoughtInProgress: 'Expand thought process by default when generating messages.',
showToolCalls:
'Display tool call labels and payloads from Harmony-compatible delta.tool_calls data below assistant messages.',
disableReasoningParsing: disableReasoningParsing:
'Send reasoning_format=none to prevent server-side extraction of reasoning tokens into separate field', 'Send reasoning_format=none to prevent server-side extraction of reasoning tokens into separate field',
showRawOutputSwitch: showRawOutputSwitch:
@@ -118,3 +118,9 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
enableContinueGeneration: enableContinueGeneration:
'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.' 'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.'
}; };
export const SETTINGS_COLOR_MODES_CONFIG = [
{ value: ColorMode.SYSTEM, label: 'System', icon: Monitor },
{ value: ColorMode.LIGHT, label: 'Light', icon: Sun },
{ value: ColorMode.DARK, label: 'Dark', icon: Moon }
];
@@ -0,0 +1,52 @@
/**
* Settings key constants for ChatSettings configuration.
*
* These keys correspond to properties in SettingsConfigType and are used
* in settings field configurations to ensure consistency.
*/
export const SETTINGS_KEYS = {
// General
THEME: 'theme',
API_KEY: 'apiKey',
SYSTEM_MESSAGE: 'systemMessage',
PASTE_LONG_TEXT_TO_FILE_LEN: 'pasteLongTextToFileLen',
COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText',
ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration',
PDF_AS_IMAGE: 'pdfAsImage',
ASK_FOR_TITLE_CONFIRMATION: 'askForTitleConfirmation',
// Display
SHOW_MESSAGE_STATS: 'showMessageStats',
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
KEEP_STATS_VISIBLE: 'keepStatsVisible',
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
AUTO_SHOW_SIDEBAR_ON_NEW_CHAT: 'autoShowSidebarOnNewChat',
// Sampling
TEMPERATURE: 'temperature',
DYNATEMP_RANGE: 'dynatemp_range',
DYNATEMP_EXPONENT: 'dynatemp_exponent',
TOP_K: 'top_k',
TOP_P: 'top_p',
MIN_P: 'min_p',
XTC_PROBABILITY: 'xtc_probability',
XTC_THRESHOLD: 'xtc_threshold',
TYP_P: 'typ_p',
MAX_TOKENS: 'max_tokens',
SAMPLERS: 'samplers',
BACKEND_SAMPLING: 'backend_sampling',
// Penalties
REPEAT_LAST_N: 'repeat_last_n',
REPEAT_PENALTY: 'repeat_penalty',
PRESENCE_PENALTY: 'presence_penalty',
FREQUENCY_PENALTY: 'frequency_penalty',
DRY_MULTIPLIER: 'dry_multiplier',
DRY_BASE: 'dry_base',
DRY_ALLOWED_LENGTH: 'dry_allowed_length',
DRY_PENALTY_LAST_N: 'dry_penalty_last_n',
// Developer
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
CUSTOM: 'custom'
} as const;
+21 -1
View File
@@ -136,9 +136,28 @@ export enum FileExtensionText {
CS = '.cs' CS = '.cs'
} }
// MIME type prefixes and includes for content detection
export enum MimeTypePrefix {
IMAGE = 'image/',
TEXT = 'text'
}
export enum MimeTypeIncludes {
JSON = 'json',
JAVASCRIPT = 'javascript',
TYPESCRIPT = 'typescript'
}
// URI patterns for content detection
export enum UriPattern {
DATABASE_KEYWORD = 'database',
DATABASE_SCHEME = 'db://'
}
// MIME type enums // MIME type enums
export enum MimeTypeApplication { export enum MimeTypeApplication {
PDF = 'application/pdf' PDF = 'application/pdf',
OCTET_STREAM = 'application/octet-stream'
} }
export enum MimeTypeAudio { export enum MimeTypeAudio {
@@ -152,6 +171,7 @@ export enum MimeTypeAudio {
export enum MimeTypeImage { export enum MimeTypeImage {
JPEG = 'image/jpeg', JPEG = 'image/jpeg',
JPG = 'image/jpg',
PNG = 'image/png', PNG = 'image/png',
GIF = 'image/gif', GIF = 'image/gif',
WEBP = 'image/webp', WEBP = 'image/webp',
+8 -5
View File
@@ -2,11 +2,11 @@ export { AttachmentType } from './attachment';
export { export {
ChatMessageStatsView, ChatMessageStatsView,
ReasoningFormat, ContentPartType,
ErrorDialogType,
MessageRole, MessageRole,
MessageType, MessageType,
ContentPartType, ReasoningFormat
ErrorDialogType
} from './chat'; } from './chat';
export { export {
@@ -19,6 +19,9 @@ export {
FileExtensionAudio, FileExtensionAudio,
FileExtensionPdf, FileExtensionPdf,
FileExtensionText, FileExtensionText,
MimeTypePrefix,
MimeTypeIncludes,
UriPattern,
MimeTypeApplication, MimeTypeApplication,
MimeTypeAudio, MimeTypeAudio,
MimeTypeImage, MimeTypeImage,
@@ -31,6 +34,6 @@ export { ServerRole, ServerModelStatus } from './server';
export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings'; export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings';
export { KeyboardKey } from './keyboard'; export { ColorMode, UrlPrefix } from './ui';
export { UrlPrefix } from './ui'; export { KeyboardKey } from './keyboard';
+7 -1
View File
@@ -1,5 +1,11 @@
export enum ColorMode {
LIGHT = 'light',
DARK = 'dark',
SYSTEM = 'system'
}
/** /**
* URL prefixes for protocol detection. * URL prefixes for protocol detection
*/ */
export enum UrlPrefix { export enum UrlPrefix {
DATA = 'data:', DATA = 'data:',
@@ -1,104 +0,0 @@
import { modelsStore } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { toast } from 'svelte-sonner';
import type { ModelModalities } from '$lib/types';
interface UseModelChangeValidationOptions {
/**
* Function to get required modalities for validation.
*/
getRequiredModalities: () => ModelModalities;
/**
* Optional callback to execute after successful validation.
*/
onSuccess?: (modelName: string) => void;
/**
* Optional callback for rollback on validation failure.
*/
onValidationFailure?: (previousModelId: string | null) => Promise<void>;
}
export function useModelChangeValidation(options: UseModelChangeValidationOptions) {
const { getRequiredModalities, onSuccess, onValidationFailure } = options;
let previousSelectedModelId: string | null = null;
const isRouter = $derived(isRouterMode());
async function handleModelChange(modelId: string, modelName: string): Promise<boolean> {
try {
if (onValidationFailure) {
previousSelectedModelId = modelsStore.selectedModelId;
}
let hasLoadedModel = false;
const isModelLoadedBefore = modelsStore.isModelLoaded(modelName);
if (isRouter && !isModelLoadedBefore) {
try {
await modelsStore.loadModel(modelName);
hasLoadedModel = true;
} catch {
toast.error(`Failed to load model "${modelName}"`);
return false;
}
}
const props = await modelsStore.fetchModelProps(modelName);
if (props?.modalities) {
const requiredModalities = getRequiredModalities();
const missingModalities: string[] = [];
if (requiredModalities.vision && !props.modalities.vision) {
missingModalities.push('vision');
}
if (requiredModalities.audio && !props.modalities.audio) {
missingModalities.push('audio');
}
if (missingModalities.length > 0) {
toast.error(
`Model "${modelName}" doesn't support required modalities: ${missingModalities.join(', ')}. Please select a different model.`
);
if (isRouter && hasLoadedModel) {
try {
await modelsStore.unloadModel(modelName);
} catch (error) {
console.error('Failed to unload incompatible model:', error);
}
}
if (onValidationFailure && previousSelectedModelId) {
await onValidationFailure(previousSelectedModelId);
}
return false;
}
}
await modelsStore.selectModelById(modelId);
if (onSuccess) {
onSuccess(modelName);
}
return true;
} catch (error) {
console.error('Failed to change model:', error);
toast.error('Failed to validate model capabilities');
if (onValidationFailure && previousSelectedModelId) {
await onValidationFailure(previousSelectedModelId);
}
return false;
}
}
return {
handleModelChange
};
}
@@ -1,42 +1,52 @@
import { getJsonHeaders } from '$lib/utils'; import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
import { AttachmentType } from '$lib/enums'; import { ATTACHMENT_LABEL_PDF_FILE } from '$lib/constants/attachment-labels';
import {
AttachmentType,
ContentPartType,
MessageRole,
ReasoningFormat,
UrlPrefix
} from '$lib/enums';
import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
import { modelsStore } from '$lib/stores/models.svelte';
import { AGENTIC_REGEX } from '$lib/constants/agentic';
/**
* ChatService - Low-level API communication layer for Chat Completions
*
* **Terminology - Chat vs Conversation:**
* - **Chat**: The active interaction space with the Chat Completions API. This service
* handles the real-time communication with the AI backend - sending messages, receiving
* streaming responses, and managing request lifecycles. "Chat" is ephemeral and runtime-focused.
* - **Conversation**: The persistent database entity storing all messages and metadata.
* Managed by ConversationsService/Store, conversations persist across sessions.
*
* This service handles direct communication with the llama-server's Chat Completions API.
* It provides the network layer abstraction for AI model interactions while remaining
* stateless and focused purely on API communication.
*
* **Architecture & Relationships:**
* - **ChatService** (this class): Stateless API communication layer
* - Handles HTTP requests/responses with the llama-server
* - Manages streaming and non-streaming response parsing
* - Provides per-conversation request abortion capabilities
* - Converts database messages to API format
* - Handles error translation for server responses
*
* - **chatStore**: Uses ChatService for all AI model communication
* - **conversationsStore**: Provides message context for API requests
*
* **Key Responsibilities:**
* - Message format conversion (DatabaseMessage API format)
* - Streaming response handling with real-time callbacks
* - Reasoning content extraction and processing
* - File attachment processing (images, PDFs, audio, text)
* - Request lifecycle management (abort via AbortSignal)
*/
export class ChatService { export class ChatService {
// ───────────────────────────────────────────────────────────────────────────── private static stripReasoningContent(
// Messaging content: ApiChatMessageData['content'] | null | undefined
// ───────────────────────────────────────────────────────────────────────────── ): ApiChatMessageData['content'] | null | undefined {
if (!content) {
return content;
}
if (typeof content === 'string') {
return content
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '');
}
if (!Array.isArray(content)) {
return content;
}
return content.map((part: ApiChatMessageContentPart) => {
if (part.type !== ContentPartType.TEXT || !part.text) return part;
return {
...part,
text: part.text
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '')
};
});
}
/**
*
*
* Messaging
*
*
*/
/** /**
* Sends a chat completion request to the llama.cpp server. * Sends a chat completion request to the llama.cpp server.
@@ -63,6 +73,8 @@ export class ChatService {
onToolCallChunk, onToolCallChunk,
onModel, onModel,
onTimings, onTimings,
// Tools for function calling
tools,
// Generation parameters // Generation parameters
temperature, temperature,
max_tokens, max_tokens,
@@ -97,6 +109,7 @@ export class ChatService {
.map((msg) => { .map((msg) => {
if ('id' in msg && 'convId' in msg && 'timestamp' in msg) { if ('id' in msg && 'convId' in msg && 'timestamp' in msg) {
const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }; const dbMsg = msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] };
return ChatService.convertDbMessageToApiChatMessageData(dbMsg); return ChatService.convertDbMessageToApiChatMessageData(dbMsg);
} else { } else {
return msg as ApiChatMessageData; return msg as ApiChatMessageData;
@@ -104,7 +117,7 @@ export class ChatService {
}) })
.filter((msg) => { .filter((msg) => {
// Filter out empty system messages // Filter out empty system messages
if (msg.role === 'system') { if (msg.role === MessageRole.SYSTEM) {
const content = typeof msg.content === 'string' ? msg.content : ''; const content = typeof msg.content === 'string' ? msg.content : '';
return content.trim().length > 0; return content.trim().length > 0;
@@ -113,13 +126,41 @@ export class ChatService {
return true; return true;
}); });
// Filter out image attachments if the model doesn't support vision
if (options.model && !modelsStore.modelSupportsVision(options.model)) {
normalizedMessages.forEach((msg) => {
if (Array.isArray(msg.content)) {
msg.content = msg.content.filter((part: ApiChatMessageContentPart) => {
if (part.type === ContentPartType.IMAGE_URL) {
console.info(
`[ChatService] Skipping image attachment in message history (model "${options.model}" does not support vision)`
);
return false;
}
return true;
});
// If only text remains and it's a single part, simplify to string
if (msg.content.length === 1 && msg.content[0].type === ContentPartType.TEXT) {
msg.content = msg.content[0].text;
}
}
});
}
const requestBody: ApiChatCompletionRequest = { const requestBody: ApiChatCompletionRequest = {
messages: normalizedMessages.map((msg: ApiChatMessageData) => ({ messages: normalizedMessages.map((msg: ApiChatMessageData) => ({
role: msg.role, role: msg.role,
content: msg.content // Strip reasoning tags/content from the prompt to avoid polluting KV cache.
// TODO: investigate backend expectations for reasoning tags and add a toggle if needed.
content: ChatService.stripReasoningContent(msg.content),
tool_calls: msg.tool_calls,
tool_call_id: msg.tool_call_id
})), })),
stream, stream,
return_progress: stream ? true : undefined return_progress: stream ? true : undefined,
tools: tools && tools.length > 0 ? tools : undefined
}; };
// Include model in request if provided (required in ROUTER mode) // Include model in request if provided (required in ROUTER mode)
@@ -127,7 +168,9 @@ export class ChatService {
requestBody.model = options.model; requestBody.model = options.model;
} }
requestBody.reasoning_format = disableReasoningParsing ? 'none' : 'auto'; requestBody.reasoning_format = disableReasoningParsing
? ReasoningFormat.NONE
: ReasoningFormat.AUTO;
if (temperature !== undefined) requestBody.temperature = temperature; if (temperature !== undefined) requestBody.temperature = temperature;
if (max_tokens !== undefined) { if (max_tokens !== undefined) {
@@ -183,9 +226,11 @@ export class ChatService {
if (!response.ok) { if (!response.ok) {
const error = await ChatService.parseErrorResponse(response); const error = await ChatService.parseErrorResponse(response);
if (onError) { if (onError) {
onError(error); onError(error);
} }
throw error; throw error;
} }
@@ -202,6 +247,7 @@ export class ChatService {
conversationId, conversationId,
signal signal
); );
return; return;
} else { } else {
return ChatService.handleNonStreamResponse( return ChatService.handleNonStreamResponse(
@@ -213,7 +259,7 @@ export class ChatService {
); );
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (isAbortError(error)) {
console.log('Chat completion request was aborted'); console.log('Chat completion request was aborted');
return; return;
} }
@@ -240,16 +286,22 @@ export class ChatService {
} }
console.error('Error in sendMessage:', error); console.error('Error in sendMessage:', error);
if (onError) { if (onError) {
onError(userFriendlyError); onError(userFriendlyError);
} }
throw userFriendlyError; throw userFriendlyError;
} }
} }
// ───────────────────────────────────────────────────────────────────────────── /**
// Streaming *
// ───────────────────────────────────────────────────────────────────────────── *
* Streaming
*
*
*/
/** /**
* Handles streaming response from the chat completion API * Handles streaming response from the chat completion API
@@ -323,6 +375,10 @@ export class ChatService {
const serializedToolCalls = JSON.stringify(aggregatedToolCalls); const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
if (import.meta.env.DEV) {
console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
}
if (!serializedToolCalls) { if (!serializedToolCalls) {
return; return;
} }
@@ -349,10 +405,11 @@ export class ChatService {
for (const line of lines) { for (const line of lines) {
if (abortSignal?.aborted) break; if (abortSignal?.aborted) break;
if (line.startsWith('data: ')) { if (line.startsWith(UrlPrefix.DATA)) {
const data = line.slice(6); const data = line.slice(6);
if (data === '[DONE]') { if (data === '[DONE]') {
streamFinished = true; streamFinished = true;
continue; continue;
} }
@@ -458,6 +515,7 @@ export class ChatService {
if (!responseText.trim()) { if (!responseText.trim()) {
const noResponseError = new Error('No response received from server. Please try again.'); const noResponseError = new Error('No response received from server. Please try again.');
throw noResponseError; throw noResponseError;
} }
@@ -472,10 +530,6 @@ export class ChatService {
const reasoningContent = data.choices[0]?.message?.reasoning_content; const reasoningContent = data.choices[0]?.message?.reasoning_content;
const toolCalls = data.choices[0]?.message?.tool_calls; const toolCalls = data.choices[0]?.message?.tool_calls;
if (reasoningContent) {
console.log('Full reasoning content:', reasoningContent);
}
let serializedToolCalls: string | undefined; let serializedToolCalls: string | undefined;
if (toolCalls && toolCalls.length > 0) { if (toolCalls && toolCalls.length > 0) {
@@ -491,6 +545,7 @@ export class ChatService {
if (!content.trim() && !serializedToolCalls) { if (!content.trim() && !serializedToolCalls) {
const noResponseError = new Error('No response received from server. Please try again.'); const noResponseError = new Error('No response received from server. Please try again.');
throw noResponseError; throw noResponseError;
} }
@@ -563,9 +618,13 @@ export class ChatService {
return result; return result;
} }
// ───────────────────────────────────────────────────────────────────────────── /**
// Conversion *
// ───────────────────────────────────────────────────────────────────────────── *
* Conversion
*
*
*/
/** /**
* Converts a database message with attachments to API chat message format. * Converts a database message with attachments to API chat message format.
@@ -582,22 +641,48 @@ export class ChatService {
static convertDbMessageToApiChatMessageData( static convertDbMessageToApiChatMessageData(
message: DatabaseMessage & { extra?: DatabaseMessageExtra[] } message: DatabaseMessage & { extra?: DatabaseMessageExtra[] }
): ApiChatMessageData { ): ApiChatMessageData {
if (!message.extra || message.extra.length === 0) { // Handle tool result messages (role: 'tool')
if (message.role === MessageRole.TOOL && message.toolCallId) {
return { return {
role: message.role as 'user' | 'assistant' | 'system', role: MessageRole.TOOL,
content: message.content,
tool_call_id: message.toolCallId
};
}
// Parse tool calls for assistant messages
let toolCalls: ApiChatCompletionToolCall[] | undefined;
if (message.toolCalls) {
try {
toolCalls = JSON.parse(message.toolCalls);
} catch {
// Ignore parse errors for malformed tool calls
}
}
if (!message.extra || message.extra.length === 0) {
const result: ApiChatMessageData = {
role: message.role as MessageRole,
content: message.content content: message.content
}; };
if (toolCalls && toolCalls.length > 0) {
result.tool_calls = toolCalls;
}
return result;
} }
const contentParts: ApiChatMessageContentPart[] = []; const contentParts: ApiChatMessageContentPart[] = [];
if (message.content) { if (message.content) {
contentParts.push({ contentParts.push({
type: 'text', type: ContentPartType.TEXT,
text: message.content text: message.content
}); });
} }
// Include images from all messages
const imageFiles = message.extra.filter( const imageFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile => (extra: DatabaseMessageExtra): extra is DatabaseMessageExtraImageFile =>
extra.type === AttachmentType.IMAGE extra.type === AttachmentType.IMAGE
@@ -605,7 +690,7 @@ export class ChatService {
for (const image of imageFiles) { for (const image of imageFiles) {
contentParts.push({ contentParts.push({
type: 'image_url', type: ContentPartType.IMAGE_URL,
image_url: { url: image.base64Url } image_url: { url: image.base64Url }
}); });
} }
@@ -617,8 +702,8 @@ export class ChatService {
for (const textFile of textFiles) { for (const textFile of textFiles) {
contentParts.push({ contentParts.push({
type: 'text', type: ContentPartType.TEXT,
text: `\n\n--- File: ${textFile.name} ---\n${textFile.content}` text: formatAttachmentText('File', textFile.name, textFile.content)
}); });
} }
@@ -630,8 +715,8 @@ export class ChatService {
for (const legacyContextFile of legacyContextFiles) { for (const legacyContextFile of legacyContextFiles) {
contentParts.push({ contentParts.push({
type: 'text', type: ContentPartType.TEXT,
text: `\n\n--- File: ${legacyContextFile.name} ---\n${legacyContextFile.content}` text: formatAttachmentText('File', legacyContextFile.name, legacyContextFile.content)
}); });
} }
@@ -642,7 +727,7 @@ export class ChatService {
for (const audio of audioFiles) { for (const audio of audioFiles) {
contentParts.push({ contentParts.push({
type: 'input_audio', type: ContentPartType.INPUT_AUDIO,
input_audio: { input_audio: {
data: audio.base64Data, data: audio.base64Data,
format: audio.mimeType.includes('wav') ? 'wav' : 'mp3' format: audio.mimeType.includes('wav') ? 'wav' : 'mp3'
@@ -659,27 +744,33 @@ export class ChatService {
if (pdfFile.processedAsImages && pdfFile.images) { if (pdfFile.processedAsImages && pdfFile.images) {
for (let i = 0; i < pdfFile.images.length; i++) { for (let i = 0; i < pdfFile.images.length; i++) {
contentParts.push({ contentParts.push({
type: 'image_url', type: ContentPartType.IMAGE_URL,
image_url: { url: pdfFile.images[i] } image_url: { url: pdfFile.images[i] }
}); });
} }
} else { } else {
contentParts.push({ contentParts.push({
type: 'text', type: ContentPartType.TEXT,
text: `\n\n--- PDF File: ${pdfFile.name} ---\n${pdfFile.content}` text: formatAttachmentText(ATTACHMENT_LABEL_PDF_FILE, pdfFile.name, pdfFile.content)
}); });
} }
} }
return { const result: ApiChatMessageData = {
role: message.role as 'user' | 'assistant' | 'system', role: message.role as MessageRole,
content: contentParts content: contentParts
}; };
return result;
} }
// ───────────────────────────────────────────────────────────────────────────── /**
// Utilities *
// ───────────────────────────────────────────────────────────────────────────── *
* Utilities
*
*
*/
/** /**
* Parses error response and creates appropriate error with context information * Parses error response and creates appropriate error with context information
@@ -714,6 +805,7 @@ export class ChatService {
contextInfo?: { n_prompt_tokens: number; n_ctx: number }; contextInfo?: { n_prompt_tokens: number; n_ctx: number };
}; };
fallback.name = 'HttpError'; fallback.name = 'HttpError';
return fallback; return fallback;
} }
} }
@@ -745,18 +837,26 @@ export class ChatService {
// 1) root (some implementations provide `model` at the top level) // 1) root (some implementations provide `model` at the top level)
const rootModel = getTrimmedString(root.model); const rootModel = getTrimmedString(root.model);
if (rootModel) return rootModel; if (rootModel) {
return rootModel;
}
// 2) streaming choice (delta) or final response (message) // 2) streaming choice (delta) or final response (message)
const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined; const firstChoice = Array.isArray(root.choices) ? asRecord(root.choices[0]) : undefined;
if (!firstChoice) return undefined; if (!firstChoice) {
return undefined;
}
// priority: delta.model (first chunk) else message.model (final response) // priority: delta.model (first chunk) else message.model (final response)
const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model); const deltaModel = getTrimmedString(asRecord(firstChoice.delta)?.model);
if (deltaModel) return deltaModel; if (deltaModel) {
return deltaModel;
}
const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model); const messageModel = getTrimmedString(asRecord(firstChoice.message)?.model);
if (messageModel) return messageModel; if (messageModel) {
return messageModel;
}
// avoid guessing from non-standard locations (metadata, etc.) // avoid guessing from non-standard locations (metadata, etc.)
return undefined; return undefined;
+211 -2
View File
@@ -1,5 +1,214 @@
export { ChatService } from './chat'; /**
*
* SERVICES
*
* Stateless service layer for API communication and data operations.
* Services handle protocol-level concerns (HTTP, WebSocket, MCP, IndexedDB)
* without managing reactive state that responsibility belongs to stores.
*
* **Design Principles:**
* - All methods are static no instance state
* - Pure I/O operations (network requests, database queries)
* - No Svelte runes or reactive primitives
* - Error handling at the protocol level; business-level error handling in stores
*
* **Architecture (bottom to top):**
* - **Services** (this layer): Stateless protocol communication
* - **Stores**: Reactive state management consuming services
* - **Components**: UI consuming stores
*
*/
/**
* **ChatService** - Chat Completions API communication layer
*
* Handles direct communication with the llama-server's `/v1/chat/completions` endpoint.
* Provides streaming and non-streaming response parsing, message format conversion
* (DatabaseMessage API format), and request lifecycle management.
*
* **Terminology - Chat vs Conversation:**
* - **Chat**: The active interaction space with the Chat Completions API. Ephemeral and
* runtime-focused sending messages, receiving streaming responses, managing request lifecycles.
* - **Conversation**: The persistent database entity storing all messages and metadata.
* Managed by conversationsStore, conversations persist across sessions.
*
* **Architecture & Relationships:**
* - **ChatService** (this class): Stateless API communication layer
* - Handles HTTP requests/responses with the llama-server
* - Manages streaming and non-streaming response parsing
* - Converts database messages to API format (multimodal, tool calls)
* - Handles error translation with user-friendly messages
*
* - **chatStore**: Primary consumer uses ChatService for all AI model communication
* - **agenticStore**: Uses ChatService for multi-turn agentic loop streaming
* - **conversationsStore**: Provides message context for API requests
*
* **Key Responsibilities:**
* - Streaming response handling with real-time content/reasoning/tool-call callbacks
* - Non-streaming response parsing with complete response extraction
* - Database message to API format conversion (attachments, tool calls, multimodal)
* - Tool call delta merging for incremental streaming aggregation
* - Request parameter assembly (sampling, penalties, custom params)
* - File attachment processing (images, PDFs, audio, text, MCP prompts/resources)
* - Reasoning content stripping from prompt history to avoid KV cache pollution
* - Error translation (network, timeout, server errors user-friendly messages)
*
* @see chatStore in stores/chat.svelte.ts primary consumer for chat state management
* @see agenticStore in stores/agentic.svelte.ts uses ChatService for agentic loop streaming
* @see conversationsStore in stores/conversations.svelte.ts provides message context
*/
export { ChatService } from './chat.service';
/**
* **DatabaseService** - IndexedDB persistence layer via Dexie ORM
*
* Provides stateless data access for conversations and messages using IndexedDB.
* Handles all low-level storage operations including branching tree structures,
* cascade deletions, and transaction safety for multi-table operations.
*
* **Architecture & Relationships (bottom to top):**
* - **DatabaseService** (this class): Stateless IndexedDB operations
* - Lowest layer direct Dexie/IndexedDB communication
* - Pure CRUD operations without business logic
* - Handles branching tree structure (parent-child relationships)
* - Provides transaction safety for multi-table operations
*
* - **conversationsStore**: Reactive state management layer
* - Uses DatabaseService for all persistence operations
* - Manages conversation list, active conversation, and messages in memory
*
* - **chatStore**: Active AI interaction management
* - Uses conversationsStore for conversation context
* - Directly uses DatabaseService for message CRUD during streaming
*
* **Key Responsibilities:**
* - Conversation CRUD (create, read, update, delete)
* - Message CRUD with branching support (parent-child relationships)
* - Root message and system prompt creation
* - Cascade deletion of message branches (descendants)
* - Transaction-safe multi-table operations
* - Conversation import with duplicate detection
*
* **Database Schema:**
* - `conversations`: id, lastModified, currNode, name
* - `messages`: id, convId, type, role, timestamp, parent, children
*
* **Branching Model:**
* Messages form a tree structure where each message can have multiple children,
* enabling conversation branching and alternative response paths. The conversation's
* `currNode` tracks the currently active branch endpoint.
*
* @see conversationsStore in stores/conversations.svelte.ts reactive layer on top of DatabaseService
* @see chatStore in stores/chat.svelte.ts uses DatabaseService directly for message CRUD during streaming
*/
export { DatabaseService } from './database.service'; export { DatabaseService } from './database.service';
/**
* **ModelsService** - Model management API communication
*
* Handles communication with model-related endpoints for both MODEL (single model)
* and ROUTER (multi-model) server modes. Provides model listing, loading/unloading,
* and status checking without managing any model state.
*
* **Architecture & Relationships:**
* - **ModelsService** (this class): Stateless HTTP communication
* - Sends requests to model endpoints
* - Parses and returns typed API responses
* - Provides model status utility methods
*
* - **modelsStore**: Primary consumer manages reactive model state
* - Calls ModelsService for all model API operations
* - Handles polling, caching, and state updates
*
* **Key Responsibilities:**
* - List available models via OpenAI-compatible `/v1/models` endpoint
* - Load/unload models via `/models/load` and `/models/unload` (ROUTER mode)
* - Model status queries (loaded, loading)
*
* **Server Mode Behavior:**
* - **MODEL mode**: Only `list()` is relevant single model always loaded
* - **ROUTER mode**: Full lifecycle `list()`, `listRouter()`, `load()`, `unload()`
*
* **Endpoints:**
* - `GET /v1/models` OpenAI-compatible model list (both modes)
* - `POST /models/load` Load a model (ROUTER mode only)
* - `POST /models/unload` Unload a model (ROUTER mode only)
*
* @see modelsStore in stores/models.svelte.ts primary consumer for reactive model state
*/
export { ModelsService } from './models.service'; export { ModelsService } from './models.service';
/**
* **PropsService** - Server properties and capabilities retrieval
*
* Fetches server configuration, model information, and capabilities from the `/props`
* endpoint. Supports both global server props and per-model props (ROUTER mode).
*
* **Architecture & Relationships:**
* - **PropsService** (this class): Stateless HTTP communication
* - Fetches server properties from `/props` endpoint
* - Handles authentication and request parameters
* - Returns typed `ApiLlamaCppServerProps` responses
*
* - **serverStore**: Consumes global server properties (role detection, connection state)
* - **modelsStore**: Consumes per-model properties (modalities, context size)
* - **settingsStore**: Syncs default generation parameters from props response
*
* **Key Responsibilities:**
* - Fetch global server properties (default generation settings, modalities)
* - Fetch per-model properties in ROUTER mode via `?model=<id>` parameter
* - Handle autoload control to prevent unintended model loading
*
* **API Behavior:**
* - `GET /props` Global server props (MODEL mode: includes modalities)
* - `GET /props?model=<id>` Per-model props (ROUTER mode: model-specific modalities)
* - `&autoload=false` Prevents model auto-loading when querying props
*
* @see serverStore in stores/server.svelte.ts consumes global server props
* @see modelsStore in stores/models.svelte.ts consumes per-model props for modalities
* @see settingsStore in stores/settings.svelte.ts syncs default generation params from props
*/
export { PropsService } from './props.service'; export { PropsService } from './props.service';
export { ParameterSyncService, SYNCABLE_PARAMETERS } from './parameter-sync.service';
/**
* **ParameterSyncService** - Server defaults and user settings synchronization
*
* Manages the complex logic of merging server-provided default parameters with
* user-configured overrides. Ensures the UI reflects the actual server state
* while preserving user customizations. Tracks parameter sources (server default
* vs user override) for display in the settings UI.
*
* **Architecture & Relationships:**
* - **ParameterSyncService** (this class): Stateless sync logic
* - Pure functions for parameter extraction, merging, and diffing
* - No side effects receives data in, returns data out
* - Handles floating-point precision normalization
*
* - **settingsStore**: Primary consumer calls sync methods during:
* - Initial load (`syncWithServerDefaults`)
* - Settings reset (`forceSyncWithServerDefaults`)
* - Parameter info queries (`getParameterInfo`)
*
* - **PropsService**: Provides raw server props that feed into extraction
*
* **Key Responsibilities:**
* - Extract syncable parameters from server `/props` response
* - Merge server defaults with user overrides (user wins)
* - Track parameter source (Custom vs Default) for UI badges
* - Validate server parameter values by type (number, string, boolean)
* - Create diffs between current settings and server defaults
* - Floating-point precision normalization for consistent comparisons
*
* **Parameter Source Priority:**
* 1. **User Override** (Custom badge) explicitly set by user in settings
* 2. **Server Default** (Default badge) from `/props` endpoint
* 3. **App Default** hardcoded fallback when server props unavailable
*
* **Exports:**
* - `ParameterSyncService` class static methods for sync logic
* - `SYNCABLE_PARAMETERS` mapping of webui setting keys to server parameter keys
*
* @see settingsStore in stores/settings.svelte.ts primary consumer for settings sync
* @see ChatSettingsParameterSourceIndicator displays parameter source badges in UI
*/
export { ParameterSyncService } from './parameter-sync.service';
File diff suppressed because it is too large Load Diff
@@ -1,54 +1,38 @@
import { browser } from '$app/environment'; /**
* conversationsStore - Reactive State Store for Conversations
*
* Manages conversation lifecycle, persistence, navigation.
*
* **Architecture & Relationships:**
* - **DatabaseService**: Stateless IndexedDB layer
* - **conversationsStore** (this): Reactive state + business logic
* - **chatStore**: Chat-specific state (streaming, loading)
*
* **Key Responsibilities:**
* - Conversation CRUD (create, load, delete)
* - Message management and tree navigation
* - Import/Export functionality
* - Title management with confirmation
*
* @see DatabaseService in services/database.ts for IndexedDB operations
*/
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { DatabaseService } from '$lib/services/database.service'; import { DatabaseService } from '$lib/services/database.service';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode } from '$lib/utils'; import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
import { AttachmentType } from '$lib/enums'; import { MessageRole } from '$lib/enums';
/**
* conversationsStore - Persistent conversation data and lifecycle management
*
* **Terminology - Chat vs Conversation:**
* - **Chat**: The active interaction space with the Chat Completions API. Represents the
* real-time streaming session, loading states, and UI visualization of AI communication.
* Managed by chatStore, a "chat" is ephemeral and exists during active AI interactions.
* - **Conversation**: The persistent database entity storing all messages and metadata.
* A "conversation" survives across sessions, page reloads, and browser restarts.
* It contains the complete message history, branching structure, and conversation metadata.
*
* This store manages all conversation-level data and operations including creation, loading,
* deletion, and navigation. It maintains the list of conversations and the currently active
* conversation with its message history, providing reactive state for UI components.
*
* **Architecture & Relationships:**
* - **conversationsStore** (this class): Persistent conversation data management
* - Manages conversation list and active conversation state
* - Handles conversation CRUD operations via DatabaseService
* - Maintains active message array for current conversation
* - Coordinates branching navigation (currNode tracking)
*
* - **chatStore**: Uses conversation data as context for active AI streaming
* - **DatabaseService**: Low-level IndexedDB storage for conversations and messages
*
* **Key Features:**
* - **Conversation Lifecycle**: Create, load, update, delete conversations
* - **Message Management**: Active message array with branching support
* - **Import/Export**: JSON-based conversation backup and restore
* - **Branch Navigation**: Navigate between message tree branches
* - **Title Management**: Auto-update titles with confirmation dialogs
* - **Reactive State**: Svelte 5 runes for automatic UI updates
*
* **State Properties:**
* - `conversations`: All conversations sorted by last modified
* - `activeConversation`: Currently viewed conversation
* - `activeMessages`: Messages in current conversation path
* - `isInitialized`: Store initialization status
*/
class ConversationsStore { class ConversationsStore {
// ───────────────────────────────────────────────────────────────────────────── /**
// State *
// ───────────────────────────────────────────────────────────────────────────── *
* State
*
*
*/
/** List of all conversations */ /** List of all conversations */
conversations = $state<DatabaseConversation[]>([]); conversations = $state<DatabaseConversation[]>([]);
@@ -65,102 +49,110 @@ class ConversationsStore {
/** Callback for title update confirmation dialog */ /** Callback for title update confirmation dialog */
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>; titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
// ───────────────────────────────────────────────────────────────────────────── /**
// Modalities *
// ───────────────────────────────────────────────────────────────────────────── *
* Lifecycle
*
*
*/
/** /**
* Modalities used in the active conversation. * Initialize the store by loading conversations from database.
* Computed from attachments in activeMessages. * Must be called once after app startup.
* Used to filter available models - models must support all used modalities.
*/ */
usedModalities: ModelModalities = $derived.by(() => { async init(): Promise<void> {
return this.calculateModalitiesFromMessages(this.activeMessages); if (!browser) return;
}); if (this.isInitialized) return;
/**
* Calculate modalities from a list of messages.
* Helper method used by both usedModalities and getModalitiesUpToMessage.
*/
private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities {
const modalities: ModelModalities = { vision: false, audio: false };
for (const message of messages) {
if (!message.extra) continue;
for (const extra of message.extra) {
if (extra.type === AttachmentType.IMAGE) {
modalities.vision = true;
}
// PDF only requires vision if processed as images
if (extra.type === AttachmentType.PDF) {
const pdfExtra = extra as DatabaseMessageExtraPdfFile;
if (pdfExtra.processedAsImages) {
modalities.vision = true;
}
}
if (extra.type === AttachmentType.AUDIO) {
modalities.audio = true;
}
}
if (modalities.vision && modalities.audio) break;
}
return modalities;
}
/**
* Get modalities used in messages BEFORE the specified message.
* Used for regeneration - only consider context that was available when generating this message.
*/
getModalitiesUpToMessage(messageId: string): ModelModalities {
const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId);
if (messageIndex === -1) {
return this.usedModalities;
}
const messagesBefore = this.activeMessages.slice(0, messageIndex);
return this.calculateModalitiesFromMessages(messagesBefore);
}
constructor() {
if (browser) {
this.initialize();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
/**
* Initializes the conversations store by loading conversations from the database
*/
async initialize(): Promise<void> {
try { try {
await this.loadConversations(); await this.loadConversations();
this.isInitialized = true; this.isInitialized = true;
} catch (error) { } catch (error) {
console.error('Failed to initialize conversations store:', error); console.error('Failed to initialize conversations:', error);
} }
} }
/**
* Alias for init() for backward compatibility.
*/
async initialize(): Promise<void> {
return this.init();
}
/**
*
*
* Message Array Operations
*
*
*/
/**
* Adds a message to the active messages array
*/
addMessageToActive(message: DatabaseMessage): void {
this.activeMessages.push(message);
}
/**
* Updates a message at a specific index in active messages
*/
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
if (index !== -1 && this.activeMessages[index]) {
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
}
}
/**
* Finds the index of a message in active messages
*/
findMessageIndex(messageId: string): number {
return this.activeMessages.findIndex((m) => m.id === messageId);
}
/**
* Removes messages from active messages starting at an index
*/
sliceActiveMessages(startIndex: number): void {
this.activeMessages = this.activeMessages.slice(0, startIndex);
}
/**
* Removes a message from active messages by index
*/
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
if (index !== -1) {
return this.activeMessages.splice(index, 1)[0];
}
return undefined;
}
/**
* Sets the callback function for title update confirmations
*/
setTitleUpdateConfirmationCallback(
callback: (currentTitle: string, newTitle: string) => Promise<boolean>
): void {
this.titleUpdateConfirmationCallback = callback;
}
/**
*
*
* Conversation CRUD
*
*
*/
/** /**
* Loads all conversations from the database * Loads all conversations from the database
*/ */
async loadConversations(): Promise<void> { async loadConversations(): Promise<void> {
this.conversations = await DatabaseService.getAllConversations(); const conversations = await DatabaseService.getAllConversations();
this.conversations = conversations;
} }
// ─────────────────────────────────────────────────────────────────────────────
// Conversation CRUD
// ─────────────────────────────────────────────────────────────────────────────
/** /**
* Creates a new conversation and navigates to it * Creates a new conversation and navigates to it
* @param name - Optional name for the conversation * @param name - Optional name for the conversation
@@ -170,7 +162,7 @@ class ConversationsStore {
const conversationName = name || `Chat ${new Date().toLocaleString()}`; const conversationName = name || `Chat ${new Date().toLocaleString()}`;
const conversation = await DatabaseService.createConversation(conversationName); const conversation = await DatabaseService.createConversation(conversationName);
this.conversations.unshift(conversation); this.conversations = [conversation, ...this.conversations];
this.activeConversation = conversation; this.activeConversation = conversation;
this.activeMessages = []; this.activeMessages = [];
@@ -196,13 +188,15 @@ class ConversationsStore {
if (conversation.currNode) { if (conversation.currNode) {
const allMessages = await DatabaseService.getConversationMessages(convId); const allMessages = await DatabaseService.getConversationMessages(convId);
this.activeMessages = filterByLeafNodeId( const filteredMessages = filterByLeafNodeId(
allMessages, allMessages,
conversation.currNode, conversation.currNode,
false false
) as DatabaseMessage[]; ) as DatabaseMessage[];
this.activeMessages = filteredMessages;
} else { } else {
this.activeMessages = await DatabaseService.getConversationMessages(convId); const messages = await DatabaseService.getConversationMessages(convId);
this.activeMessages = messages;
} }
return true; return true;
@@ -213,169 +207,11 @@ class ConversationsStore {
} }
/** /**
* Clears the active conversation and messages * Clears the active conversation and messages.
* Used when navigating away from chat or starting fresh
*/ */
clearActiveConversation(): void { clearActiveConversation(): void {
this.activeConversation = null; this.activeConversation = null;
this.activeMessages = []; this.activeMessages = [];
// Active processing conversation is now managed by chatStore
}
// ─────────────────────────────────────────────────────────────────────────────
// Message Management
// ─────────────────────────────────────────────────────────────────────────────
/**
* Refreshes active messages based on currNode after branch navigation
*/
async refreshActiveMessages(): Promise<void> {
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
if (allMessages.length === 0) {
this.activeMessages = [];
return;
}
const leafNodeId =
this.activeConversation.currNode ||
allMessages.reduce((latest: DatabaseMessage, msg: DatabaseMessage) =>
msg.timestamp > latest.timestamp ? msg : latest
).id;
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
this.activeMessages.length = 0;
this.activeMessages.push(...currentPath);
}
/**
* Updates the name of a conversation
* @param convId - The conversation ID to update
* @param name - The new name for the conversation
*/
async updateConversationName(convId: string, name: string): Promise<void> {
try {
await DatabaseService.updateConversation(convId, { name });
const convIndex = this.conversations.findIndex((c) => c.id === convId);
if (convIndex !== -1) {
this.conversations[convIndex].name = name;
}
if (this.activeConversation?.id === convId) {
this.activeConversation.name = name;
}
} catch (error) {
console.error('Failed to update conversation name:', error);
}
}
/**
* Updates conversation title with optional confirmation dialog based on settings
* @param convId - The conversation ID to update
* @param newTitle - The new title content
* @param onConfirmationNeeded - Callback when user confirmation is needed
* @returns True if title was updated, false if cancelled
*/
async updateConversationTitleWithConfirmation(
convId: string,
newTitle: string,
onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
): Promise<boolean> {
try {
const currentConfig = config();
if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
const conversation = await DatabaseService.getConversation(convId);
if (!conversation) return false;
const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
if (!shouldUpdate) return false;
}
await this.updateConversationName(convId, newTitle);
return true;
} catch (error) {
console.error('Failed to update conversation title with confirmation:', error);
return false;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Navigation
// ─────────────────────────────────────────────────────────────────────────────
/**
* Updates the current node of the active conversation
* @param nodeId - The new current node ID
*/
async updateCurrentNode(nodeId: string): Promise<void> {
if (!this.activeConversation) return;
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
this.activeConversation.currNode = nodeId;
}
/**
* Updates conversation lastModified timestamp and moves it to top of list
*/
updateConversationTimestamp(): void {
if (!this.activeConversation) return;
const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (chatIndex !== -1) {
this.conversations[chatIndex].lastModified = Date.now();
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
this.conversations.unshift(updatedConv);
}
}
/**
* Navigates to a specific sibling branch by updating currNode and refreshing messages
* @param siblingId - The sibling message ID to navigate to
*/
async navigateToSibling(siblingId: string): Promise<void> {
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
const rootMessage = allMessages.find(
(m: DatabaseMessage) => m.type === 'root' && m.parent === null
);
const currentFirstUserMessage = this.activeMessages.find(
(m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage?.id
);
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
this.activeConversation.currNode = currentLeafNodeId;
await this.refreshActiveMessages();
// Only show title dialog if we're navigating between different first user message siblings
if (rootMessage && this.activeMessages.length > 0) {
const newFirstUserMessage = this.activeMessages.find(
(m: DatabaseMessage) => m.role === 'user' && m.parent === rootMessage.id
);
if (
newFirstUserMessage &&
newFirstUserMessage.content.trim() &&
(!currentFirstUserMessage ||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
) {
await this.updateConversationTitleWithConfirmation(
this.activeConversation.id,
newFirstUserMessage.content.trim(),
this.titleUpdateConfirmationCallback
);
}
}
} }
/** /**
@@ -420,12 +256,192 @@ class ConversationsStore {
} }
} }
// ───────────────────────────────────────────────────────────────────────────── /**
// Import/Export *
// ───────────────────────────────────────────────────────────────────────────── *
* Message Management
*
*
*/
/** /**
* Downloads a conversation as JSON file * Refreshes active messages based on currNode after branch navigation.
*/
async refreshActiveMessages(): Promise<void> {
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
if (allMessages.length === 0) {
this.activeMessages = [];
return;
}
const leafNodeId =
this.activeConversation.currNode ||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
this.activeMessages = currentPath;
}
/**
* Gets all messages for a specific conversation
* @param convId - The conversation ID
* @returns Array of messages
*/
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
return await DatabaseService.getConversationMessages(convId);
}
/**
*
*
* Title Management
*
*
*/
/**
* Updates the name of a conversation.
* @param convId - The conversation ID to update
* @param name - The new name for the conversation
*/
async updateConversationName(convId: string, name: string): Promise<void> {
try {
await DatabaseService.updateConversation(convId, { name });
const convIndex = this.conversations.findIndex((c) => c.id === convId);
if (convIndex !== -1) {
this.conversations[convIndex].name = name;
this.conversations = [...this.conversations];
}
if (this.activeConversation?.id === convId) {
this.activeConversation = { ...this.activeConversation, name };
}
} catch (error) {
console.error('Failed to update conversation name:', error);
}
}
/**
* Updates conversation title with optional confirmation dialog based on settings
* @param convId - The conversation ID to update
* @param newTitle - The new title content
* @returns True if title was updated, false if cancelled
*/
async updateConversationTitleWithConfirmation(
convId: string,
newTitle: string
): Promise<boolean> {
try {
const currentConfig = config();
if (currentConfig.askForTitleConfirmation && this.titleUpdateConfirmationCallback) {
const conversation = await DatabaseService.getConversation(convId);
if (!conversation) return false;
const shouldUpdate = await this.titleUpdateConfirmationCallback(
conversation.name,
newTitle
);
if (!shouldUpdate) return false;
}
await this.updateConversationName(convId, newTitle);
return true;
} catch (error) {
console.error('Failed to update conversation title with confirmation:', error);
return false;
}
}
/**
* Updates conversation lastModified timestamp and moves it to top of list
*/
updateConversationTimestamp(): void {
if (!this.activeConversation) return;
const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (chatIndex !== -1) {
this.conversations[chatIndex].lastModified = Date.now();
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
this.conversations = [updatedConv, ...this.conversations];
}
}
/**
* Updates the current node of the active conversation
* @param nodeId - The new current node ID
*/
async updateCurrentNode(nodeId: string): Promise<void> {
if (!this.activeConversation) return;
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
this.activeConversation = { ...this.activeConversation, currNode: nodeId };
}
/**
*
*
* Branch Navigation
*
*
*/
/**
* Navigates to a specific sibling branch by updating currNode and refreshing messages.
* @param siblingId - The sibling message ID to navigate to
*/
async navigateToSibling(siblingId: string): Promise<void> {
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const currentFirstUserMessage = this.activeMessages.find(
(m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
);
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
await this.refreshActiveMessages();
if (rootMessage && this.activeMessages.length > 0) {
const newFirstUserMessage = this.activeMessages.find(
(m) => m.role === MessageRole.USER && m.parent === rootMessage.id
);
if (
newFirstUserMessage &&
newFirstUserMessage.content.trim() &&
(!currentFirstUserMessage ||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
) {
await this.updateConversationTitleWithConfirmation(
this.activeConversation.id,
newFirstUserMessage.content.trim()
);
}
}
}
/**
*
*
* Import & Export
*
*
*/
/**
* Downloads a conversation as JSON file.
* @param convId - The conversation ID to download * @param convId - The conversation ID to download
*/ */
async downloadConversation(convId: string): Promise<void> { async downloadConversation(convId: string): Promise<void> {
@@ -456,7 +472,7 @@ class ConversationsStore {
} }
const allData = await Promise.all( const allData = await Promise.all(
allConversations.map(async (conv: DatabaseConversation) => { allConversations.map(async (conv) => {
const messages = await DatabaseService.getConversationMessages(conv.id); const messages = await DatabaseService.getConversationMessages(conv.id);
return { conv, messages }; return { conv, messages };
}) })
@@ -536,15 +552,6 @@ class ConversationsStore {
}); });
} }
/**
* Gets all messages for a specific conversation
* @param convId - The conversation ID
* @returns Array of messages
*/
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
return await DatabaseService.getConversationMessages(convId);
}
/** /**
* Imports conversations from provided data (without file picker) * Imports conversations from provided data (without file picker)
* @param data - Array of conversation data with messages * @param data - Array of conversation data with messages
@@ -558,61 +565,8 @@ class ConversationsStore {
return result; return result;
} }
/**
* Adds a message to the active messages array
* Used by chatStore when creating new messages
* @param message - The message to add
*/
addMessageToActive(message: DatabaseMessage): void {
this.activeMessages.push(message);
}
/**
* Updates a message at a specific index in active messages
* Creates a new object to trigger Svelte 5 reactivity
* @param index - The index of the message to update
* @param updates - Partial message data to update
*/
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
if (index !== -1 && this.activeMessages[index]) {
// Create new object to trigger Svelte 5 reactivity
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
}
}
/**
* Finds the index of a message in active messages
* @param messageId - The message ID to find
* @returns The index of the message, or -1 if not found
*/
findMessageIndex(messageId: string): number {
return this.activeMessages.findIndex((m) => m.id === messageId);
}
/**
* Removes messages from active messages starting at an index
* @param startIndex - The index to start removing from
*/
sliceActiveMessages(startIndex: number): void {
this.activeMessages = this.activeMessages.slice(0, startIndex);
}
/**
* Removes a message from active messages by index
* @param index - The index to remove
* @returns The removed message or undefined
*/
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
if (index !== -1) {
return this.activeMessages.splice(index, 1)[0];
}
return undefined;
}
/** /**
* Triggers file download in browser * Triggers file download in browser
* @param data - The data to download
* @param filename - Optional filename for the download
*/ */
private triggerDownload(data: ExportedConversations, filename?: string): void { private triggerDownload(data: ExportedConversations, filename?: string): void {
const conversation = const conversation =
@@ -641,26 +595,16 @@ class ConversationsStore {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
// ─────────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Sets the callback function for title update confirmations
* @param callback - Function to call when confirmation is needed
*/
setTitleUpdateConfirmationCallback(
callback: (currentTitle: string, newTitle: string) => Promise<boolean>
): void {
this.titleUpdateConfirmationCallback = callback;
}
} }
export const conversationsStore = new ConversationsStore(); export const conversationsStore = new ConversationsStore();
// Auto-initialize in browser
if (browser) {
conversationsStore.init();
}
export const conversations = () => conversationsStore.conversations; export const conversations = () => conversationsStore.conversations;
export const activeConversation = () => conversationsStore.activeConversation; export const activeConversation = () => conversationsStore.activeConversation;
export const activeMessages = () => conversationsStore.activeMessages; export const activeMessages = () => conversationsStore.activeMessages;
export const isConversationsInitialized = () => conversationsStore.isInitialized; export const isConversationsInitialized = () => conversationsStore.isInitialized;
export const usedModalities = () => conversationsStore.usedModalities;
@@ -1,8 +1,9 @@
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { ModelsService } from '$lib/services/models.service';
import { PropsService } from '$lib/services/props.service';
import { ServerModelStatus, ModelModality } from '$lib/enums'; import { ServerModelStatus, ModelModality } from '$lib/enums';
import { ModelsService, PropsService } from '$lib/services';
import { serverStore } from '$lib/stores/server.svelte'; import { serverStore } from '$lib/stores/server.svelte';
import { TTLCache } from '$lib/utils';
import { MODEL_PROPS_CACHE_TTL_MS, MODEL_PROPS_CACHE_MAX_ENTRIES } from '$lib/constants/cache';
/** /**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes * modelsStore - Reactive store for model management in both MODEL and ROUTER modes
@@ -32,9 +33,13 @@ import { serverStore } from '$lib/stores/server.svelte';
* - **Lazy loading**: ensureModelLoaded() loads models on demand * - **Lazy loading**: ensureModelLoaded() loads models on demand
*/ */
class ModelsStore { class ModelsStore {
// ───────────────────────────────────────────────────────────────────────────── /**
// State *
// ───────────────────────────────────────────────────────────────────────────── *
* State
*
*
*/
models = $state<ModelOption[]>([]); models = $state<ModelOption[]>([]);
routerModels = $state<ApiModelDataEntry[]>([]); routerModels = $state<ApiModelDataEntry[]>([]);
@@ -48,10 +53,14 @@ class ModelsStore {
private modelLoadingStates = $state<Map<string, boolean>>(new Map()); private modelLoadingStates = $state<Map<string, boolean>>(new Map());
/** /**
* Model-specific props cache * Model-specific props cache with TTL
* Key: modelId, Value: props data including modalities * Key: modelId, Value: props data including modalities
* TTL: 10 minutes - props don't change frequently
*/ */
private modelPropsCache = $state<Map<string, ApiLlamaCppServerProps>>(new Map()); private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
ttlMs: MODEL_PROPS_CACHE_TTL_MS,
maxEntries: MODEL_PROPS_CACHE_MAX_ENTRIES
});
private modelPropsFetching = $state<Set<string>>(new Set()); private modelPropsFetching = $state<Set<string>>(new Set());
/** /**
@@ -59,9 +68,13 @@ class ModelsStore {
*/ */
propsCacheVersion = $state(0); propsCacheVersion = $state(0);
// ───────────────────────────────────────────────────────────────────────────── /**
// Computed Getters *
// ───────────────────────────────────────────────────────────────────────────── *
* Computed Getters
*
*
*/
get selectedModel(): ModelOption | null { get selectedModel(): ModelOption | null {
if (!this.selectedModelId) return null; if (!this.selectedModelId) return null;
@@ -95,22 +108,24 @@ class ModelsStore {
return props.model_path.split(/(\\|\/)/).pop() || null; return props.model_path.split(/(\\|\/)/).pop() || null;
} }
// ───────────────────────────────────────────────────────────────────────────── /**
// Modalities *
// ───────────────────────────────────────────────────────────────────────────── *
* Modalities
*
*
*/
/** /**
* Get modalities for a specific model * Get modalities for a specific model
* Returns cached modalities from model props * Returns cached modalities from model props
*/ */
getModelModalities(modelId: string): ModelModalities | null { getModelModalities(modelId: string): ModelModalities | null {
// First check if modalities are stored in the model option
const model = this.models.find((m) => m.model === modelId || m.id === modelId); const model = this.models.find((m) => m.model === modelId || m.id === modelId);
if (model?.modalities) { if (model?.modalities) {
return model.modalities; return model.modalities;
} }
// Fall back to props cache
const props = this.modelPropsCache.get(modelId); const props = this.modelPropsCache.get(modelId);
if (props?.modalities) { if (props?.modalities) {
return { return {
@@ -155,15 +170,17 @@ class ModelsStore {
* Get props for a specific model (from cache) * Get props for a specific model (from cache)
*/ */
getModelProps(modelId: string): ApiLlamaCppServerProps | null { getModelProps(modelId: string): ApiLlamaCppServerProps | null {
return this.modelPropsCache.get(modelId) ?? null; return this.modelPropsCache.get(modelId);
} }
/** /**
* Get context size (n_ctx) for a specific model from cached props * Get context size (n_ctx) for a specific model from cached props
*/ */
getModelContextSize(modelId: string): number | null { getModelContextSize(modelId: string): number | null {
const props = this.modelPropsCache.get(modelId); const props = this.getModelProps(modelId);
return props?.default_generation_settings?.n_ctx ?? null; const nCtx = props?.default_generation_settings?.n_ctx;
return typeof nCtx === 'number' ? nCtx : null;
} }
/** /**
@@ -181,9 +198,13 @@ class ModelsStore {
return this.modelPropsFetching.has(modelId); return this.modelPropsFetching.has(modelId);
} }
// ───────────────────────────────────────────────────────────────────────────── /**
// Status Queries *
// ───────────────────────────────────────────────────────────────────────────── *
* Status Queries
*
*
*/
isModelLoaded(modelId: string): boolean { isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId); const model = this.routerModels.find((m) => m.id === modelId);
@@ -208,9 +229,13 @@ class ModelsStore {
return usage !== undefined && usage.size > 0; return usage !== undefined && usage.size > 0;
} }
// ───────────────────────────────────────────────────────────────────────────── /**
// Data Fetching *
// ───────────────────────────────────────────────────────────────────────────── *
* Data Fetching
*
*
*/
/** /**
* Fetch list of models from server and detect server role * Fetch list of models from server and detect server role
@@ -224,7 +249,6 @@ class ModelsStore {
this.error = null; this.error = null;
try { try {
// Ensure server props are loaded (for role detection and MODEL mode modalities)
if (!serverStore.props) { if (!serverStore.props) {
await serverStore.fetch(); await serverStore.fetch();
} }
@@ -251,7 +275,6 @@ class ModelsStore {
this.models = models; this.models = models;
// In MODEL mode, populate modalities from serverStore.props (single model)
// WORKAROUND: In MODEL mode, /props returns modalities for the single model, // WORKAROUND: In MODEL mode, /props returns modalities for the single model,
// but /v1/models doesn't include modalities. We bridge this gap here. // but /v1/models doesn't include modalities. We bridge this gap here.
const serverProps = serverStore.props; const serverProps = serverStore.props;
@@ -260,9 +283,7 @@ class ModelsStore {
vision: serverProps.modalities.vision ?? false, vision: serverProps.modalities.vision ?? false,
audio: serverProps.modalities.audio ?? false audio: serverProps.modalities.audio ?? false
}; };
// Cache props for the single model
this.modelPropsCache.set(this.models[0].model, serverProps); this.modelPropsCache.set(this.models[0].model, serverProps);
// Update model with modalities
this.models = this.models.map((model, index) => this.models = this.models.map((model, index) =>
index === 0 ? { ...model, modalities } : model index === 0 ? { ...model, modalities } : model
); );
@@ -302,7 +323,6 @@ class ModelsStore {
* @returns Props data or null if fetch failed or model not loaded * @returns Props data or null if fetch failed or model not loaded
*/ */
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> { async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
// Return cached props if available
const cached = this.modelPropsCache.get(modelId); const cached = this.modelPropsCache.get(modelId);
if (cached) return cached; if (cached) return cached;
@@ -310,7 +330,6 @@ class ModelsStore {
return null; return null;
} }
// Avoid duplicate fetches
if (this.modelPropsFetching.has(modelId)) return null; if (this.modelPropsFetching.has(modelId)) return null;
this.modelPropsFetching.add(modelId); this.modelPropsFetching.add(modelId);
@@ -335,7 +354,6 @@ class ModelsStore {
const loadedModelIds = this.loadedModelIds; const loadedModelIds = this.loadedModelIds;
if (loadedModelIds.length === 0) return; if (loadedModelIds.length === 0) return;
// Fetch props for each loaded model in parallel
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId)); const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
try { try {
@@ -357,7 +375,6 @@ class ModelsStore {
return { ...model, modalities }; return { ...model, modalities };
}); });
// Increment version to trigger reactivity
this.propsCacheVersion++; this.propsCacheVersion++;
} catch (error) { } catch (error) {
console.warn('Failed to fetch modalities for loaded models:', error); console.warn('Failed to fetch modalities for loaded models:', error);
@@ -382,16 +399,19 @@ class ModelsStore {
model.model === modelId ? { ...model, modalities } : model model.model === modelId ? { ...model, modalities } : model
); );
// Increment version to trigger reactivity
this.propsCacheVersion++; this.propsCacheVersion++;
} catch (error) { } catch (error) {
console.warn(`Failed to update modalities for model ${modelId}:`, error); console.warn(`Failed to update modalities for model ${modelId}:`, error);
} }
} }
// ───────────────────────────────────────────────────────────────────────────── /**
// Model Selection *
// ───────────────────────────────────────────────────────────────────────────── *
* Model Selection
*
*
*/
/** /**
* Select a model for new conversations * Select a model for new conversations
@@ -443,9 +463,13 @@ class ModelsStore {
return this.models.some((model) => model.model === modelName); return this.models.some((model) => model.model === modelName);
} }
// ───────────────────────────────────────────────────────────────────────────── /**
// Loading/Unloading Models *
// ───────────────────────────────────────────────────────────────────────────── *
* Loading/Unloading Models
*
*
*/
/** /**
* WORKAROUND: Polling for model status after load/unload operations. * WORKAROUND: Polling for model status after load/unload operations.
@@ -486,7 +510,6 @@ class ModelsStore {
return; return;
} }
// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL)); await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
} }
@@ -511,8 +534,6 @@ class ModelsStore {
try { try {
await ModelsService.load(modelId); await ModelsService.load(modelId);
// Poll until model is loaded
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED); await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
await this.updateModelModalities(modelId); await this.updateModelModalities(modelId);
@@ -562,9 +583,13 @@ class ModelsStore {
await this.loadModel(modelId); await this.loadModel(modelId);
} }
// ───────────────────────────────────────────────────────────────────────────── /**
// Utilities *
// ───────────────────────────────────────────────────────────────────────────── *
* Utilities
*
*
*/
private toDisplayName(id: string): string { private toDisplayName(id: string): string {
const segments = id.split(/\\|\//); const segments = id.split(/\\|\//);
@@ -586,6 +611,14 @@ class ModelsStore {
this.modelPropsCache.clear(); this.modelPropsCache.clear();
this.modelPropsFetching.clear(); this.modelPropsFetching.clear();
} }
/**
* Prune expired entries from caches.
* Call periodically for proactive memory cleanup.
*/
pruneExpiredCache(): number {
return this.modelPropsCache.prune();
}
} }
export const modelsStore = new ModelsStore(); export const modelsStore = new ModelsStore();
+19 -4
View File
@@ -1,8 +1,5 @@
import type { ErrorDialogType } from '$lib/enums'; import type { ErrorDialogType } from '$lib/enums';
import type { DatabaseMessage, DatabaseMessageExtra } from './database'; import type { DatabaseMessageExtra } from './database';
export type ChatMessageType = 'root' | 'text' | 'think' | 'system';
export type ChatRole = 'user' | 'assistant' | 'system';
export interface ChatUploadedFile { export interface ChatUploadedFile {
id: string; id: string;
@@ -61,6 +58,9 @@ export interface ChatMessageTimings {
prompt_n?: number; prompt_n?: number;
} }
/**
* Callbacks for streaming chat responses
*/
export interface ChatStreamCallbacks { export interface ChatStreamCallbacks {
onChunk?: (chunk: string) => void; onChunk?: (chunk: string) => void;
onReasoningChunk?: (chunk: string) => void; onReasoningChunk?: (chunk: string) => void;
@@ -77,12 +77,18 @@ export interface ChatStreamCallbacks {
onError?: (error: Error) => void; onError?: (error: Error) => void;
} }
/**
* Error dialog state for displaying server/timeout errors
*/
export interface ErrorDialogState { export interface ErrorDialogState {
type: ErrorDialogType; type: ErrorDialogType;
message: string; message: string;
contextInfo?: { n_prompt_tokens: number; n_ctx: number }; contextInfo?: { n_prompt_tokens: number; n_ctx: number };
} }
/**
* Live processing stats during prompt evaluation
*/
export interface LiveProcessingStats { export interface LiveProcessingStats {
tokensProcessed: number; tokensProcessed: number;
totalTokens: number; totalTokens: number;
@@ -91,17 +97,26 @@ export interface LiveProcessingStats {
etaSecs?: number; etaSecs?: number;
} }
/**
* Live generation stats during token generation
*/
export interface LiveGenerationStats { export interface LiveGenerationStats {
tokensGenerated: number; tokensGenerated: number;
timeMs: number; timeMs: number;
tokensPerSecond: number; tokensPerSecond: number;
} }
/**
* Options for getting attachment display items
*/
export interface AttachmentDisplayItemsOptions { export interface AttachmentDisplayItemsOptions {
uploadedFiles?: ChatUploadedFile[]; uploadedFiles?: ChatUploadedFile[];
attachments?: DatabaseMessageExtra[]; attachments?: DatabaseMessageExtra[];
} }
/**
* Result of file processing operation
*/
export interface FileProcessingResult { export interface FileProcessingResult {
extras: DatabaseMessageExtra[]; extras: DatabaseMessageExtra[];
emptyFiles: string[]; emptyFiles: string[];
+12 -2
View File
@@ -1,7 +1,12 @@
import type { AttachmentType } from '$lib/enums'; import type { AttachmentType } from '$lib/enums';
/**
* Common utility types used across the application
*/
/** /**
* Represents a key-value pair. * Represents a key-value pair.
* Used for headers, environment variables, query parameters, etc.
*/ */
export interface KeyValuePair { export interface KeyValuePair {
key: string; key: string;
@@ -9,16 +14,19 @@ export interface KeyValuePair {
} }
/** /**
* Binary detection configuration options. * Binary detection configuration options
*/ */
export interface BinaryDetectionOptions { export interface BinaryDetectionOptions {
/** Number of characters to check from the beginning of the file */
prefixLength: number; prefixLength: number;
/** Maximum ratio of suspicious characters allowed (0.0 to 1.0) */
suspiciousCharThresholdRatio: number; suspiciousCharThresholdRatio: number;
/** Maximum absolute number of null bytes allowed */
maxAbsoluteNullBytes: number; maxAbsoluteNullBytes: number;
} }
/** /**
* Format for text attachments when copied to clipboard. * Format for text attachments when copied to clipboard
*/ */
export interface ClipboardTextAttachment { export interface ClipboardTextAttachment {
type: typeof AttachmentType.TEXT; type: typeof AttachmentType.TEXT;
@@ -33,3 +41,5 @@ export interface ParsedClipboardContent {
message: string; message: string;
textAttachments: ClipboardTextAttachment[]; textAttachments: ClipboardTextAttachment[];
} }
export type MimeTypeUnion = MimeTypeAudio | MimeTypeImage | MimeTypeApplication | MimeTypeText;
+11 -13
View File
@@ -35,9 +35,9 @@ export interface DatabaseMessageExtraPdfFile {
type: AttachmentType.PDF; type: AttachmentType.PDF;
base64Data: string; base64Data: string;
name: string; name: string;
content: string; // Text content extracted from PDF content: string;
images?: string[]; // Optional: PDF pages as base64 images images?: string[];
processedAsImages: boolean; // Whether PDF was processed as images processedAsImages: boolean;
} }
export interface DatabaseMessageExtraTextFile { export interface DatabaseMessageExtraTextFile {
@@ -60,26 +60,24 @@ export interface DatabaseMessage {
timestamp: number; timestamp: number;
role: ChatRole; role: ChatRole;
content: string; content: string;
parent: string; parent: string | null;
thinking: string; /**
* @deprecated - left for backward compatibility
*/
thinking?: string;
/** Serialized JSON array of tool calls made by assistant messages */
toolCalls?: string; toolCalls?: string;
/** Tool call ID for tool result messages (role: 'tool') */
toolCallId?: string;
children: string[]; children: string[];
extra?: DatabaseMessageExtra[]; extra?: DatabaseMessageExtra[];
timings?: ChatMessageTimings; timings?: ChatMessageTimings;
model?: string; model?: string;
} }
/**
* Represents a single conversation with its associated messages,
* typically used for import/export operations.
*/
export type ExportedConversation = { export type ExportedConversation = {
conv: DatabaseConversation; conv: DatabaseConversation;
messages: DatabaseMessage[]; messages: DatabaseMessage[];
}; };
/**
* Type representing one or more exported conversations.
* Can be a single conversation object or an array of them.
*/
export type ExportedConversations = ExportedConversation | ExportedConversation[]; export type ExportedConversations = ExportedConversation | ExportedConversation[];
+1 -3
View File
@@ -34,8 +34,6 @@ export type {
// Chat types // Chat types
export type { export type {
ChatMessageType,
ChatRole,
ChatUploadedFile, ChatUploadedFile,
ChatAttachmentDisplayItem, ChatAttachmentDisplayItem,
ChatAttachmentPreviewItem, ChatAttachmentPreviewItem,
@@ -48,7 +46,7 @@ export type {
LiveGenerationStats, LiveGenerationStats,
AttachmentDisplayItemsOptions, AttachmentDisplayItemsOptions,
FileProcessingResult FileProcessingResult
} from './chat'; } from './chat.d';
// Database types // Database types
export type { export type {
+8 -4
View File
@@ -1,7 +1,7 @@
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; import type { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat'; import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
import type { DatabaseMessageExtra } from './database'; import type { DatabaseMessageExtra } from './database';
import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
export type SettingsConfigValue = string | number | boolean; export type SettingsConfigValue = string | number | boolean;
@@ -69,14 +69,18 @@ export type SettingsConfigType = typeof SETTING_CONFIG_DEFAULT & {
[key: string]: SettingsConfigValue; [key: string]: SettingsConfigValue;
}; };
/**
* Parameter synchronization types for server defaults and user overrides
* Note: ParameterSource and SyncableParameterType enums are imported from '$lib/enums'
*/
export type ParameterValue = string | number | boolean; export type ParameterValue = string | number | boolean;
export type ParameterRecord = Record<string, ParameterValue>; export type ParameterRecord = Record<string, ParameterValue>;
export interface ParameterInfo { export interface ParameterInfo {
value: ParameterValue; value: string | number | boolean;
source: ParameterSource; source: ParameterSource;
serverDefault?: ParameterValue; serverDefault?: string | number | boolean;
userOverride?: ParameterValue; userOverride?: string | number | boolean;
} }
export interface SyncableParameter { export interface SyncableParameter {
+4 -19
View File
@@ -3,8 +3,10 @@ import { AttachmentType } from '$lib/enums';
import type { import type {
DatabaseMessageExtra, DatabaseMessageExtra,
DatabaseMessageExtraTextFile, DatabaseMessageExtraTextFile,
DatabaseMessageExtraLegacyContext DatabaseMessageExtraLegacyContext,
} from '$lib/types/database'; ClipboardTextAttachment,
ParsedClipboardContent
} from '$lib/types';
/** /**
* Copy text to clipboard with toast notification * Copy text to clipboard with toast notification
@@ -68,23 +70,6 @@ export async function copyCodeToClipboard(
return copyToClipboard(rawCode, successMessage, errorMessage); return copyToClipboard(rawCode, successMessage, errorMessage);
} }
/**
* Format for text attachments when copied to clipboard
*/
export interface ClipboardTextAttachment {
type: typeof AttachmentType.TEXT;
name: string;
content: string;
}
/**
* Parsed result from clipboard content
*/
export interface ParsedClipboardContent {
message: string;
textAttachments: ClipboardTextAttachment[];
}
/** /**
* Formats a message with text attachments for clipboard copying. * Formats a message with text attachments for clipboard copying.
* *
@@ -7,6 +7,7 @@ import { modelsStore } from '$lib/stores/models.svelte';
import { getFileTypeCategory } from '$lib/utils'; import { getFileTypeCategory } from '$lib/utils';
import { readFileAsText, isLikelyTextFile } from './text-files'; import { readFileAsText, isLikelyTextFile } from './text-files';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import type { FileProcessingResult, ChatUploadedFile, DatabaseMessageExtra } from '$lib/types';
function readFileAsBase64(file: File): Promise<string> { function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -25,11 +26,6 @@ function readFileAsBase64(file: File): Promise<string> {
}); });
} }
export interface FileProcessingResult {
extras: DatabaseMessageExtra[];
emptyFiles: string[];
}
export async function parseFilesToMessageExtras( export async function parseFilesToMessageExtras(
files: ChatUploadedFile[], files: ChatUploadedFile[],
activeModelId?: string activeModelId?: string
+34 -6
View File
@@ -1,3 +1,11 @@
import {
MS_PER_SECOND,
SECONDS_PER_MINUTE,
SECONDS_PER_HOUR,
SHORT_DURATION_THRESHOLD,
MEDIUM_DURATION_THRESHOLD
} from '$lib/constants/formatters';
/** /**
* Formats file size in bytes to human readable format * Formats file size in bytes to human readable format
* Supports Bytes, KB, MB, and GB * Supports Bytes, KB, MB, and GB
@@ -93,19 +101,19 @@ export function formatTime(date: Date): string {
export function formatPerformanceTime(ms: number): string { export function formatPerformanceTime(ms: number): string {
if (ms < 0) return '0s'; if (ms < 0) return '0s';
const totalSeconds = ms / 1000; const totalSeconds = ms / MS_PER_SECOND;
if (totalSeconds < 1) { if (totalSeconds < SHORT_DURATION_THRESHOLD) {
return `${totalSeconds.toFixed(1)}s`; return `${totalSeconds.toFixed(1)}s`;
} }
if (totalSeconds < 10) { if (totalSeconds < MEDIUM_DURATION_THRESHOLD) {
return `${totalSeconds.toFixed(1)}s`; return `${totalSeconds.toFixed(1)}s`;
} }
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor((totalSeconds % 3600) / 60); const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
const seconds = Math.floor(totalSeconds % 60); const seconds = Math.floor(totalSeconds % SECONDS_PER_MINUTE);
const parts: string[] = []; const parts: string[] = [];
@@ -123,3 +131,23 @@ export function formatPerformanceTime(ms: number): string {
return parts.join(' '); return parts.join(' ');
} }
/**
* Formats attachment content for API requests with consistent header style.
* Used when converting message attachments to text content parts.
*
* @param label - Type label (e.g., 'File', 'PDF File', 'MCP Prompt')
* @param name - File or attachment name
* @param content - The actual content to include
* @param extra - Optional extra info to append to name (e.g., server name for MCP)
* @returns Formatted string with header and content
*/
export function formatAttachmentText(
label: string,
name: string,
content: string,
extra?: string
): string {
const header = extra ? `${name} (${extra})` : name;
return `\n\n--- ${label}: ${header} ---\n${content}`;
}
+29 -8
View File
@@ -13,10 +13,7 @@ export { apiFetch, apiFetchWithParams, apiPost, type ApiFetchOptions } from './a
export { validateApiKey } from './api-key-validation'; export { validateApiKey } from './api-key-validation';
// Attachment utilities // Attachment utilities
export { export { getAttachmentDisplayItems } from './attachment-display';
getAttachmentDisplayItems,
type AttachmentDisplayItemsOptions
} from './attachment-display';
export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type'; export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type';
// Textarea utilities // Textarea utilities
@@ -46,9 +43,7 @@ export {
copyCodeToClipboard, copyCodeToClipboard,
formatMessageForClipboard, formatMessageForClipboard,
parseClipboardContent, parseClipboardContent,
hasClipboardAttachments, hasClipboardAttachments
type ClipboardTextAttachment,
type ParsedClipboardContent
} from './clipboard'; } from './clipboard';
// File preview utilities // File preview utilities
@@ -64,7 +59,15 @@ export {
} from './file-type'; } from './file-type';
// Formatting utilities // Formatting utilities
export { formatFileSize, formatParameters, formatNumber } from './formatters'; export {
formatFileSize,
formatParameters,
formatNumber,
formatJsonPretty,
formatTime,
formatPerformanceTime,
formatAttachmentText
} from './formatters';
// IME utilities // IME utilities
export { isIMEComposing } from './is-ime-composing'; export { isIMEComposing } from './is-ime-composing';
@@ -94,5 +97,23 @@ export { getLanguageFromFilename } from './syntax-highlight-language';
// Text file utilities // Text file utilities
export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files'; export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files';
// Debounce utilities
export { debounce } from './debounce';
// Image error fallback utilities // Image error fallback utilities
export { getImageErrorFallbackHtml } from './image-error-fallback'; export { getImageErrorFallbackHtml } from './image-error-fallback';
// Data URL utilities
export { createBase64DataUrl } from './data-url';
// Cache utilities
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
// Abort signal utilities
export {
throwIfAborted,
isAbortError,
createLinkedController,
createTimeoutSignal,
withAbortSignal
} from './abort';
+4 -3
View File
@@ -15,6 +15,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { modelsStore } from '$lib/stores/models.svelte'; import { modelsStore } from '$lib/stores/models.svelte';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config'; import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
import { KeyboardKey } from '$lib/enums';
import { IsMobile } from '$lib/hooks/is-mobile.svelte'; import { IsMobile } from '$lib/hooks/is-mobile.svelte';
let { children } = $props(); let { children } = $props();
@@ -43,7 +44,7 @@
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
const isCtrlOrCmd = event.ctrlKey || event.metaKey; const isCtrlOrCmd = event.ctrlKey || event.metaKey;
if (isCtrlOrCmd && event.key === 'k') { if (isCtrlOrCmd && event.key === KeyboardKey.K_LOWER) {
event.preventDefault(); event.preventDefault();
if (chatSidebar?.activateSearchMode) { if (chatSidebar?.activateSearchMode) {
chatSidebar.activateSearchMode(); chatSidebar.activateSearchMode();
@@ -51,12 +52,12 @@
} }
} }
if (isCtrlOrCmd && event.shiftKey && event.key === 'O') { if (isCtrlOrCmd && event.shiftKey && event.key === KeyboardKey.O_UPPER) {
event.preventDefault(); event.preventDefault();
goto('?new_chat=true#/'); goto('?new_chat=true#/');
} }
if (event.shiftKey && isCtrlOrCmd && event.key === 'E') { if (event.shiftKey && isCtrlOrCmd && event.key === KeyboardKey.E_UPPER) {
event.preventDefault(); event.preventDefault();
if (chatSidebar?.editActiveConversation) { if (chatSidebar?.editActiveConversation) {