Pre-MCP UI and architecture cleanup (#19689)
This commit is contained in:
committed by
GitHub
parent
d0061be838
commit
ea003229d3
+3
-2
@@ -8,7 +8,8 @@
|
||||
isImageFile,
|
||||
isPdfFile,
|
||||
isAudioFile,
|
||||
getLanguageFromFilename
|
||||
getLanguageFromFilename,
|
||||
createBase64DataUrl
|
||||
} from '$lib/utils';
|
||||
import { convertPDFToImage } from '$lib/utils/browser-only';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
@@ -255,7 +256,7 @@
|
||||
<audio
|
||||
controls
|
||||
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.
|
||||
</audio>
|
||||
|
||||
+44
-101
@@ -1,8 +1,12 @@
|
||||
<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 { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
|
||||
import { getAttachmentDisplayItems } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
@@ -41,12 +45,10 @@
|
||||
|
||||
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
|
||||
|
||||
let canScrollLeft = $state(false);
|
||||
let canScrollRight = $state(false);
|
||||
let carouselRef: HorizontalScrollCarousel | undefined = $state();
|
||||
let isScrollable = $state(false);
|
||||
let previewDialogOpen = $state(false);
|
||||
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
|
||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
|
||||
let viewAllDialogOpen = $state(false);
|
||||
|
||||
@@ -65,41 +67,9 @@
|
||||
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(() => {
|
||||
if (scrollContainer && displayItems.length) {
|
||||
scrollContainer.scrollLeft = 0;
|
||||
|
||||
setTimeout(() => {
|
||||
updateScrollButtons();
|
||||
}, 0);
|
||||
if (carouselRef && displayItems.length) {
|
||||
carouselRef.resetScroll();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -107,67 +77,40 @@
|
||||
{#if displayItems.length > 0}
|
||||
<div class={className} {style}>
|
||||
{#if limitToSingleRow}
|
||||
<div class="relative">
|
||||
<button
|
||||
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
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={scrollLeft}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="scrollbar-hide flex items-start gap-3 overflow-x-auto"
|
||||
bind:this={scrollContainer}
|
||||
onscroll={updateScrollButtons}
|
||||
>
|
||||
{#each displayItems as item (item.id)}
|
||||
{#if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow
|
||||
? 'first:ml-4 last:mr-4'
|
||||
: ''}"
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
preview={item.preview}
|
||||
{readonly}
|
||||
onRemove={onFileRemove}
|
||||
height={imageHeight}
|
||||
width={imageWidth}
|
||||
{imageClass}
|
||||
onClick={(event) => openPreview(item, event)}
|
||||
/>
|
||||
{:else}
|
||||
<ChatAttachmentThumbnailFile
|
||||
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>
|
||||
<HorizontalScrollCarousel
|
||||
bind:this={carouselRef}
|
||||
onScrollableChange={(scrollable) => (isScrollable = scrollable)}
|
||||
>
|
||||
{#each displayItems as item (item.id)}
|
||||
{#if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
preview={item.preview}
|
||||
{readonly}
|
||||
onRemove={onFileRemove}
|
||||
height={imageHeight}
|
||||
width={imageWidth}
|
||||
{imageClass}
|
||||
onClick={(event) => openPreview(item, event)}
|
||||
/>
|
||||
{:else}
|
||||
<ChatAttachmentThumbnailFile
|
||||
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}
|
||||
</HorizontalScrollCarousel>
|
||||
|
||||
{#if showViewAll}
|
||||
<div class="mt-2 -mr-2 flex justify-end px-4">
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import {
|
||||
ChatAttachmentsList,
|
||||
ChatFormActions,
|
||||
ChatFormFileInputInvisible,
|
||||
ChatFormHelperText,
|
||||
ChatFormTextarea
|
||||
} 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 { CLIPBOARD_CONTENT_QUOTE_PREFIX } from '$lib/constants/chat-form';
|
||||
import { KeyboardKey, MimeTypeText } from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { MimeTypeText } from '$lib/enums';
|
||||
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
|
||||
import {
|
||||
AudioRecorder,
|
||||
@@ -25,68 +24,82 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
// Data
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
uploadedFiles?: ChatUploadedFile[];
|
||||
value?: string;
|
||||
|
||||
// UI State
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
initialMessage?: string;
|
||||
isLoading?: boolean;
|
||||
onFileRemove?: (fileId: string) => void;
|
||||
onFileUpload?: (files: File[]) => void;
|
||||
onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
|
||||
placeholder?: string;
|
||||
|
||||
// Event Handlers
|
||||
onAttachmentRemove?: (index: number) => void;
|
||||
onFilesAdd?: (files: File[]) => void;
|
||||
onStop?: () => void;
|
||||
onSystemPromptAdd?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
|
||||
showHelperText?: boolean;
|
||||
uploadedFiles?: ChatUploadedFile[];
|
||||
onSubmit?: () => void;
|
||||
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
|
||||
onUploadedFileRemove?: (fileId: string) => void;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className,
|
||||
attachments = [],
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
initialMessage = '',
|
||||
isLoading = false,
|
||||
onFileRemove,
|
||||
onFileUpload,
|
||||
onSend,
|
||||
placeholder = 'Type a message...',
|
||||
uploadedFiles = $bindable([]),
|
||||
value = $bindable(''),
|
||||
onAttachmentRemove,
|
||||
onFilesAdd,
|
||||
onStop,
|
||||
onSystemPromptAdd,
|
||||
showHelperText = true,
|
||||
uploadedFiles = $bindable([])
|
||||
onSubmit,
|
||||
onSystemPromptClick,
|
||||
onUploadedFileRemove,
|
||||
onValueChange
|
||||
}: Props = $props();
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* STATE
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
// Component References
|
||||
let audioRecorder: AudioRecorder | undefined;
|
||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||
let currentConfig = $derived(config());
|
||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
||||
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
|
||||
|
||||
// Audio Recording State
|
||||
let isRecording = $state(false);
|
||||
let message = $derived(initialMessage);
|
||||
let recordingSupported = $state(false);
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* DERIVED STATE
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
// Configuration
|
||||
let currentConfig = $derived(config());
|
||||
let pasteLongTextToFileLength = $derived.by(() => {
|
||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||
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)
|
||||
$effect(() => {
|
||||
if (initialMessage !== previousInitialMessage) {
|
||||
message = initialMessage;
|
||||
previousInitialMessage = initialMessage;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSystemPromptClick() {
|
||||
onSystemPromptAdd?.({ message, files: uploadedFiles });
|
||||
}
|
||||
|
||||
// Check if model is selected (in ROUTER mode)
|
||||
// Model Selection Logic
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let conversationModel = $derived(
|
||||
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(() => {
|
||||
const options = modelOptions();
|
||||
|
||||
@@ -94,14 +107,12 @@
|
||||
return options.length > 0 ? options[0].model : null;
|
||||
}
|
||||
|
||||
// First try user-selected model
|
||||
const selectedId = selectedModelId();
|
||||
if (selectedId) {
|
||||
const model = options.find((m) => m.id === selectedId);
|
||||
if (model) return model.model;
|
||||
}
|
||||
|
||||
// Fallback to conversation model
|
||||
if (conversationModel) {
|
||||
const model = options.find((m) => m.model === conversationModel);
|
||||
if (model) return model.model;
|
||||
@@ -110,46 +121,101 @@
|
||||
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) {
|
||||
// Open the model selector
|
||||
chatFormActionsRef?.openModelSelector();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* EVENT HANDLERS - File Management
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
function handleFileSelect(files: File[]) {
|
||||
onFileUpload?.(files);
|
||||
onFilesAdd?.(files);
|
||||
}
|
||||
|
||||
function handleFileUpload() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
|
||||
async function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
|
||||
function handleFileRemove(fileId: string) {
|
||||
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();
|
||||
|
||||
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
|
||||
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
|
||||
|
||||
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;
|
||||
}
|
||||
onSubmit?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,29 +229,30 @@
|
||||
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onFileUpload?.(files);
|
||||
|
||||
onFilesAdd?.(files);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
|
||||
|
||||
if (text.startsWith('"')) {
|
||||
if (text.startsWith(CLIPBOARD_CONTENT_QUOTE_PREFIX)) {
|
||||
const parsed = parseClipboardContent(text);
|
||||
|
||||
if (parsed.textAttachments.length > 0) {
|
||||
event.preventDefault();
|
||||
value = parsed.message;
|
||||
onValueChange?.(parsed.message);
|
||||
|
||||
message = parsed.message;
|
||||
|
||||
const attachmentFiles = parsed.textAttachments.map(
|
||||
(att) =>
|
||||
new File([att.content], att.name, {
|
||||
type: MimeTypeText.PLAIN
|
||||
})
|
||||
);
|
||||
|
||||
onFileUpload?.(attachmentFiles);
|
||||
// Handle text attachments as files
|
||||
if (parsed.textAttachments.length > 0) {
|
||||
const attachmentFiles = parsed.textAttachments.map(
|
||||
(att) =>
|
||||
new File([att.content], att.name, {
|
||||
type: MimeTypeText.PLAIN
|
||||
})
|
||||
);
|
||||
onFilesAdd?.(attachmentFiles);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
textareaRef?.focus();
|
||||
@@ -206,14 +273,21 @@
|
||||
type: MimeTypeText.PLAIN
|
||||
});
|
||||
|
||||
onFileUpload?.([textFile]);
|
||||
onFilesAdd?.([textFile]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* EVENT HANDLERS - Audio Recording
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
async function handleMicClick() {
|
||||
if (!audioRecorder || !recordingSupported) {
|
||||
console.warn('Audio recording not supported');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,7 +297,7 @@
|
||||
const wavBlob = await convertToWav(audioBlob);
|
||||
const audioFile = createAudioFile(wavBlob);
|
||||
|
||||
onFileUpload?.([audioFile]);
|
||||
onFilesAdd?.([audioFile]);
|
||||
isRecording = false;
|
||||
} catch (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>
|
||||
|
||||
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
|
||||
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="relative {INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: ''} {className}"
|
||||
data-slot="chat-form"
|
||||
class="relative {className}"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
|
||||
onSubmit?.();
|
||||
}}
|
||||
>
|
||||
<ChatAttachmentsList
|
||||
bind:uploadedFiles
|
||||
{onFileRemove}
|
||||
limitToSingleRow
|
||||
class="py-5"
|
||||
style="scroll-padding: 1rem;"
|
||||
activeModelId={activeModelId ?? undefined}
|
||||
/>
|
||||
|
||||
<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"
|
||||
onpaste={handlePaste}
|
||||
class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: ''}"
|
||||
data-slot="input-area"
|
||||
>
|
||||
<ChatFormTextarea
|
||||
class="px-5 py-1.5 md:pt-0"
|
||||
bind:this={textareaRef}
|
||||
bind:value={message}
|
||||
onKeydown={handleKeydown}
|
||||
{disabled}
|
||||
<ChatAttachmentsList
|
||||
{attachments}
|
||||
bind:uploadedFiles
|
||||
onFileRemove={handleFileRemove}
|
||||
limitToSingleRow
|
||||
class="py-5"
|
||||
style="scroll-padding: 1rem;"
|
||||
activeModelId={activeModelId ?? undefined}
|
||||
/>
|
||||
|
||||
<ChatFormActions
|
||||
class="px-3"
|
||||
bind:this={chatFormActionsRef}
|
||||
canSend={message.trim().length > 0 || uploadedFiles.length > 0}
|
||||
hasText={message.trim().length > 0}
|
||||
{disabled}
|
||||
{isLoading}
|
||||
{isRecording}
|
||||
{uploadedFiles}
|
||||
onFileUpload={handleFileUpload}
|
||||
onMicClick={handleMicClick}
|
||||
onStop={handleStop}
|
||||
onSystemPromptClick={handleSystemPromptClick}
|
||||
/>
|
||||
<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"
|
||||
onpaste={handlePaste}
|
||||
>
|
||||
<ChatFormTextarea
|
||||
class="px-5 py-1.5 md:pt-0"
|
||||
bind:this={textareaRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
{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>
|
||||
</form>
|
||||
|
||||
<ChatFormHelperText show={showHelperText} />
|
||||
|
||||
+110
-126
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
@@ -16,16 +16,6 @@
|
||||
onSystemPromptClick?: () => void;
|
||||
}
|
||||
|
||||
type AttachmentActionId = 'images' | 'audio' | 'text' | 'pdf' | 'system';
|
||||
|
||||
interface AttachmentAction {
|
||||
id: AttachmentActionId;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
@@ -36,62 +26,20 @@
|
||||
}: Props = $props();
|
||||
|
||||
let isNewChat = $derived(!page.params.id);
|
||||
|
||||
let systemMessageTooltip = $derived(
|
||||
isNewChat
|
||||
? 'Add custom system message for a new conversation'
|
||||
: 'Inject custom system message at the beginning of the conversation'
|
||||
);
|
||||
|
||||
let actions = $derived.by<AttachmentAction[]>(() => [
|
||||
{
|
||||
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
|
||||
}
|
||||
]);
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
function handleActionClick(id: AttachmentActionId) {
|
||||
if (id === 'system') {
|
||||
onSystemPromptClick?.();
|
||||
return;
|
||||
}
|
||||
|
||||
onFileUpload?.();
|
||||
}
|
||||
|
||||
const triggerTooltipText = 'Add files or system message';
|
||||
const itemClass = 'flex cursor-pointer items-center gap-2';
|
||||
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Root bind:open={dropdownOpen}>
|
||||
<DropdownMenu.Trigger name="Attach files" {disabled}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
@@ -101,89 +49,125 @@
|
||||
variant="secondary"
|
||||
type="button"
|
||||
>
|
||||
<span class="sr-only">{triggerTooltipText}</span>
|
||||
<span class="sr-only">{fileUploadTooltipText}</span>
|
||||
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{triggerTooltipText}</p>
|
||||
<p>{fileUploadTooltipText}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content align="start" class="w-56">
|
||||
{#each actions as item (item.id)}
|
||||
{@const hasDisabledTooltip = !!item.disabled && !!item.disabledReason}
|
||||
{@const hasEnabledTooltip = !item.disabled && !!item.tooltip}
|
||||
<DropdownMenu.Content align="start" class="w-48">
|
||||
{#if hasVisionModality}
|
||||
<DropdownMenu.Item
|
||||
class="images-button flex cursor-pointer items-center gap-2"
|
||||
onclick={() => onFileUpload?.()}
|
||||
>
|
||||
<FILE_TYPE_ICONS.image class="h-4 w-4" />
|
||||
|
||||
{#if hasDisabledTooltip}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item class={itemClass} disabled>
|
||||
{#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.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'}
|
||||
<span>Images</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="images-button flex cursor-pointer items-center gap-2"
|
||||
disabled
|
||||
>
|
||||
<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>
|
||||
{/if}
|
||||
{/each}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>{systemMessageTooltip}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
-143
@@ -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>
|
||||
+1
-12
@@ -13,8 +13,7 @@
|
||||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { activeMessages, usedModalities } from '$lib/stores/conversations.svelte';
|
||||
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
|
||||
import { activeMessages } from '$lib/stores/conversations.svelte';
|
||||
|
||||
interface Props {
|
||||
canSend?: boolean;
|
||||
@@ -154,15 +153,6 @@
|
||||
export function openModelSelector() {
|
||||
selectorModelRef?.open();
|
||||
}
|
||||
|
||||
const { handleModelChange } = useModelChangeValidation({
|
||||
getRequiredModalities: () => usedModalities(),
|
||||
onValidationFailure: async (previousModelId: string | null) => {
|
||||
if (previousModelId) {
|
||||
await modelsStore.selectModelById(previousModelId);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
|
||||
@@ -183,7 +173,6 @@
|
||||
currentModel={conversationModel}
|
||||
forceForegroundText={true}
|
||||
useGlobalSelection={true}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,61 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import {
|
||||
chatStore,
|
||||
pendingEditMessageId,
|
||||
clearPendingEditMessageId,
|
||||
removeSystemPromptPlaceholder
|
||||
} from '$lib/stores/chat.svelte';
|
||||
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
|
||||
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { DatabaseService } from '$lib/services';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
|
||||
import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
|
||||
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
|
||||
import ChatMessageUser from './ChatMessageUser.svelte';
|
||||
import ChatMessageSystem from './ChatMessageSystem.svelte';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import {
|
||||
ChatMessageAssistant,
|
||||
ChatMessageUser,
|
||||
ChatMessageSystem
|
||||
} from '$lib/components/app/chat';
|
||||
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
onCopy?: (message: DatabaseMessage) => void;
|
||||
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;
|
||||
isLastAssistantMessage?: boolean;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
onCopy,
|
||||
onContinueAssistantMessage,
|
||||
onDelete,
|
||||
onEditWithBranching,
|
||||
onEditWithReplacement,
|
||||
onEditUserMessagePreserveResponses,
|
||||
onNavigateToSibling,
|
||||
onRegenerateWithBranching,
|
||||
isLastAssistantMessage = false,
|
||||
siblingInfo = null
|
||||
}: Props = $props();
|
||||
|
||||
const chatActions = getChatActionsContext();
|
||||
|
||||
let deletionInfo = $state<{
|
||||
totalCount: number;
|
||||
userMessages: number;
|
||||
@@ -70,45 +44,51 @@
|
||||
let shouldBranchAfterEdit = $state(false);
|
||||
let textareaElement: HTMLTextAreaElement | undefined = $state();
|
||||
|
||||
let thinkingContent = $derived.by(() => {
|
||||
if (message.role === 'assistant') {
|
||||
const trimmedThinking = message.thinking?.trim();
|
||||
let showSaveOnlyOption = $derived(message.role === MessageRole.USER);
|
||||
|
||||
return trimmedThinking ? trimmedThinking : null;
|
||||
}
|
||||
return null;
|
||||
setMessageEditContext({
|
||||
get isEditing() {
|
||||
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(() => {
|
||||
const pendingId = pendingEditMessageId();
|
||||
|
||||
if (pendingId && pendingId === message.id && !isEditing) {
|
||||
handleEdit();
|
||||
clearPendingEditMessageId();
|
||||
chatStore.clearPendingEditMessageId();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -116,8 +96,8 @@
|
||||
isEditing = false;
|
||||
|
||||
// If canceling a new system message with placeholder content, remove it without deleting children
|
||||
if (message.role === 'system') {
|
||||
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
|
||||
if (message.role === MessageRole.SYSTEM) {
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
|
||||
if (conversationDeleted) {
|
||||
goto(`${base}/`);
|
||||
@@ -131,30 +111,19 @@
|
||||
editedUploadedFiles = [];
|
||||
}
|
||||
|
||||
function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
|
||||
editedExtras = extras;
|
||||
}
|
||||
|
||||
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);
|
||||
function handleCopy() {
|
||||
chatActions.copy(message);
|
||||
}
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
if (message.role === 'system') {
|
||||
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
|
||||
if (message.role === MessageRole.SYSTEM) {
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
|
||||
if (conversationDeleted) {
|
||||
goto('/');
|
||||
goto(`${base}/`);
|
||||
}
|
||||
} else {
|
||||
onDelete?.(message);
|
||||
chatActions.delete(message);
|
||||
}
|
||||
|
||||
showDeleteDialog = false;
|
||||
@@ -167,9 +136,9 @@
|
||||
|
||||
function handleEdit() {
|
||||
isEditing = true;
|
||||
// Clear placeholder content for system messages
|
||||
// Clear temporary placeholder content for system messages
|
||||
editedContent =
|
||||
message.role === 'system' && message.content === SYSTEM_MESSAGE_PLACEHOLDER
|
||||
message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER
|
||||
? ''
|
||||
: message.content;
|
||||
textareaElement?.focus();
|
||||
@@ -187,38 +156,26 @@
|
||||
}, 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) {
|
||||
onRegenerateWithBranching?.(message, modelOverride);
|
||||
chatActions.regenerateWithBranching(message, modelOverride);
|
||||
}
|
||||
|
||||
function handleContinue() {
|
||||
onContinueAssistantMessage?.(message);
|
||||
chatActions.continueAssistantMessage(message);
|
||||
}
|
||||
|
||||
function handleNavigateToSibling(siblingId: string) {
|
||||
chatActions.navigateToSibling(siblingId);
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (message.role === 'system') {
|
||||
if (message.role === MessageRole.SYSTEM) {
|
||||
// System messages: update in place without branching
|
||||
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) {
|
||||
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
|
||||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
isEditing = false;
|
||||
if (conversationDeleted) {
|
||||
goto(`${base}/`);
|
||||
@@ -231,13 +188,13 @@
|
||||
if (index !== -1) {
|
||||
conversationsStore.updateMessageAtIndex(index, { content: newContent });
|
||||
}
|
||||
} else if (message.role === 'user') {
|
||||
} else if (message.role === MessageRole.USER) {
|
||||
const finalExtras = await getMergedExtras();
|
||||
onEditWithBranching?.(message, editedContent.trim(), finalExtras);
|
||||
chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
|
||||
} else {
|
||||
// For assistant messages, preserve exact content including trailing whitespace
|
||||
// This is important for the Continue feature to work properly
|
||||
onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
|
||||
chatActions.editWithReplacement(message, editedContent, shouldBranchAfterEdit);
|
||||
}
|
||||
|
||||
isEditing = false;
|
||||
@@ -246,10 +203,10 @@
|
||||
}
|
||||
|
||||
async function handleSaveEditOnly() {
|
||||
if (message.role === 'user') {
|
||||
if (message.role === MessageRole.USER) {
|
||||
// For user messages, trim to avoid accidental whitespace
|
||||
const finalExtras = await getMergedExtras();
|
||||
onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
|
||||
chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
|
||||
}
|
||||
|
||||
isEditing = false;
|
||||
@@ -261,8 +218,8 @@
|
||||
return editedExtras;
|
||||
}
|
||||
|
||||
const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
|
||||
const result = await parseFilesToMessageExtras(editedUploadedFiles);
|
||||
const plainFiles = $state.snapshot(editedUploadedFiles);
|
||||
const result = await parseFilesToMessageExtras(plainFiles);
|
||||
const newExtras = result?.extras || [];
|
||||
|
||||
return [...editedExtras, ...newExtras];
|
||||
@@ -273,49 +230,31 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if message.role === 'system'}
|
||||
{#if message.role === MessageRole.SYSTEM}
|
||||
<ChatMessageSystem
|
||||
bind:textareaElement
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{editedContent}
|
||||
{isEditing}
|
||||
{message}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditKeydown={handleEditKeydown}
|
||||
onEditedContentChange={handleEditedContentChange}
|
||||
{onNavigateToSibling}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
/>
|
||||
{:else if message.role === 'user'}
|
||||
{:else if message.role === MessageRole.USER}
|
||||
<ChatMessageUser
|
||||
bind:textareaElement
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{editedContent}
|
||||
{editedExtras}
|
||||
{editedUploadedFiles}
|
||||
{isEditing}
|
||||
{message}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditKeydown={handleEditKeydown}
|
||||
onEditedContentChange={handleEditedContentChange}
|
||||
onEditedExtrasChange={handleEditedExtrasChange}
|
||||
onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
|
||||
{onNavigateToSibling}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onSaveEditOnly={handleSaveEditOnly}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
@@ -325,27 +264,18 @@
|
||||
bind:textareaElement
|
||||
class={className}
|
||||
{deletionInfo}
|
||||
{editedContent}
|
||||
{isEditing}
|
||||
{isLastAssistantMessage}
|
||||
{message}
|
||||
messageContent={message.content}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onContinue={handleContinue}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditKeydown={handleEditKeydown}
|
||||
onEditedContentChange={handleEditedContentChange}
|
||||
{onNavigateToSibling}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onRegenerate={handleRegenerate}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||
{shouldBranchAfterEdit}
|
||||
onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
{thinkingContent}
|
||||
{toolCallContent}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+10
-9
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
|
||||
import {
|
||||
ActionButton,
|
||||
ActionIcon,
|
||||
ChatMessageBranchingControls,
|
||||
DialogConfirmation
|
||||
} from '$lib/components/app';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
role: 'user' | 'assistant';
|
||||
role: MessageRole.USER | MessageRole.ASSISTANT;
|
||||
justify: 'start' | 'end';
|
||||
actionsPosition: 'left' | 'right';
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
@@ -71,21 +72,21 @@
|
||||
<div
|
||||
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}
|
||||
<ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} />
|
||||
<ActionIcon icon={Edit} tooltip="Edit" onclick={onEdit} />
|
||||
{/if}
|
||||
|
||||
{#if role === 'assistant' && onRegenerate}
|
||||
<ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
|
||||
{#if role === MessageRole.ASSISTANT && onRegenerate}
|
||||
<ActionIcon icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
|
||||
{/if}
|
||||
|
||||
{#if role === 'assistant' && onContinue}
|
||||
<ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
|
||||
{#if role === MessageRole.ASSISTANT && onContinue}
|
||||
<ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
|
||||
{/if}
|
||||
|
||||
<ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} />
|
||||
<ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+204
-180
@@ -1,26 +1,29 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ModelBadge,
|
||||
ChatMessageActions,
|
||||
ChatMessageStatistics,
|
||||
ChatMessageThinkingBlock,
|
||||
CopyToClipboardIcon,
|
||||
MarkdownContent,
|
||||
ModelBadge,
|
||||
ModelsSelector
|
||||
} from '$lib/components/app';
|
||||
import ChatMessageThinkingBlock from './ChatMessageThinkingBlock.svelte';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
|
||||
import { isLoading } from '$lib/stores/chat.svelte';
|
||||
import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
|
||||
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
|
||||
import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
|
||||
import { tick } from 'svelte';
|
||||
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 { 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 { config } from '$lib/stores/settings.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.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 {
|
||||
class?: string;
|
||||
@@ -30,153 +33,198 @@
|
||||
assistantMessages: number;
|
||||
messageTypes: string[];
|
||||
} | null;
|
||||
editedContent?: string;
|
||||
isEditing?: boolean;
|
||||
isLastAssistantMessage?: boolean;
|
||||
message: DatabaseMessage;
|
||||
messageContent: string | undefined;
|
||||
onCancelEdit?: () => void;
|
||||
onCopy: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onContinue?: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: () => void;
|
||||
onEditKeydown?: (event: KeyboardEvent) => void;
|
||||
onEditedContentChange?: (content: string) => void;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onRegenerate: (modelOverride?: string) => void;
|
||||
onSaveEdit?: () => void;
|
||||
onShowDeleteDialogChange: (show: boolean) => void;
|
||||
onShouldBranchAfterEditChange?: (value: boolean) => void;
|
||||
showDeleteDialog: boolean;
|
||||
shouldBranchAfterEdit?: boolean;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
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 {
|
||||
class: className = '',
|
||||
deletionInfo,
|
||||
editedContent = '',
|
||||
isEditing = false,
|
||||
isLastAssistantMessage = false,
|
||||
message,
|
||||
messageContent,
|
||||
onCancelEdit,
|
||||
onConfirmDelete,
|
||||
onContinue,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onEditKeydown,
|
||||
onEditedContentChange,
|
||||
onNavigateToSibling,
|
||||
onRegenerate,
|
||||
onSaveEdit,
|
||||
onShowDeleteDialogChange,
|
||||
onShouldBranchAfterEditChange,
|
||||
showDeleteDialog,
|
||||
shouldBranchAfterEdit = false,
|
||||
siblingInfo = null,
|
||||
textareaElement = $bindable(),
|
||||
thinkingContent,
|
||||
toolCallContent = null
|
||||
textareaElement = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
const toolCalls = $derived(
|
||||
Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
|
||||
);
|
||||
const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
|
||||
// Get edit context
|
||||
const editCtx = getMessageEditContext();
|
||||
|
||||
// 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();
|
||||
|
||||
// Local state for raw output toggle (per message)
|
||||
let showRawOutput = $state(false);
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
let displayedModel = $derived((): string | null => {
|
||||
if (message.model) {
|
||||
return message.model;
|
||||
let showRawOutput = $state(false);
|
||||
let statsContainerEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
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({
|
||||
getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
|
||||
onSuccess: (modelName: string) => onRegenerate(modelName)
|
||||
});
|
||||
const yBefore = el.getBoundingClientRect().top;
|
||||
|
||||
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() {
|
||||
const model = displayedModel();
|
||||
|
||||
void copyToClipboard(model ?? '');
|
||||
void copyToClipboard(displayedModel ?? '');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isEditing && textareaElement) {
|
||||
if (editCtx.isEditing && textareaElement) {
|
||||
autoResizeTextarea(textareaElement);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isLoading() && !message?.content?.trim()) {
|
||||
if (showProcessingInfoTop || showProcessingInfoBottom) {
|
||||
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>
|
||||
|
||||
<div
|
||||
@@ -184,34 +232,36 @@
|
||||
role="group"
|
||||
aria-label="Assistant message with actions"
|
||||
>
|
||||
{#if thinkingContent}
|
||||
{#if !editCtx.isEditing && thinkingContent}
|
||||
<ChatMessageThinkingBlock
|
||||
reasoningContent={thinkingContent}
|
||||
isStreaming={!message.timestamp}
|
||||
hasRegularContent={!!messageContent?.trim()}
|
||||
hasRegularContent={!!visibleMessageContent?.trim()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
|
||||
{#if showProcessingInfoTop}
|
||||
<div class="mt-6 w-full max-w-[48rem]" in:fade>
|
||||
<div class="processing-container">
|
||||
<span class="processing-text">
|
||||
{processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
|
||||
{processingState.getPromptProgressText() ??
|
||||
processingState.getProcessingMessage() ??
|
||||
'Processing...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isEditing}
|
||||
{#if editCtx.isEditing}
|
||||
<div class="w-full">
|
||||
<textarea
|
||||
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}"
|
||||
onkeydown={onEditKeydown}
|
||||
onkeydown={handleEditKeydown}
|
||||
oninput={(e) => {
|
||||
autoResizeTextarea(e.currentTarget);
|
||||
onEditedContentChange?.(e.currentTarget.value);
|
||||
editCtx.setContent(e.currentTarget.value);
|
||||
}}
|
||||
placeholder="Edit assistant message..."
|
||||
></textarea>
|
||||
@@ -221,30 +271,35 @@
|
||||
<Checkbox
|
||||
id="branch-after-edit"
|
||||
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">
|
||||
Branch conversation after edit
|
||||
</Label>
|
||||
</div>
|
||||
<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" />
|
||||
Cancel
|
||||
</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" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if message.role === 'assistant'}
|
||||
{:else if message.role === MessageRole.ASSISTANT}
|
||||
{#if showRawOutput}
|
||||
<pre class="raw-output">{messageContent || ''}</pre>
|
||||
{:else}
|
||||
<MarkdownContent content={messageContent || ''} />
|
||||
<MarkdownContent content={visibleMessageContent || ''} attachments={message.extra} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm whitespace-pre-wrap">
|
||||
@@ -252,18 +307,41 @@
|
||||
</div>
|
||||
{/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">
|
||||
{#if displayedModel()}
|
||||
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||
{#if displayedModel}
|
||||
<div
|
||||
bind:this={statsContainerEl}
|
||||
class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{#if isRouter}
|
||||
<ModelsSelector
|
||||
currentModel={displayedModel()}
|
||||
onModelChange={handleModelChange}
|
||||
currentModel={displayedModel}
|
||||
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}
|
||||
<ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
|
||||
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
|
||||
{/if}
|
||||
|
||||
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||
@@ -272,6 +350,7 @@
|
||||
promptMs={message.timings.prompt_ms}
|
||||
predictedTokens={message.timings.predicted_n}
|
||||
predictedMs={message.timings.predicted_ms}
|
||||
onActiveViewChange={handleStatsViewChange}
|
||||
/>
|
||||
{:else if isLoading() && currentConfig.showMessageStats}
|
||||
{@const liveStats = processingState.getLiveProcessingStats()}
|
||||
@@ -293,53 +372,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
{#if message.timestamp && !isEditing}
|
||||
{#if message.timestamp && !editCtx.isEditing}
|
||||
<ChatMessageActions
|
||||
role="assistant"
|
||||
role={MessageRole.ASSISTANT}
|
||||
justify="start"
|
||||
actionsPosition="left"
|
||||
{siblingInfo}
|
||||
@@ -348,7 +385,7 @@
|
||||
{onCopy}
|
||||
{onEdit}
|
||||
{onRegenerate}
|
||||
onContinue={currentConfig.enableContinueGeneration && !thinkingContent
|
||||
onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
|
||||
? onContinue
|
||||
: undefined}
|
||||
{onDelete}
|
||||
@@ -408,17 +445,4 @@
|
||||
white-space: pre-wrap;
|
||||
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>
|
||||
|
||||
+48
-303
@@ -1,79 +1,26 @@
|
||||
<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 { Switch } from '$lib/components/ui/switch';
|
||||
import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
|
||||
import { INPUT_CLASSES } from '$lib/constants/input-classes';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
|
||||
import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
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';
|
||||
import { ChatForm, DialogConfirmation } from '$lib/components/app';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
|
||||
|
||||
interface Props {
|
||||
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;
|
||||
}
|
||||
const editCtx = getMessageEditContext();
|
||||
|
||||
let {
|
||||
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 inputAreaRef: ChatForm | undefined = $state(undefined);
|
||||
let saveWithoutRegenerate = $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(() => {
|
||||
if (editedContent !== originalContent) return true;
|
||||
if (editedUploadedFiles.length > 0) return true;
|
||||
if (editCtx.editedContent !== editCtx.originalContent) return true;
|
||||
if (editCtx.editedUploadedFiles.length > 0) return true;
|
||||
|
||||
const extrasChanged =
|
||||
editedExtras.length !== originalExtras.length ||
|
||||
editedExtras.some((extra, i) => extra !== originalExtras[i]);
|
||||
editCtx.editedExtras.length !== editCtx.originalExtras.length ||
|
||||
editCtx.editedExtras.some((extra, i) => extra !== editCtx.originalExtras[i]);
|
||||
|
||||
if (extrasChanged) return true;
|
||||
|
||||
@@ -81,77 +28,14 @@
|
||||
});
|
||||
|
||||
let hasAttachments = $derived(
|
||||
(editedExtras && editedExtras.length > 0) ||
|
||||
(editedUploadedFiles && editedUploadedFiles.length > 0)
|
||||
(editCtx.editedExtras && editCtx.editedExtras.length > 0) ||
|
||||
(editCtx.editedUploadedFiles && editCtx.editedUploadedFiles.length > 0)
|
||||
);
|
||||
|
||||
let canSubmit = $derived(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 = '';
|
||||
}
|
||||
let canSubmit = $derived(editCtx.editedContent.trim().length > 0 || hasAttachments);
|
||||
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
attemptCancel();
|
||||
}
|
||||
@@ -161,205 +45,66 @@
|
||||
if (hasUnsavedChanges) {
|
||||
showDiscardDialog = true;
|
||||
} 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() {
|
||||
if (!canSubmit) return;
|
||||
|
||||
if (saveWithoutRegenerate && onSaveEditOnly) {
|
||||
onSaveEditOnly();
|
||||
if (saveWithoutRegenerate && editCtx.showSaveOnlyOption) {
|
||||
editCtx.saveOnly();
|
||||
} else {
|
||||
onSaveEdit();
|
||||
editCtx.save();
|
||||
}
|
||||
|
||||
saveWithoutRegenerate = false;
|
||||
}
|
||||
|
||||
async function processNewFiles(files: File[]) {
|
||||
if (!onEditedUploadedFilesChange) return;
|
||||
function handleAttachmentRemove(index: number) {
|
||||
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);
|
||||
|
||||
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]);
|
||||
}
|
||||
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (textareaElement) {
|
||||
autoResizeTextarea(textareaElement);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
setEditModeActive(processNewFiles);
|
||||
chatStore.setEditModeActive(handleFilesAdd);
|
||||
|
||||
return () => {
|
||||
clearEditMode();
|
||||
chatStore.clearEditMode();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||
|
||||
<input
|
||||
bind:this={fileInputElement}
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
|
||||
data-slot="edit-form"
|
||||
>
|
||||
<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 w-full max-w-[80%]">
|
||||
<ChatForm
|
||||
bind:this={inputAreaRef}
|
||||
value={editCtx.editedContent}
|
||||
attachments={editCtx.editedExtras}
|
||||
uploadedFiles={editCtx.editedUploadedFiles}
|
||||
placeholder="Edit your message..."
|
||||
onValueChange={editCtx.setContent}
|
||||
onAttachmentRemove={handleAttachmentRemove}
|
||||
onUploadedFileRemove={handleUploadedFileRemove}
|
||||
onFilesAdd={handleFilesAdd}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<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 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">
|
||||
<Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
|
||||
|
||||
@@ -386,6 +131,6 @@
|
||||
cancelText="Keep editing"
|
||||
variant="destructive"
|
||||
icon={AlertTriangle}
|
||||
onConfirm={onCancelEdit}
|
||||
onConfirm={editCtx.cancel}
|
||||
onCancel={() => (showDiscardDialog = false)}
|
||||
/>
|
||||
|
||||
+23
-9
@@ -3,19 +3,18 @@
|
||||
import { BadgeChatStatistic } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
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 {
|
||||
predictedTokens?: number;
|
||||
predictedMs?: number;
|
||||
promptTokens?: number;
|
||||
promptMs?: number;
|
||||
// Live mode: when true, shows stats during streaming
|
||||
isLive?: boolean;
|
||||
// Whether prompt processing is still in progress
|
||||
isProcessingPrompt?: boolean;
|
||||
// Initial view to show (defaults to READING in live mode)
|
||||
initialView?: ChatMessageStatsView;
|
||||
onActiveViewChange?: (view: ChatMessageStatsView) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -25,12 +24,17 @@
|
||||
promptMs,
|
||||
isLive = false,
|
||||
isProcessingPrompt = false,
|
||||
initialView = ChatMessageStatsView.GENERATION
|
||||
initialView = ChatMessageStatsView.GENERATION,
|
||||
onActiveViewChange
|
||||
}: Props = $props();
|
||||
|
||||
let activeView: ChatMessageStatsView = $derived(initialView);
|
||||
let hasAutoSwitchedToGeneration = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
onActiveViewChange?.(activeView);
|
||||
});
|
||||
|
||||
// In live mode: auto-switch to GENERATION tab when prompt processing completes
|
||||
$effect(() => {
|
||||
if (isLive) {
|
||||
@@ -57,14 +61,16 @@
|
||||
predictedMs > 0
|
||||
);
|
||||
|
||||
let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
|
||||
let tokensPerSecond = $derived(
|
||||
hasGenerationStats ? (predictedTokens! / predictedMs!) * MS_PER_SECOND : 0
|
||||
);
|
||||
let formattedTime = $derived(
|
||||
predictedMs !== undefined ? formatPerformanceTime(predictedMs) : '0s'
|
||||
predictedMs !== undefined ? formatPerformanceTime(predictedMs) : DEFAULT_PERFORMANCE_TIME
|
||||
);
|
||||
|
||||
let promptTokensPerSecond = $derived(
|
||||
promptTokens !== undefined && promptMs !== undefined && promptMs > 0
|
||||
? (promptTokens / promptMs) * 1000
|
||||
? (promptTokens / promptMs) * MS_PER_SECOND
|
||||
: undefined
|
||||
);
|
||||
|
||||
@@ -97,9 +103,11 @@
|
||||
onclick={() => (activeView = ChatMessageStatsView.READING)}
|
||||
>
|
||||
<BookOpenText class="h-3 w-3" />
|
||||
|
||||
<span class="sr-only">Reading</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Reading (prompt processing)</p>
|
||||
</Tooltip.Content>
|
||||
@@ -119,9 +127,11 @@
|
||||
disabled={isGenerationDisabled}
|
||||
>
|
||||
<Sparkles class="h-3 w-3" />
|
||||
|
||||
<span class="sr-only">Generation</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>
|
||||
{isGenerationDisabled
|
||||
@@ -140,16 +150,18 @@
|
||||
value="{predictedTokens?.toLocaleString()} tokens"
|
||||
tooltipLabel="Generated tokens"
|
||||
/>
|
||||
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value={formattedTime}
|
||||
tooltipLabel="Generation time"
|
||||
/>
|
||||
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Gauge}
|
||||
value="{tokensPerSecond.toFixed(2)} tokens/s"
|
||||
value="{tokensPerSecond.toFixed(2)} t/s"
|
||||
tooltipLabel="Generation speed"
|
||||
/>
|
||||
{:else if hasPromptStats}
|
||||
@@ -159,12 +171,14 @@
|
||||
value="{promptTokens} tokens"
|
||||
tooltipLabel="Prompt tokens"
|
||||
/>
|
||||
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value={formattedPromptTime ?? '0s'}
|
||||
tooltipLabel="Prompt processing time"
|
||||
/>
|
||||
|
||||
<BadgeChatStatistic
|
||||
class="bg-transparent"
|
||||
icon={Gauge}
|
||||
|
||||
+41
-24
@@ -3,15 +3,16 @@
|
||||
import { Card } from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
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 { isIMEComposing } from '$lib/utils';
|
||||
import ChatMessageActions from './ChatMessageActions.svelte';
|
||||
import { KeyboardKey, MessageRole } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
isEditing: boolean;
|
||||
editedContent: string;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
showDeleteDialog: boolean;
|
||||
deletionInfo: {
|
||||
@@ -20,10 +21,6 @@
|
||||
assistantMessages: number;
|
||||
messageTypes: string[];
|
||||
} | null;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: () => void;
|
||||
onEditKeydown: (event: KeyboardEvent) => void;
|
||||
onEditedContentChange: (content: string) => void;
|
||||
onCopy: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -36,15 +33,9 @@
|
||||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
isEditing,
|
||||
editedContent,
|
||||
siblingInfo = null,
|
||||
showDeleteDialog,
|
||||
deletionInfo,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onEditKeydown,
|
||||
onEditedContentChange,
|
||||
onCopy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -54,10 +45,25 @@
|
||||
textareaElement = $bindable()
|
||||
}: 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 messageElement: HTMLElement | undefined = $state();
|
||||
let isExpanded = $state(false);
|
||||
let contentHeight = $state(0);
|
||||
|
||||
const MAX_HEIGHT = 200; // pixels
|
||||
const currentConfig = config();
|
||||
|
||||
@@ -97,25 +103,32 @@
|
||||
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
|
||||
role="group"
|
||||
>
|
||||
{#if isEditing}
|
||||
{#if editCtx.isEditing}
|
||||
<div class="w-full max-w-[80%]">
|
||||
<textarea
|
||||
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}"
|
||||
onkeydown={onEditKeydown}
|
||||
oninput={(e) => onEditedContentChange(e.currentTarget.value)}
|
||||
onkeydown={handleEditKeydown}
|
||||
oninput={(e) => editCtx.setContent(e.currentTarget.value)}
|
||||
placeholder="Edit system message..."
|
||||
></textarea>
|
||||
|
||||
<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" />
|
||||
|
||||
Cancel
|
||||
</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" />
|
||||
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
@@ -131,12 +144,12 @@
|
||||
type="button"
|
||||
>
|
||||
<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}
|
||||
style="border: 2px dashed hsl(var(--border));"
|
||||
style="border: 2px dashed hsl(var(--border)); max-height: var(--max-message-height);"
|
||||
>
|
||||
<div
|
||||
class="relative overflow-hidden transition-all duration-300 {isExpanded
|
||||
class="relative transition-all duration-300 {isExpanded
|
||||
? 'cursor-text select-text'
|
||||
: 'select-none'}"
|
||||
style={!isExpanded && showExpandButton
|
||||
@@ -145,7 +158,10 @@
|
||||
>
|
||||
{#if currentConfig.renderUserContentAsMarkdown}
|
||||
<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>
|
||||
{:else}
|
||||
<span
|
||||
@@ -160,6 +176,7 @@
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
|
||||
></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"
|
||||
>
|
||||
@@ -208,7 +225,7 @@
|
||||
{onShowDeleteDialogChange}
|
||||
{siblingInfo}
|
||||
{showDeleteDialog}
|
||||
role="user"
|
||||
role={MessageRole.USER}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+18
-55
@@ -1,67 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { Card } from '$lib/components/ui/card';
|
||||
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import ChatMessageActions from './ChatMessageActions.svelte';
|
||||
import ChatMessageEditForm from './ChatMessageEditForm.svelte';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
isEditing: boolean;
|
||||
editedContent: string;
|
||||
editedExtras?: DatabaseMessageExtra[];
|
||||
editedUploadedFiles?: ChatUploadedFile[];
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
showDeleteDialog: boolean;
|
||||
deletionInfo: {
|
||||
totalCount: number;
|
||||
userMessages: number;
|
||||
assistantMessages: number;
|
||||
messageTypes: string[];
|
||||
} | null;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: () => void;
|
||||
onSaveEditOnly?: () => void;
|
||||
onEditKeydown: (event: KeyboardEvent) => void;
|
||||
onEditedContentChange: (content: string) => void;
|
||||
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
|
||||
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
|
||||
onCopy: () => void;
|
||||
showDeleteDialog: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onShowDeleteDialogChange: (show: boolean) => void;
|
||||
textareaElement?: HTMLTextAreaElement;
|
||||
onNavigateToSibling?: (siblingId: string) => void;
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
message,
|
||||
isEditing,
|
||||
editedContent,
|
||||
editedExtras = [],
|
||||
editedUploadedFiles = [],
|
||||
siblingInfo = null,
|
||||
showDeleteDialog,
|
||||
deletionInfo,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onSaveEditOnly,
|
||||
onEditKeydown,
|
||||
onEditedContentChange,
|
||||
onEditedExtrasChange,
|
||||
onEditedUploadedFilesChange,
|
||||
onCopy,
|
||||
showDeleteDialog,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onNavigateToSibling,
|
||||
onShowDeleteDialogChange,
|
||||
textareaElement = $bindable()
|
||||
onNavigateToSibling,
|
||||
onCopy
|
||||
}: Props = $props();
|
||||
|
||||
// Get contexts
|
||||
const editCtx = getMessageEditContext();
|
||||
|
||||
let isMultiline = $state(false);
|
||||
let messageElement: HTMLElement | undefined = $state();
|
||||
const currentConfig = config();
|
||||
@@ -96,24 +77,8 @@
|
||||
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
|
||||
role="group"
|
||||
>
|
||||
{#if isEditing}
|
||||
<ChatMessageEditForm
|
||||
bind:textareaElement
|
||||
messageId={message.id}
|
||||
{editedContent}
|
||||
{editedExtras}
|
||||
{editedUploadedFiles}
|
||||
originalContent={message.content}
|
||||
originalExtras={message.extra}
|
||||
showSaveOnlyOption={!!onSaveEditOnly}
|
||||
{onCancelEdit}
|
||||
{onSaveEdit}
|
||||
{onSaveEditOnly}
|
||||
{onEditKeydown}
|
||||
{onEditedContentChange}
|
||||
{onEditedExtrasChange}
|
||||
{onEditedUploadedFilesChange}
|
||||
/>
|
||||
{#if editCtx.isEditing}
|
||||
<ChatMessageEditForm />
|
||||
{:else}
|
||||
{#if message.extra && message.extra.length > 0}
|
||||
<div class="mb-2 max-w-[80%]">
|
||||
@@ -123,15 +88,13 @@
|
||||
|
||||
{#if message.content.trim()}
|
||||
<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}
|
||||
style="max-height: var(--max-message-height); overflow-wrap: anywhere; word-break: break-word;"
|
||||
>
|
||||
{#if currentConfig.renderUserContentAsMarkdown}
|
||||
<div bind:this={messageElement} class="text-md">
|
||||
<MarkdownContent
|
||||
class="markdown-user-content text-primary-foreground"
|
||||
content={message.content}
|
||||
/>
|
||||
<div bind:this={messageElement}>
|
||||
<MarkdownContent class="markdown-user-content -my-4" content={message.content} />
|
||||
</div>
|
||||
{:else}
|
||||
<span bind:this={messageElement} class="text-md whitespace-pre-wrap">
|
||||
@@ -155,7 +118,7 @@
|
||||
{onShowDeleteDialogChange}
|
||||
{siblingInfo}
|
||||
{showDeleteDialog}
|
||||
role="user"
|
||||
role={MessageRole.USER}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { ChatMessage } from '$lib/components/app';
|
||||
import { setChatActionsContext } from '$lib/contexts';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { getMessageSiblings } from '$lib/utils';
|
||||
import { copyToClipboard, formatMessageForClipboard, getMessageSiblings } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
@@ -16,6 +18,69 @@
|
||||
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
||||
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() {
|
||||
const conversation = activeConversation();
|
||||
|
||||
@@ -42,16 +107,28 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter out system messages if showSystemMessage is false
|
||||
const filteredMessages = currentConfig.showSystemMessage
|
||||
? 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 isLastAssistantMessage =
|
||||
message.role === MessageRole.ASSISTANT && index === lastAssistantIndex;
|
||||
|
||||
return {
|
||||
message,
|
||||
isLastAssistantMessage,
|
||||
siblingInfo: siblingInfo || {
|
||||
message,
|
||||
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>
|
||||
|
||||
<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
|
||||
{#each displayMessages as { message, siblingInfo } (message.id)}
|
||||
<div class="flex h-full flex-col space-y-10 pt-24 {className}" style="height: auto; ">
|
||||
{#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)}
|
||||
<ChatMessage
|
||||
class="mx-auto w-full max-w-[48rem]"
|
||||
{message}
|
||||
{isLastAssistantMessage}
|
||||
{siblingInfo}
|
||||
onDelete={handleDeleteMessage}
|
||||
onNavigateToSibling={handleNavigateToSibling}
|
||||
onEditWithBranching={handleEditWithBranching}
|
||||
onEditWithReplacement={handleEditWithReplacement}
|
||||
onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
|
||||
onRegenerateWithBranching={handleRegenerateWithBranching}
|
||||
onContinueAssistantMessage={handleContinueAssistantMessage}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import {
|
||||
ChatForm,
|
||||
ChatScreenForm,
|
||||
ChatScreenHeader,
|
||||
ChatMessages,
|
||||
ChatScreenProcessingInfo,
|
||||
@@ -12,11 +12,9 @@
|
||||
} from '$lib/components/app';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import {
|
||||
AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
|
||||
AUTO_SCROLL_INTERVAL,
|
||||
INITIAL_SCROLL_DELAY
|
||||
} from '$lib/constants/auto-scroll';
|
||||
import { INITIAL_SCROLL_DELAY } from '$lib/constants/auto-scroll';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import {
|
||||
chatStore,
|
||||
errorDialog,
|
||||
@@ -44,16 +42,13 @@
|
||||
let { showCenteredEmpty = false } = $props();
|
||||
|
||||
let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
|
||||
let autoScrollEnabled = $state(true);
|
||||
let chatScrollContainer: HTMLDivElement | undefined = $state();
|
||||
let dragCounter = $state(0);
|
||||
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 uploadedFiles = $state<ChatUploadedFile[]>([]);
|
||||
let userScrolledUp = $state(false);
|
||||
|
||||
const autoScroll = createAutoScrollController();
|
||||
|
||||
let fileErrorData = $state<{
|
||||
generallyUnsupported: File[];
|
||||
@@ -217,7 +212,11 @@
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
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();
|
||||
if (activeConversation()) {
|
||||
showDeleteDialog = true;
|
||||
@@ -234,37 +233,13 @@
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (disableAutoScroll || !chatScrollContainer) return;
|
||||
|
||||
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;
|
||||
autoScroll.handleScroll();
|
||||
}
|
||||
|
||||
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
|
||||
const result = files
|
||||
? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
|
||||
const plainFiles = files ? $state.snapshot(files) : undefined;
|
||||
const result = plainFiles
|
||||
? await parseFilesToMessageExtras(plainFiles, activeModelId ?? undefined)
|
||||
: undefined;
|
||||
|
||||
if (result?.emptyFiles && result.emptyFiles.length > 0) {
|
||||
@@ -281,12 +256,9 @@
|
||||
const extras = result?.extras;
|
||||
|
||||
// Enable autoscroll for user-initiated message sending
|
||||
if (!disableAutoScroll) {
|
||||
userScrolledUp = false;
|
||||
autoScrollEnabled = true;
|
||||
}
|
||||
autoScroll.enable();
|
||||
await chatStore.sendMessage(message, extras);
|
||||
scrollChatToBottom();
|
||||
autoScroll.scrollToBottom();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -336,24 +308,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') {
|
||||
if (disableAutoScroll) return;
|
||||
|
||||
chatScrollContainer?.scrollTo({
|
||||
top: chatScrollContainer?.scrollHeight,
|
||||
behavior
|
||||
});
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
if (!disableAutoScroll) {
|
||||
setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
|
||||
setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (!disableAutoScroll) {
|
||||
setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
|
||||
setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
|
||||
}
|
||||
|
||||
const pendingDraft = chatStore.consumePendingDraft();
|
||||
@@ -364,21 +327,15 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (disableAutoScroll) {
|
||||
autoScrollEnabled = false;
|
||||
if (scrollInterval) {
|
||||
clearInterval(scrollInterval);
|
||||
scrollInterval = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
autoScroll.setContainer(chatScrollContainer);
|
||||
});
|
||||
|
||||
if (isCurrentConversationLoading && autoScrollEnabled) {
|
||||
scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL);
|
||||
} else if (scrollInterval) {
|
||||
clearInterval(scrollInterval);
|
||||
scrollInterval = undefined;
|
||||
}
|
||||
$effect(() => {
|
||||
autoScroll.setDisabled(disableAutoScroll);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
autoScroll.updateInterval(isCurrentConversationLoading);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -406,11 +363,8 @@
|
||||
class="mb-16 md:mb-24"
|
||||
messages={activeMessages()}
|
||||
onUserAction={() => {
|
||||
if (!disableAutoScroll) {
|
||||
userScrolledUp = false;
|
||||
autoScrollEnabled = true;
|
||||
scrollChatToBottom();
|
||||
}
|
||||
autoScroll.enable();
|
||||
autoScroll.scrollToBottom();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -444,7 +398,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
|
||||
<ChatForm
|
||||
<ChatScreenForm
|
||||
disabled={hasPropsError || isEditing()}
|
||||
{initialMessage}
|
||||
isLoading={isCurrentConversationLoading}
|
||||
@@ -474,7 +428,7 @@
|
||||
>
|
||||
<div class="w-full max-w-[48rem] px-4">
|
||||
<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">
|
||||
{serverStore.props?.modalities?.audio
|
||||
@@ -504,7 +458,7 @@
|
||||
{/if}
|
||||
|
||||
<div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
|
||||
<ChatForm
|
||||
<ChatScreenForm
|
||||
disabled={hasPropsError}
|
||||
{initialMessage}
|
||||
isLoading={isCurrentConversationLoading}
|
||||
@@ -617,7 +571,7 @@
|
||||
contextInfo={activeErrorDialog?.contextInfo}
|
||||
onOpenChange={handleErrorDialogOpenChange}
|
||||
open={Boolean(activeErrorDialog)}
|
||||
type={(activeErrorDialog?.type as ErrorDialogType) ?? ErrorDialogType.SERVER}
|
||||
type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}
|
||||
/>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<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 {
|
||||
class?: string;
|
||||
@@ -28,20 +30,92 @@
|
||||
showHelperText = true,
|
||||
uploadedFiles = $bindable([])
|
||||
}: 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>
|
||||
|
||||
<div class="relative mx-auto max-w-[48rem]">
|
||||
<ChatForm
|
||||
bind:this={chatFormRef}
|
||||
bind:value={message}
|
||||
bind:uploadedFiles
|
||||
class={className}
|
||||
{disabled}
|
||||
{initialMessage}
|
||||
{isLoading}
|
||||
{onFileRemove}
|
||||
{onFileUpload}
|
||||
{onSend}
|
||||
onFilesAdd={handleFilesAdd}
|
||||
{onStop}
|
||||
{onSystemPromptAdd}
|
||||
{showHelperText}
|
||||
bind:uploadedFiles
|
||||
onSubmit={handleSubmit}
|
||||
onSystemPromptClick={handleSystemPromptClick}
|
||||
onUploadedFileRemove={handleUploadedFileRemove}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatFormHelperText show={showHelperText} />
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
AlertTriangle,
|
||||
Code,
|
||||
Monitor,
|
||||
Sun,
|
||||
Moon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Database
|
||||
@@ -23,7 +21,12 @@
|
||||
type SettingsSectionTitle
|
||||
} from '$lib/constants/settings-sections';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { ColorMode } from '$lib/enums/ui';
|
||||
import { SettingsFieldType } from '$lib/enums/settings';
|
||||
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 {
|
||||
onSave?: () => void;
|
||||
@@ -38,240 +41,231 @@
|
||||
title: SettingsSectionTitle;
|
||||
}> = [
|
||||
{
|
||||
title: 'General',
|
||||
title: SETTINGS_SECTION_TITLES.GENERAL,
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
key: 'theme',
|
||||
key: SETTINGS_KEYS.THEME,
|
||||
label: 'Theme',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon }
|
||||
]
|
||||
type: SettingsFieldType.SELECT,
|
||||
options: SETTINGS_COLOR_MODES_CONFIG
|
||||
},
|
||||
{ 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',
|
||||
type: 'textarea'
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
},
|
||||
{
|
||||
key: 'pasteLongTextToFileLen',
|
||||
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
|
||||
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',
|
||||
type: 'checkbox'
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: 'enableContinueGeneration',
|
||||
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
|
||||
label: 'Enable "Continue" button',
|
||||
type: 'checkbox',
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: 'pdfAsImage',
|
||||
key: SETTINGS_KEYS.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',
|
||||
type: 'checkbox'
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
title: SETTINGS_SECTION_TITLES.DISPLAY,
|
||||
icon: Monitor,
|
||||
fields: [
|
||||
{
|
||||
key: 'showMessageStats',
|
||||
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
|
||||
label: 'Show message generation statistics',
|
||||
type: 'checkbox'
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: 'showThoughtInProgress',
|
||||
key: SETTINGS_KEYS.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',
|
||||
type: 'checkbox'
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: 'autoMicOnEmpty',
|
||||
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
|
||||
label: 'Show microphone on empty input',
|
||||
type: 'checkbox',
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: 'renderUserContentAsMarkdown',
|
||||
key: SETTINGS_KEYS.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',
|
||||
type: 'checkbox'
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: 'alwaysShowSidebarOnDesktop',
|
||||
key: SETTINGS_KEYS.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',
|
||||
type: 'checkbox'
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Sampling',
|
||||
title: SETTINGS_SECTION_TITLES.SAMPLING,
|
||||
icon: Funnel,
|
||||
fields: [
|
||||
{
|
||||
key: 'temperature',
|
||||
key: SETTINGS_KEYS.TEMPERATURE,
|
||||
label: 'Temperature',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'dynatemp_range',
|
||||
key: SETTINGS_KEYS.DYNATEMP_RANGE,
|
||||
label: 'Dynamic temperature range',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'dynatemp_exponent',
|
||||
key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
|
||||
label: 'Dynamic temperature exponent',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'top_k',
|
||||
key: SETTINGS_KEYS.TOP_K,
|
||||
label: 'Top K',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'top_p',
|
||||
key: SETTINGS_KEYS.TOP_P,
|
||||
label: 'Top P',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'min_p',
|
||||
key: SETTINGS_KEYS.MIN_P,
|
||||
label: 'Min P',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'xtc_probability',
|
||||
key: SETTINGS_KEYS.XTC_PROBABILITY,
|
||||
label: 'XTC probability',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'xtc_threshold',
|
||||
key: SETTINGS_KEYS.XTC_THRESHOLD,
|
||||
label: 'XTC threshold',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'typ_p',
|
||||
key: SETTINGS_KEYS.TYP_P,
|
||||
label: 'Typical P',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'max_tokens',
|
||||
key: SETTINGS_KEYS.MAX_TOKENS,
|
||||
label: 'Max tokens',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'samplers',
|
||||
key: SETTINGS_KEYS.SAMPLERS,
|
||||
label: 'Samplers',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'backend_sampling',
|
||||
key: SETTINGS_KEYS.BACKEND_SAMPLING,
|
||||
label: 'Backend sampling',
|
||||
type: 'checkbox'
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Penalties',
|
||||
title: SETTINGS_SECTION_TITLES.PENALTIES,
|
||||
icon: AlertTriangle,
|
||||
fields: [
|
||||
{
|
||||
key: 'repeat_last_n',
|
||||
key: SETTINGS_KEYS.REPEAT_LAST_N,
|
||||
label: 'Repeat last N',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'repeat_penalty',
|
||||
key: SETTINGS_KEYS.REPEAT_PENALTY,
|
||||
label: 'Repeat penalty',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'presence_penalty',
|
||||
key: SETTINGS_KEYS.PRESENCE_PENALTY,
|
||||
label: 'Presence penalty',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'frequency_penalty',
|
||||
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
|
||||
label: 'Frequency penalty',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'dry_multiplier',
|
||||
key: SETTINGS_KEYS.DRY_MULTIPLIER,
|
||||
label: 'DRY multiplier',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'dry_base',
|
||||
key: SETTINGS_KEYS.DRY_BASE,
|
||||
label: 'DRY base',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: 'dry_allowed_length',
|
||||
key: SETTINGS_KEYS.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',
|
||||
type: 'input'
|
||||
type: SettingsFieldType.INPUT
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Import/Export',
|
||||
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
|
||||
icon: Database,
|
||||
fields: []
|
||||
},
|
||||
{
|
||||
title: 'Developer',
|
||||
title: SETTINGS_SECTION_TITLES.DEVELOPER,
|
||||
icon: Code,
|
||||
fields: [
|
||||
{
|
||||
key: 'showToolCalls',
|
||||
label: 'Show tool call labels',
|
||||
type: 'checkbox'
|
||||
},
|
||||
{
|
||||
key: 'disableReasoningParsing',
|
||||
key: SETTINGS_KEYS.DISABLE_REASONING_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',
|
||||
type: 'checkbox'
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: 'custom',
|
||||
key: SETTINGS_KEYS.CUSTOM,
|
||||
label: 'Custom JSON',
|
||||
type: 'textarea'
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -303,11 +297,7 @@
|
||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (!initialSection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingSections.some((section) => section.title === initialSection)) {
|
||||
if (initialSection) {
|
||||
activeSection = initialSection;
|
||||
}
|
||||
});
|
||||
@@ -315,7 +305,7 @@
|
||||
function handleThemeChange(newTheme: string) {
|
||||
localConfig.theme = newTheme;
|
||||
|
||||
setMode(newTheme as 'light' | 'dark' | 'system');
|
||||
setMode(newTheme as ColorMode);
|
||||
}
|
||||
|
||||
function handleConfigChange(key: string, value: string | boolean) {
|
||||
@@ -325,7 +315,7 @@
|
||||
function handleReset() {
|
||||
localConfig = { ...config() };
|
||||
|
||||
setMode(localConfig.theme as 'light' | 'dark' | 'system');
|
||||
setMode(localConfig.theme as ColorMode);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
@@ -341,33 +331,16 @@
|
||||
|
||||
// Convert numeric strings to numbers for numeric fields
|
||||
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] !== '') {
|
||||
const numValue = Number(processedConfig[field]);
|
||||
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 {
|
||||
alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
|
||||
return;
|
||||
@@ -506,7 +479,7 @@
|
||||
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
|
||||
</div>
|
||||
|
||||
{#if currentSection.title === 'Import/Export'}
|
||||
{#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
|
||||
<ChatSettingsImportExportTab />
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
|
||||
+8
-6
@@ -6,6 +6,8 @@
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
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 { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
|
||||
import type { Component } from 'svelte';
|
||||
@@ -31,7 +33,7 @@
|
||||
|
||||
{#each fields as field (field.key)}
|
||||
<div class="space-y-2">
|
||||
{#if field.type === 'input'}
|
||||
{#if field.type === SettingsFieldType.INPUT}
|
||||
{@const paramInfo = getParameterSourceInfo(field.key)}
|
||||
{@const currentValue = String(localConfig[field.key] ?? '')}
|
||||
{@const propsDefault = paramInfo?.serverDefault}
|
||||
@@ -98,7 +100,7 @@
|
||||
{@html field.help || SETTING_CONFIG_INFO[field.key]}
|
||||
</p>
|
||||
{/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">
|
||||
{field.label}
|
||||
|
||||
@@ -121,7 +123,7 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if field.key === 'systemMessage'}
|
||||
{#if field.key === SETTINGS_KEYS.SYSTEM_MESSAGE}
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showSystemMessage"
|
||||
@@ -134,7 +136,7 @@
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if field.type === 'select'}
|
||||
{:else if field.type === SettingsFieldType.SELECT}
|
||||
{@const selectedOption = field.options?.find(
|
||||
(opt: { value: string; label: string; icon?: Component }) =>
|
||||
opt.value === localConfig[field.key]
|
||||
@@ -166,7 +168,7 @@
|
||||
type="single"
|
||||
value={currentValue}
|
||||
onValueChange={(value) => {
|
||||
if (field.key === 'theme' && value && onThemeChange) {
|
||||
if (field.key === SETTINGS_KEYS.THEME && value && onThemeChange) {
|
||||
onThemeChange(value);
|
||||
} else {
|
||||
onConfigChange(field.key, value);
|
||||
@@ -222,7 +224,7 @@
|
||||
{field.help || SETTING_CONFIG_INFO[field.key]}
|
||||
</p>
|
||||
{/if}
|
||||
{:else if field.type === 'checkbox'}
|
||||
{:else if field.type === SettingsFieldType.CHECKBOX}
|
||||
<div class="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
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 './badges';
|
||||
export * from './chat';
|
||||
export * from './content';
|
||||
export * from './dialogs';
|
||||
export * from './forms';
|
||||
export * from './misc';
|
||||
export * from './models';
|
||||
export * from './navigation';
|
||||
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;
|
||||
/** When true, user's global selection takes priority over currentModel (for form selector) */
|
||||
useGlobalSelection?: boolean;
|
||||
/** Optional compatibility prop for context-aware selectors. */
|
||||
upToMessageId?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -41,9 +39,7 @@
|
||||
onModelChange,
|
||||
disabled = false,
|
||||
forceForegroundText = false,
|
||||
useGlobalSelection = false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
upToMessageId: _upToMessageId = undefined
|
||||
useGlobalSelection = false
|
||||
}: Props = $props();
|
||||
|
||||
let options = $derived(modelOptions());
|
||||
|
||||
Reference in New Issue
Block a user