Compare commits

..

3 Commits

Author SHA1 Message Date
ddh0
b97ebdc98f llama-quant : fix --tensor-type when default qtype is overriden (#22572)
fix #22544 (my fault!)

Credit to @Anai-Guo, ref #22559 - since that one was closed due to the
new contributor policy I am taking the liberty of re-submitting that PR
here.
2026-05-01 19:55:55 +02:00
Aparna M P
2098fd6169 hexagon: enable non-contiguous row tensor support for unary ops (#22574) 2026-05-01 10:09:23 -07:00
Aleksander Grygier
ab6120cde5 webui: Spring Cleaning Refactor v1 (#22505)
* wip: server_tools

* feat: Integrate with `/tools` endpoint

* feat: Builtin + MCP + JSON Schema Tools WIP

* refactor

* displayName -> display_name

* snake_case everywhere

* rm redundant field

* feat: Improvements

* chore: update webui build output

* refactor: Updates after server updates

* chore: update webui build output

* change arg to --tools all

* feat: UI improvements

* chore: update webui build output

* add readme mention

* llama-gen-docs

* chore: update webui build output

* chore: update webui build output

* chore: update webui build output

* feat: Reorganize settings sections

* feat: Separate dialogs for MCP Servers Settings and Import/Export

* feat: WIP

* feat: WIP

* feat: WIP

* feat: WIP

* feat: WIP

* feat: WIP

* WIP on allozaur/20677-webui-server-tools

* feat: UI improvements

* chore: Update package lock

* chore: Run `npm audit fix`

* feat: UI WIP

* feat: UI

* refactor: Desktop Icon Strip DRY

* feat: Cleaner rendering and transition for ChatScreen

* feat: UI improvements

* feat: UI improvement

* feat: Remove MCP Server "enable" switch from Tools submenu

* chore: Run `npm audit fix`

* feat: WIP

* feat: Logic improvements

* refactor: Cleanup

* refactor: DRY

* test: Fix Chat Sidebar UI Tests

* chore: Update package lock

* refactor: Cleanup

* feat: Chat Message Action Card with Continue and Permission flow implementations

* feat: Add agentic steering messages, draft messages and improve chat UX

* fix: Search results UI

* test: Fix unit test

* feat: UI/UX improvements

* refactor: Simplify `useToolsPanel` access in components

* feat: Implement Processing Info Context API

* feat: Implement 'Go back to chat' functionality for settings

* feat: Enhance MCP Server management in Chat Form Attachments

* style: Minor UI and branding adjustments

* chore: Update webui static build output

* chore: Formatting, linting & type checks

* feat: Draft messages logic

* feat: UI improvements

* feat: Steering Messages improvements

* refactor: Cleanup

* refactor: Cleanup

* feat: Improve UI

* refactor: Settings navigation hook

* refactor: DRY code

* refactor: DRY ChatMessageUser UI components

* refactor: Desktop Icon Strip DRY

* refactor: Tools & permissions

* fix: Navigation condition

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup

* fix: preserve reasoning_content in agentic flow

* refactor: Storybook cleanup

* refactor: isInViewport util function

* refactor: Rename globally `onClick` to `onclick`

* chore: `npm audit fix`

* refactor: Action Icon usage

* refactor: Naming

* refactor: JS in `class` directive

* refactor: Chat components cleanup WIP

* refactor: Components structure

* refactor: Cleanup WIP

* feat: New ChatAttachmentsPreview component

* feat: UI improvements

* feat: UI improvements

* refactor: Cleanup

* refactor: ChatAttachmentsPreview UI/UX

* refactor: Remove dead code

* refactor: Cleanup

* fix: Model Name aliases displaying

* feat: Shortcut improvements

* refactor: Chat Message

* feat: Move Import/Export to settings

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
2026-05-01 18:36:29 +02:00
171 changed files with 7065 additions and 7292 deletions

View File

@@ -2421,8 +2421,8 @@ static bool ggml_hexagon_supported_unary(const struct ggml_hexagon_session * ses
return false;
}
// TODO: add support for non-contigiuos tensors
if (!ggml_is_contiguous(src0) || !ggml_is_contiguous(dst)) {
// TODO: add support for non-contiguous elements within a row
if (!ggml_is_contiguous_rows(src0) || !ggml_is_contiguous_rows(dst)) {
return false;
}

View File

@@ -17,7 +17,7 @@
#define EXP_LOGN2 (0x3F317218) // ln(2) = 0.6931471805
#define EXP_LOG2E (0x3FB8AA3B) // log2(e) = 1/ln(2) = 1.4426950408
#define EXP_ONE (0x3f800000) // 1.0
#define EXP_RANGE_R (0x42B16666) // 88.7
#define EXP_RANGE_R (0x42B17218) // ln(FLT_MAX) approx = 88.7228
#define EXP_RANGE_L (0xC2B00000) // -88.0 (approx log(FLT_MIN))
static inline HVX_Vector hvx_vec_exp_f32(HVX_Vector in_vec) {
@@ -163,7 +163,7 @@ static inline void hvx_exp_f32(uint8_t * restrict dst, const uint8_t * restrict
HVX_Vector vec_out = Q6_V_vzero();
static const float kInf = INFINITY;
static const float kMaxExp = 88.7f;
static const float kMaxExp = 88.7228f;
const HVX_Vector max_exp = hvx_vec_splat_f32(kMaxExp);
const HVX_Vector inf = hvx_vec_splat_f32(kInf);

View File

@@ -26,8 +26,8 @@ struct htp_unary_context {
const uint8_t * data_src0;
uint8_t * data_dst;
size_t src0_row_size;
size_t dst_row_size;
size_t src0_data_row_size; // actual data bytes per row
size_t dst_data_row_size; // actual data bytes per row
size_t src0_row_size_aligned;
size_t dst_row_size_aligned;
@@ -41,6 +41,40 @@ struct htp_unary_context {
uint32_t nc;
};
// Convert flat row index to DDR byte offset using the tensor's actual strides.
// ir = i1 + ne1*(i2 + ne2*i3) => offset = i1*nb1 + i2*nb2 + i3*nb3
static inline size_t unary_row_offset(uint32_t ir,
uint32_t ne1, uint32_t ne2,
size_t nb1, size_t nb2, size_t nb3) {
const uint32_t i1 = ir % ne1;
const uint32_t i2 = (ir / ne1) % ne2;
const uint32_t i3 = ir / (ne1 * ne2);
return i1 * nb1 + i2 * nb2 + i3 * nb3;
}
// Safe DMA block size from row `ir`: clamp to the tighter dim-1 slice
// boundary of src and dst so the nb1 stride stays valid for all rows.
static inline uint32_t unary_block_size(uint32_t ir,
uint32_t end_row,
uint32_t block,
bool src_contig,
bool dst_contig,
uint32_t src_ne1,
uint32_t dst_ne1) {
uint32_t limit = MIN(block, end_row - ir);
if (!src_contig) {
const uint32_t src_slice_end = (ir / src_ne1 + 1) * src_ne1;
limit = MIN(limit, src_slice_end - ir);
}
if (!dst_contig) {
const uint32_t dst_slice_end = (ir / dst_ne1 + 1) * dst_ne1;
limit = MIN(limit, dst_slice_end - ir);
}
return limit;
}
#define htp_unary_preamble \
const uint32_t ne00 = src->ne[0]; \
const uint32_t ne01 = src->ne[1]; \
@@ -276,8 +310,8 @@ static void unary_job_f32_per_thread(unsigned int nth, unsigned int ith, void *
int32_t * op_params = octx->op_params;
uint32_t src0_nrows_per_thread = uctx->src0_nrows_per_thread;
const size_t src0_row_size = uctx->src0_row_size;
const size_t dst_row_size = uctx->dst_row_size;
const size_t src0_data_row_size = uctx->src0_data_row_size;
const size_t dst_data_row_size = uctx->dst_data_row_size;
const size_t src0_row_size_aligned = uctx->src0_row_size_aligned;
const size_t dst_row_size_aligned = uctx->dst_row_size_aligned;
@@ -303,7 +337,16 @@ static void unary_job_f32_per_thread(unsigned int nth, unsigned int ith, void *
size_t src0_spad_half_size = uctx->src0_spad_half_size;
size_t dst_spad_half_size = uctx->dst_spad_half_size;
const int BLOCK = uctx->block;
// Non-contiguous tensors have gaps at dim-2/3 boundaries that a single-stride
// 2D DMA descriptor cannot span. Clamp BLOCK to ne1 (one dim-1 slice) so every
// transfer stays within a nb1-uniform region. Skipped for contiguous tensors.
const bool src0_contig = (nb02 == (size_t)ne01 * nb01) &&
(nb03 == (size_t)ne02 * nb02);
const bool dst_contig = (nb2 == (size_t)ne1 * nb1) &&
(nb3 == (size_t)ne2 * nb2);
const uint32_t src0_max_block = src0_contig ? uctx->block : MIN((uint32_t)uctx->block, ne01);
const uint32_t dst_max_block = dst_contig ? uctx->block : MIN((uint32_t)uctx->block, ne1);
const uint32_t BLOCK = MIN(src0_max_block, dst_max_block);
if (BLOCK == 0) {
FARF(ERROR, "unary-f32 : current VTCM reservation %zu is too small for even 1 row per thread, needed at least %zu\n",
octx->src0_spad.size_per_thread, src0_row_size_aligned);
@@ -312,21 +355,23 @@ static void unary_job_f32_per_thread(unsigned int nth, unsigned int ith, void *
dma_queue * dma_queue = octx->ctx->dma[ith];
for (uint32_t ir = src0_start_row, spad_idx = 0; ir < src0_end_row && spad_idx < 2; ir += BLOCK, spad_idx++) {
const uint32_t block_size = MIN(BLOCK, src0_end_row - ir);
for (uint32_t ir = src0_start_row, spad_idx = 0; ir < src0_end_row && spad_idx < 2; spad_idx++) {
const uint32_t block_size = unary_block_size(ir, src0_end_row, BLOCK, src0_contig, dst_contig, ne01, ne1);
// Dummy DMA transation for sequencing (interleaving dst,src,dst,...)
dma_queue_push_vtcm_to_ddr(dma_queue,
dma_queue_push(dma_queue,
dma_make_ptr(data_dst, dst_spad_data + (spad_idx * dst_spad_half_size)),
dst_row_size, dst_row_size_aligned, 0);
nb1, dst_row_size_aligned, dst_data_row_size, 0);
dma_queue_push_ddr_to_vtcm(dma_queue,
dma_make_ptr(src0_spad_data + (spad_idx * src0_spad_half_size), data_src + (ir * src0_row_size)),
src0_row_size_aligned, src0_row_size, block_size);
const size_t src0_off = unary_row_offset(ir, ne01, ne02, nb01, nb02, nb03);
dma_queue_push(dma_queue,
dma_make_ptr(src0_spad_data + (spad_idx * src0_spad_half_size), data_src + src0_off),
src0_row_size_aligned, nb01, src0_data_row_size, block_size);
ir += block_size;
}
for (uint32_t ir = src0_start_row; ir < src0_end_row; ir += BLOCK) {
const uint32_t block_size = MIN(BLOCK, src0_end_row - ir);
for (uint32_t ir = src0_start_row; ir < src0_end_row; ) {
const uint32_t block_size = unary_block_size(ir, src0_end_row, BLOCK, src0_contig, dst_contig, ne01, ne1);
float * dst_spad = (float *) dma_queue_pop(dma_queue).src;
float * src0_spad = (float *) dma_queue_pop(dma_queue).dst;
@@ -361,18 +406,25 @@ static void unary_job_f32_per_thread(unsigned int nth, unsigned int ith, void *
break;
}
dma_queue_push_vtcm_to_ddr(dma_queue,
dma_make_ptr(data_dst + (ir * dst_row_size), dst_spad),
dst_row_size, dst_row_size_aligned, block_size);
const size_t dst_off = unary_row_offset(ir, ne1, ne2, nb1, nb2, nb3);
dma_queue_push(dma_queue,
dma_make_ptr(data_dst + dst_off, dst_spad),
nb1, dst_row_size_aligned, dst_data_row_size, block_size);
// prefetch N+2 loop iteration if any
const uint32_t pref_block = (ir + BLOCK * 2);
if (pref_block < src0_end_row) {
const uint32_t pref_block_size = MIN(BLOCK, src0_end_row - pref_block);
dma_queue_push_ddr_to_vtcm(dma_queue,
dma_make_ptr(src0_spad, data_src + (pref_block * src0_row_size)),
src0_row_size_aligned, src0_row_size, pref_block_size);
const uint32_t next_ir = ir + block_size;
if (next_ir < src0_end_row) {
const uint32_t next_block_size = unary_block_size(next_ir, src0_end_row, BLOCK, src0_contig, dst_contig, ne01, ne1);
const uint32_t pref_ir = next_ir + next_block_size;
if (pref_ir < src0_end_row) {
const uint32_t pref_block_size = unary_block_size(pref_ir, src0_end_row, BLOCK, src0_contig, dst_contig, ne01, ne1);
const size_t src0_pref_off = unary_row_offset(pref_ir, ne01, ne02, nb01, nb02, nb03);
dma_queue_push(dma_queue,
dma_make_ptr(src0_spad, data_src + src0_pref_off),
src0_row_size_aligned, nb01, src0_data_row_size, pref_block_size);
}
}
ir += block_size;
}
dma_queue_flush(dma_queue);
@@ -426,11 +478,11 @@ static int execute_op_unary_f32(struct htp_ops_context * octx) {
const uint32_t src0_nrows = src0->ne[1] * src0->ne[2] * src0->ne[3];
const uint32_t n_threads = MIN(octx->n_threads, src0_nrows);
const size_t src0_row_size = src0->nb[1];
const size_t dst_row_size = dst->nb[1];
const size_t src0_data_row_size = src0->ne[0] * sizeof(float);
const size_t dst_data_row_size = dst->ne[0] * sizeof(float);
const size_t src0_row_size_aligned = hex_round_up(src0_row_size, VLEN);
const size_t dst_row_size_aligned = hex_round_up(dst_row_size, VLEN);
const size_t src0_row_size_aligned = hex_round_up(src0_data_row_size, VLEN);
const size_t dst_row_size_aligned = hex_round_up(dst_data_row_size, VLEN);
// VTCM scratchpads for all tensors
// N rows per thread, padded to HVX vector size
@@ -468,8 +520,8 @@ static int execute_op_unary_f32(struct htp_ops_context * octx) {
.data_src0 = (const uint8_t *)src0->data,
.data_dst = (uint8_t *)dst->data,
.src0_row_size = src0_row_size,
.dst_row_size = dst_row_size,
.src0_data_row_size = src0_data_row_size,
.dst_data_row_size = dst_data_row_size,
.src0_row_size_aligned = src0_row_size_aligned,
.dst_row_size_aligned = dst_row_size_aligned,

View File

@@ -683,9 +683,9 @@ static ggml_type llama_tensor_get_type(quantize_state_impl & qs, const llama_mod
LLAMA_LOG_WARN("%s: %-36s - applying manual override: %s -> %s\n",
__func__, tensor_name.c_str(), ggml_type_name(new_type), ggml_type_name(qtype));
new_type = qtype;
manual = true;
break;
}
manual = true;
break;
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import * as Tooltip from '../src/lib/components/ui/tooltip';
import * as Tooltip from '../../src/lib/components/ui/tooltip';
interface Props {
children: any;

View File

@@ -1,7 +1,7 @@
import type { Preview } from '@storybook/sveltekit';
import '../src/app.css';
import ModeWatcherDecorator from './ModeWatcherDecorator.svelte';
import TooltipProviderDecorator from './TooltipProviderDecorator.svelte';
import ModeWatcherDecorator from './decorators/ModeWatcherDecorator.svelte';
import TooltipProviderDecorator from './decorators/TooltipProviderDecorator.svelte';
const preview: Preview = {
parameters: {

View File

@@ -3640,9 +3640,9 @@
}
},
"node_modules/bits-ui": {
"version": "2.17.3",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.17.3.tgz",
"integrity": "sha512-Bef41uY9U2jaBJHPhcPvmBNkGec5Wx2z6eioDsTmsaR2vH4QoaOcPi75gzCG3+/2TNr6v/qBwzgWNPYCxNtrEA==",
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.0.tgz",
"integrity": "sha512-GLOBZRVy3hxNHIQ2MpD/+5aK9KcBFZRhUJtZ1UDABXdlVR4K6zFpgt4T+Rwuhf2sQzlc6yK1q/DprHPjwT4Pjw==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -28,7 +28,6 @@ import type {
ApiRouterModelsUnloadResponse,
// Chat types
ChatAttachmentDisplayItem,
ChatAttachmentPreviewItem,
ChatMessageType,
ChatRole,
ChatUploadedFile,
@@ -92,7 +91,6 @@ declare global {
ApiRouterModelsUnloadResponse,
// Chat types
ChatAttachmentDisplayItem,
ChatAttachmentPreviewItem,
ChatMessagePromptProgress,
ChatMessageSiblingInfo,
ChatMessageTimings,

View File

@@ -1,3 +1,5 @@
import { isElementInViewport } from '$lib/utils/viewport';
/**
* Svelte action that fades in an element when it enters the viewport.
* Uses IntersectionObserver for efficient viewport detection.
@@ -12,17 +14,8 @@ export function fadeInView(
) {
const { duration = 300, y = 0, skipIfVisible = false } = options;
if (skipIfVisible) {
const rect = node.getBoundingClientRect();
const isAlreadyVisible =
rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0;
if (isAlreadyVisible) {
return;
}
if (skipIfVisible && isElementInViewport(node)) {
return;
}
node.style.opacity = '0';

View File

@@ -0,0 +1,11 @@
---
name: app
description: Opinionated app components building on top of ./ui primitives
---
- Can include business logic and state management
- Can include data fetching and caching logic
- Should use original spelling for HTML-native events and `camelCase` for custom events
- Props and markup attributes should be listed alphabetically
- Use JS Objects and Arrays for CSS classes and styles when they are dynamic
- Whenever there can be repetition in the component's markup, if it's too small to be decoupled as a separate component — use Svelte 5's `{#snippet}` + `{@render}`

View File

@@ -5,15 +5,16 @@
import { TooltipSide } from '$lib/enums';
interface Props {
icon: Component;
tooltip: string;
variant?: ButtonVariant;
size?: ButtonSize;
iconSize?: string;
ariaLabel?: string;
class?: string;
disabled?: boolean;
icon: Component;
iconSize?: string;
onclick: (e?: MouseEvent) => void;
'aria-label'?: string;
size?: ButtonSize;
stopPropagationOnClick?: boolean;
tooltip: string;
variant?: ButtonVariant;
tooltipSide?: TooltipSide;
}
@@ -26,8 +27,9 @@
disabled = false,
iconSize = 'h-3 w-3',
tooltipSide = TooltipSide.TOP,
stopPropagationOnClick = false,
onclick,
'aria-label': ariaLabel
ariaLabel
}: Props = $props();
</script>
@@ -37,13 +39,18 @@
{variant}
{size}
{disabled}
{onclick}
onclick={(e: MouseEvent) => {
if (stopPropagationOnClick) e.stopPropagation();
onclick?.(e);
}}
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
aria-label={ariaLabel || tooltip}
>
{@const IconComponent = icon}
<IconComponent class={iconSize} />
{#if icon}
{@const IconComponent = icon}
<IconComponent class={iconSize} />
{/if}
</Button>
</Tooltip.Trigger>

View File

@@ -1,18 +1,17 @@
<script lang="ts">
import { Copy } from '@lucide/svelte';
import { copyToClipboard } from '$lib/utils';
import ActionIcon from './ActionIcon.svelte';
interface Props {
ariaLabel?: string;
canCopy?: boolean;
text: string;
}
let { ariaLabel = 'Copy to clipboard', canCopy = true, text }: Props = $props();
export let ariaLabel: string = 'Copy to clipboard';
export let canCopy: boolean = true;
export let text: string;
</script>
<Copy
class="h-3 w-3 flex-shrink-0 cursor-{canCopy ? 'pointer' : 'not-allowed'}"
aria-label={ariaLabel}
<ActionIcon
icon={Copy}
tooltip={ariaLabel}
iconSize="h-4 w-4"
disabled={!canCopy}
onclick={() => canCopy && copyToClipboard(text)}
/>

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
id: string;
onRemove?: (id: string) => void;
class?: string;
iconSize?: number;
}
let { id, onRemove, class: className = '', iconSize = 3 }: Props = $props();
</script>
<Button
type="button"
variant="ghost"
size="icon-sm"
class="bg-white/20 p-0 hover:bg-white/30 {className}"
onclick={(e: MouseEvent) => {
e.stopPropagation();
onRemove?.(id);
}}
aria-label="Remove file"
>
<X class="h-{iconSize} w-{iconSize}" />
</Button>

View File

@@ -1,46 +0,0 @@
<script lang="ts">
import { Eye } from '@lucide/svelte';
import { ActionIconCopyToClipboard } from '$lib/components/app';
import { FileTypeText } from '$lib/enums';
interface Props {
code: string;
language: string;
disabled?: boolean;
onPreview?: (code: string, language: string) => void;
}
let { code, language, disabled = false, onPreview }: Props = $props();
const showPreview = $derived(language?.toLowerCase() === FileTypeText.HTML);
function handlePreview() {
if (disabled) return;
onPreview?.(code, language);
}
</script>
<div class="code-block-actions">
<div class="copy-code-btn" class:opacity-50={disabled} class:!cursor-not-allowed={disabled}>
<ActionIconCopyToClipboard
text={code}
canCopy={!disabled}
ariaLabel={disabled ? 'Code incomplete' : 'Copy code'}
/>
</div>
{#if showPreview}
<button
class="preview-code-btn"
class:opacity-50={disabled}
class:!cursor-not-allowed={disabled}
title={disabled ? 'Code incomplete' : 'Preview code'}
aria-label="Preview code"
aria-disabled={disabled}
type="button"
onclick={handlePreview}
>
<Eye size={16} />
</button>
{/if}
</div>

View File

@@ -9,11 +9,5 @@
/** Styled icon button for action triggers with tooltip. */
export { default as ActionIcon } from './ActionIcon.svelte';
/** Code block actions component (copy, preview). */
export { default as ActionIconsCodeBlock } from './ActionIconsCodeBlock.svelte';
/** Copy-to-clipboard icon button with click handler. */
/** Copy-to-clipboard icon button with clipboard logic. */
export { default as ActionIconCopyToClipboard } from './ActionIconCopyToClipboard.svelte';
/** Remove/delete icon button with X icon. */
export { default as ActionIconRemove } from './ActionIconRemove.svelte';

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import type { Snippet } from 'svelte';
interface Props {
@@ -13,10 +12,10 @@
</script>
<button
class={cn(
class={[
'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75',
className
)}
]}
{onclick}
>
{#if icon}

View File

@@ -1,39 +0,0 @@
<script lang="ts">
import { ModelModality } from '$lib/enums';
import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants';
import { cn } from '$lib/components/ui/utils';
type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO;
interface Props {
modalities: ModelModality[];
class?: string;
}
let { modalities, class: className = '' }: Props = $props();
// Filter to only modalities that have icons (VISION, AUDIO)
const displayableModalities = $derived(
modalities.filter(
(m): m is DisplayableModality => m === ModelModality.VISION || m === ModelModality.AUDIO
)
);
</script>
{#each displayableModalities as modality, index (index)}
{@const IconComponent = MODALITY_ICONS[modality]}
{@const label = MODALITY_LABELS[modality]}
<span
class={cn(
'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
className
)}
>
{#if IconComponent}
<IconComponent class="h-3 w-3" />
{/if}
{label}
</span>
{/each}

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Eye, Mic } from '@lucide/svelte';
import { ModelModality } from '$lib/enums';
interface Props {
modalities: ModelModality[];
class?: string;
}
let { modalities, class: className = '' }: Props = $props();
</script>
{#each modalities as modality (modality)}
{#if modality === ModelModality.VISION || modality === ModelModality.AUDIO}
<span
class={[
'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
className
]}
>
{#if modality === ModelModality.VISION}
<Eye class="h-3 w-3" />
Vision
{:else}
<Mic class="h-3 w-3" />
Audio
{/if}
</span>
{/if}
{/each}

View File

@@ -6,11 +6,8 @@
*
*/
/** Badge displaying chat statistics (tokens, timing). */
export { default as BadgeChatStatistic } from './BadgeChatStatistic.svelte';
/** Generic info badge with optional tooltip and click handler. */
export { default as BadgeInfo } from './BadgeInfo.svelte';
/** Badge indicating model modality (vision, audio, tools). */
export { default as BadgeModality } from './BadgeModality.svelte';
export { default as BadgesModality } from './BadgesModality.svelte';

View File

@@ -1,284 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Alert from '$lib/components/ui/alert';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { FileText, Image, Music, FileIcon, Eye, Info } from '@lucide/svelte';
import {
isTextFile,
isImageFile,
isPdfFile,
isAudioFile,
getLanguageFromFilename,
createBase64DataUrl
} from '$lib/utils';
import { convertPDFToImage } from '$lib/utils/browser-only';
import { modelsStore } from '$lib/stores/models.svelte';
interface Props {
// Either an uploaded file or a stored attachment
uploadedFile?: ChatUploadedFile;
attachment?: DatabaseMessageExtra;
// For uploaded files
preview?: string;
name?: string;
textContent?: string;
// For checking vision modality
activeModelId?: string;
}
let { uploadedFile, attachment, preview, name, textContent, activeModelId }: Props = $props();
let hasVisionModality = $derived(
activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
);
let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
// Determine file type from uploaded file or attachment
let isAudio = $derived(isAudioFile(attachment, uploadedFile));
let isImage = $derived(isImageFile(attachment, uploadedFile));
let isPdf = $derived(isPdfFile(attachment, uploadedFile));
let isText = $derived(isTextFile(attachment, uploadedFile));
let displayPreview = $derived(
uploadedFile?.preview ||
(isImage && attachment && 'base64Url' in attachment ? attachment.base64Url : preview)
);
let displayTextContent = $derived(
uploadedFile?.textContent ||
(attachment && 'content' in attachment ? attachment.content : textContent)
);
let language = $derived(getLanguageFromFilename(displayName));
let IconComponent = $derived(() => {
if (isImage) return Image;
if (isText || isPdf) return FileText;
if (isAudio) return Music;
return FileIcon;
});
let pdfViewMode = $state<'text' | 'pages'>('pages');
let pdfImages = $state<string[]>([]);
let pdfImagesLoading = $state(false);
let pdfImagesError = $state<string | null>(null);
async function loadPdfImages() {
if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
pdfImagesLoading = true;
pdfImagesError = null;
try {
let file: File | null = null;
if (uploadedFile?.file) {
file = uploadedFile.file;
} else if (isPdf && attachment) {
// Check if we have pre-processed images
if (
'images' in attachment &&
attachment.images &&
Array.isArray(attachment.images) &&
attachment.images.length > 0
) {
pdfImages = attachment.images;
return;
}
// Convert base64 back to File for processing
if ('base64Data' in attachment && attachment.base64Data) {
const base64Data = attachment.base64Data;
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
file = new File([byteArray], displayName, { type: 'application/pdf' });
}
}
if (file) {
pdfImages = await convertPDFToImage(file);
} else {
throw new Error('No PDF file available for conversion');
}
} catch (error) {
pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
} finally {
pdfImagesLoading = false;
}
}
export function reset() {
pdfImages = [];
pdfImagesLoading = false;
pdfImagesError = null;
pdfViewMode = 'pages';
}
$effect(() => {
if (isPdf && pdfViewMode === 'pages') {
loadPdfImages();
}
});
</script>
<div class="space-y-4">
<div class="flex items-center justify-end gap-6">
{#if isPdf}
<div class="flex items-center gap-2">
<Button
variant={pdfViewMode === 'text' ? 'default' : 'outline'}
size="sm"
onclick={() => (pdfViewMode = 'text')}
disabled={pdfImagesLoading}
>
<FileText class="mr-1 h-4 w-4" />
Text
</Button>
<Button
variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
size="sm"
onclick={() => {
pdfViewMode = 'pages';
loadPdfImages();
}}
disabled={pdfImagesLoading}
>
{#if pdfImagesLoading}
<div
class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
></div>
{:else}
<Eye class="mr-1 h-4 w-4" />
{/if}
Pages
</Button>
</div>
{/if}
</div>
<div class="flex-1 overflow-auto">
{#if isImage && displayPreview}
<div class="flex items-center justify-center">
<img
src={displayPreview}
alt={displayName}
class="max-h-full rounded-lg object-contain shadow-lg"
/>
</div>
{:else if isPdf && pdfViewMode === 'pages'}
{#if !hasVisionModality && activeModelId}
<Alert.Root class="mb-4">
<Info class="h-4 w-4" />
<Alert.Title>Preview only</Alert.Title>
<Alert.Description>
<span class="inline-flex">
The selected model does not support vision. Only the extracted
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="mx-1 cursor-pointer underline" onclick={() => (pdfViewMode = 'text')}>
text
</span>
will be sent to the model.
</span>
</Alert.Description>
</Alert.Root>
{/if}
{#if pdfImagesLoading}
<div class="flex items-center justify-center p-8">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
<p class="text-muted-foreground">Converting PDF to images...</p>
</div>
</div>
{:else if pdfImagesError}
<div class="flex items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
<p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
<p class="text-sm text-muted-foreground">{pdfImagesError}</p>
<Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
</div>
</div>
{:else if pdfImages.length > 0}
<div class="max-h-[70vh] space-y-4 overflow-auto">
{#each pdfImages as image, index (image)}
<div class="text-center">
<p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
<img
src={image}
alt="PDF Page {index + 1}"
class="mx-auto max-w-full rounded-lg shadow-lg"
/>
</div>
{/each}
</div>
{:else}
<div class="flex items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
<p class="mb-4 text-muted-foreground">No PDF pages available</p>
</div>
</div>
{/if}
{:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
<SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="calc(69rem - 2rem)" />
{:else if isAudio}
<div class="flex items-center justify-center p-8">
<div class="w-full max-w-md text-center">
<Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
{#if uploadedFile?.preview}
<audio controls class="mb-4 w-full" src={uploadedFile.preview}>
Your browser does not support the audio element.
</audio>
{:else if isAudio && attachment && 'mimeType' in attachment && 'base64Data' in attachment}
<audio
controls
class="mb-4 w-full"
src={createBase64DataUrl(attachment.mimeType, attachment.base64Data)}
>
Your browser does not support the audio element.
</audio>
{:else}
<p class="mb-4 text-muted-foreground">Audio preview not available</p>
{/if}
<p class="text-sm text-muted-foreground">
{displayName}
</p>
</div>
</div>
{:else}
<div class="flex items-center justify-center p-8">
<div class="text-center">
{#if IconComponent}
<IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
{/if}
<p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
</div>
</div>
{/if}
</div>
</div>

View File

@@ -1,165 +0,0 @@
<script lang="ts">
import { ActionIconRemove } from '$lib/components/app';
import { formatFileSize, getFileTypeLabel, getPreviewText, isTextFile } from '$lib/utils';
import { AttachmentType } from '$lib/enums';
interface Props {
class?: string;
id: string;
onClick?: (event?: MouseEvent) => void;
onRemove?: (id: string) => void;
name: string;
readonly?: boolean;
size?: number;
textContent?: string;
// Either uploaded file or stored attachment
uploadedFile?: ChatUploadedFile;
attachment?: DatabaseMessageExtra;
}
let {
class: className = '',
id,
onClick,
onRemove,
name,
readonly = false,
size,
textContent,
uploadedFile,
attachment
}: Props = $props();
let isText = $derived(isTextFile(attachment, uploadedFile));
let fileTypeLabel = $derived.by(() => {
if (uploadedFile?.type) {
return getFileTypeLabel(uploadedFile.type);
}
if (attachment) {
if ('mimeType' in attachment && attachment.mimeType) {
return getFileTypeLabel(attachment.mimeType);
}
if (attachment.type) {
return getFileTypeLabel(attachment.type);
}
}
return getFileTypeLabel(name);
});
let pdfProcessingMode = $derived.by(() => {
if (attachment?.type === AttachmentType.PDF) {
const pdfAttachment = attachment as DatabaseMessageExtraPdfFile;
return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text';
}
return null;
});
</script>
{#if isText}
{#if readonly}
<!-- Readonly mode (ChatMessage) -->
<button
class="cursor-pointer rounded-lg border border-border bg-muted p-3 transition-shadow hover:shadow-md {className} w-full max-w-2xl"
onclick={onClick}
aria-label={`Preview ${name}`}
type="button"
>
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col items-start text-left">
<span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
{#if size}
<span class="text-xs text-muted-foreground">{formatFileSize(size)}</span>
{/if}
{#if textContent}
<div class="relative mt-2 w-full">
<div
class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
>
{getPreviewText(textContent)}
</div>
{#if textContent.length > 150}
<div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-6 bg-gradient-to-t from-muted to-transparent"
></div>
{/if}
</div>
{/if}
</div>
</div>
</button>
{:else}
<!-- Non-readonly mode (ChatForm) -->
<button
class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
? 'max-h-24 max-w-72'
: 'max-w-36'} cursor-pointer text-left"
onclick={onClick}
>
<div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
<ActionIconRemove {id} {onRemove} />
</div>
<div class="pr-8">
<span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
{#if textContent}
<div class="relative">
<div
class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
style="max-height: 3rem; line-height: 1.2em;"
>
{getPreviewText(textContent)}
</div>
{#if textContent.length > 150}
<div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent"
></div>
{/if}
</div>
{/if}
</div>
</button>
{/if}
{:else}
<button
class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
onclick={onClick}
>
<div
class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
>
{fileTypeLabel}
</div>
<div class="flex flex-col gap-0.5">
<span
class="max-w-24 truncate text-sm font-medium text-foreground {readonly
? ''
: 'group-hover:pr-6'} md:max-w-32"
>
{name}
</span>
{#if pdfProcessingMode}
<span class="text-left text-xs text-muted-foreground">{pdfProcessingMode}</span>
{:else if size}
<span class="text-left text-xs text-muted-foreground">{formatFileSize(size)}</span>
{/if}
</div>
{#if !readonly}
<div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
<ActionIconRemove {id} {onRemove} />
</div>
{/if}
</button>
{/if}

View File

@@ -1,287 +0,0 @@
<script lang="ts">
import {
ChatAttachmentMcpPrompt,
ChatAttachmentMcpResource,
ChatAttachmentThumbnailImage,
ChatAttachmentThumbnailFile,
HorizontalScrollCarousel,
DialogChatAttachmentPreview,
DialogChatAttachmentsViewAll,
DialogMcpResourcePreview
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { AttachmentType } from '$lib/enums';
import type {
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraMcpResource,
MCPResourceAttachment
} from '$lib/types';
import { getAttachmentDisplayItems } from '$lib/utils';
interface Props {
class?: string;
style?: string;
// For ChatMessage - stored attachments
attachments?: DatabaseMessageExtra[];
readonly?: boolean;
// For ChatForm - pending uploads
onFileRemove?: (fileId: string) => void;
uploadedFiles?: ChatUploadedFile[];
// Image size customization
imageClass?: string;
imageHeight?: string;
imageWidth?: string;
// Limit display to single row with "+ X more" button
limitToSingleRow?: boolean;
// For vision modality check
activeModelId?: string;
}
let {
class: className = '',
style = '',
attachments = [],
readonly = false,
onFileRemove,
uploadedFiles = $bindable([]),
// Default to small size for form previews
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
limitToSingleRow = false,
activeModelId
}: Props = $props();
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
let carouselRef: HorizontalScrollCarousel | undefined = $state();
let isScrollable = $state(false);
let previewDialogOpen = $state(false);
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
let mcpResourcePreviewOpen = $state(false);
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
let viewAllDialogOpen = $state(false);
function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
previewItem = {
uploadedFile: item.uploadedFile,
attachment: item.attachment,
preview: item.preview,
name: item.name,
size: item.size,
textContent: item.textContent
};
previewDialogOpen = true;
}
function openMcpResourcePreview(extra: DatabaseMessageExtraMcpResource) {
mcpResourcePreviewExtra = extra;
mcpResourcePreviewOpen = true;
}
function toMcpResourceAttachment(
extra: DatabaseMessageExtraMcpResource,
id: string
): MCPResourceAttachment {
return {
id,
resource: {
uri: extra.uri,
name: extra.name,
title: extra.name,
serverName: extra.serverName
}
};
}
$effect(() => {
if (carouselRef && displayItems.length) {
carouselRef.resetScroll();
}
});
</script>
{#if displayItems.length > 0}
<div class={className} {style}>
{#if limitToSingleRow}
<HorizontalScrollCarousel
bind:this={carouselRef}
onScrollableChange={(scrollable) => (isScrollable = scrollable)}
>
{#each displayItems as item (item.id)}
{#if item.isMcpPrompt}
{@const mcpPrompt =
item.attachment?.type === AttachmentType.MCP_PROMPT
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
type: AttachmentType.MCP_PROMPT as const,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments
}
: null}
{#if mcpPrompt}
<ChatAttachmentMcpPrompt
class="max-w-[300px] min-w-[200px] flex-shrink-0 {limitToSingleRow
? 'first:ml-4 last:mr-4'
: ''}"
prompt={mcpPrompt}
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
/>
{/if}
{:else if item.isMcpResource && item.attachment?.type === AttachmentType.MCP_RESOURCE}
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
<ChatAttachmentMcpResource
class="flex-shrink-0 {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
attachment={toMcpResourceAttachment(mcpResource, item.id)}
onClick={() => openMcpResourcePreview(mcpResource)}
/>
{:else 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">
<Button
type="button"
variant="ghost"
size="sm"
class="h-6 text-xs text-muted-foreground hover:text-foreground"
onclick={() => (viewAllDialogOpen = true)}
>
View all ({displayItems.length})
</Button>
</div>
{/if}
{:else}
<div class="flex flex-wrap items-start justify-end gap-3">
{#each displayItems as item (item.id)}
{#if item.isMcpPrompt}
{@const mcpPrompt =
item.attachment?.type === AttachmentType.MCP_PROMPT
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
type: AttachmentType.MCP_PROMPT as const,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments
}
: null}
{#if mcpPrompt}
<ChatAttachmentMcpPrompt
class="max-w-[300px] min-w-[200px]"
prompt={mcpPrompt}
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
/>
{/if}
{:else if item.isMcpResource && item.attachment?.type === AttachmentType.MCP_RESOURCE}
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
<ChatAttachmentMcpResource
attachment={toMcpResourceAttachment(mcpResource, item.id)}
onClick={() => openMcpResourcePreview(mcpResource)}
/>
{:else if item.isImage && item.preview}
<ChatAttachmentThumbnailImage
class="cursor-pointer"
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="cursor-pointer"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onClick={(event?: MouseEvent) => openPreview(item, event)}
/>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{#if previewItem}
<DialogChatAttachmentPreview
bind:open={previewDialogOpen}
uploadedFile={previewItem.uploadedFile}
attachment={previewItem.attachment}
preview={previewItem.preview}
name={previewItem.name}
size={previewItem.size}
textContent={previewItem.textContent}
{activeModelId}
/>
{/if}
<DialogChatAttachmentsViewAll
bind:open={viewAllDialogOpen}
{uploadedFiles}
{attachments}
{readonly}
{onFileRemove}
imageHeight="h-64"
{imageClass}
{activeModelId}
/>
{#if mcpResourcePreviewExtra}
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
{/if}

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import {
ChatAttachmentsListItem,
DialogChatAttachmentsPreview,
DialogMcpResourcePreview,
HorizontalScrollCarousel
} from '$lib/components/app';
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
import { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from '$lib/utils';
interface Props {
class?: string;
style?: string;
// For ChatMessage - stored attachments
attachments?: DatabaseMessageExtra[];
readonly?: boolean;
// For ChatForm - pending uploads
onFileRemove?: (fileId: string) => void;
uploadedFiles?: ChatUploadedFile[];
// Image size customization
imageClass?: string;
imageHeight?: string;
imageWidth?: string;
// Limit display to single row with "+ X more" button
limitToSingleRow?: boolean;
// For vision modality check
activeModelId?: string;
}
let {
class: className = '',
style = '',
attachments = [],
readonly = false,
onFileRemove,
uploadedFiles = $bindable([]),
// Default to small size for form previews
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
limitToSingleRow = false,
activeModelId
}: Props = $props();
let carouselRef: HorizontalScrollCarousel | undefined = $state();
let mcpResourcePreviewOpen = $state(false);
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
let previewFocusIndex = $state(0);
let viewAllDialogOpen = $state(false);
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
// Find the index of the clicked item among non-MCP attachments
const nonMcpItems = displayItems.filter((i) => !isMcpPrompt(i) && !isMcpResource(i));
const index = nonMcpItems.findIndex((i) => i.id === item.id);
previewFocusIndex = index >= 0 ? index : 0;
viewAllDialogOpen = true;
}
function openMcpResourcePreview(extra: DatabaseMessageExtraMcpResource) {
mcpResourcePreviewExtra = extra;
mcpResourcePreviewOpen = true;
}
$effect(() => {
if (carouselRef && displayItems.length) {
carouselRef.resetScroll();
}
});
</script>
{#snippet attachmentitem(item: ChatAttachmentDisplayItem)}
<ChatAttachmentsListItem
{imageClass}
{imageHeight}
{imageWidth}
{item}
{limitToSingleRow}
{onFileRemove}
onMcpResourcePreview={openMcpResourcePreview}
onPreview={(i: ChatAttachmentDisplayItem, event?: MouseEvent) => openPreview(i, event)}
{readonly}
/>
{/snippet}
{#if displayItems.length > 0}
<div class={className} {style}>
{#if limitToSingleRow}
<HorizontalScrollCarousel bind:this={carouselRef}>
{#each displayItems as item (item.id)}
{@render attachmentitem(item)}
{/each}
</HorizontalScrollCarousel>
{:else}
<div class="flex flex-wrap items-start justify-end gap-3">
{#each displayItems as item (item.id)}
{@render attachmentitem(item)}
{/each}
</div>
{/if}
</div>
{/if}
<DialogChatAttachmentsPreview
{activeModelId}
{attachments}
bind:open={viewAllDialogOpen}
{previewFocusIndex}
{uploadedFiles}
/>
{#if mcpResourcePreviewExtra}
<DialogMcpResourcePreview extra={mcpResourcePreviewExtra} bind:open={mcpResourcePreviewOpen} />
{/if}

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import {
ChatAttachmentsListItemMcpPrompt,
ChatAttachmentsListItemMcpResource,
ChatAttachmentsListItemThumbnailImage,
ChatAttachmentsListItemThumbnailFile
} from '$lib/components/app';
import { AttachmentType } from '$lib/enums';
import type {
ChatAttachmentDisplayItem,
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraMcpResource,
MCPResourceAttachment
} from '$lib/types';
import { isMcpPrompt, isMcpResource, isPdfFile } from '$lib/utils';
interface Props {
class?: string;
imageClass?: string;
imageHeight?: string;
imageWidth?: string;
item: ChatAttachmentDisplayItem;
limitToSingleRow?: boolean;
onFileRemove?: (fileId: string) => void;
onMcpResourcePreview?: (extra: DatabaseMessageExtraMcpResource) => void;
onPreview?: (item: ChatAttachmentDisplayItem) => void;
readonly?: boolean;
}
let {
class: className = '',
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
item,
limitToSingleRow = false,
onFileRemove,
onMcpResourcePreview,
onPreview,
readonly = false
}: Props = $props();
const scrollClasses = $derived(limitToSingleRow ? 'first:ml-4 last:mr-4' : '');
function toMcpResourceAttachment(
extra: DatabaseMessageExtraMcpResource,
id: string
): MCPResourceAttachment {
return {
id,
resource: {
uri: extra.uri,
name: extra.name,
title: extra.name,
serverName: extra.serverName
}
};
}
</script>
{#if isMcpPrompt(item)}
{@const mcpPrompt =
item.attachment?.type === AttachmentType.MCP_PROMPT
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
type: AttachmentType.MCP_PROMPT as const,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments
}
: null}
{#if mcpPrompt}
<ChatAttachmentsListItemMcpPrompt
class="max-w-[300px] min-w-[200px] flex-shrink-0 {className} {scrollClasses}"
prompt={mcpPrompt}
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
/>
{/if}
{:else if isMcpResource(item)}
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
<ChatAttachmentsListItemMcpResource
class="flex-shrink-0 {className} {scrollClasses}"
attachment={toMcpResourceAttachment(mcpResource, item.id)}
onclick={() => onMcpResourcePreview?.(mcpResource)}
/>
{:else if item.isImage && item.preview}
<ChatAttachmentsListItemThumbnailImage
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
preview={item.preview}
{readonly}
onRemove={onFileRemove}
height={imageHeight}
width={imageWidth}
{imageClass}
onclick={() => onPreview?.(item)}
/>
{:else if isPdfFile(item.attachment, item.uploadedFile)}
<ChatAttachmentsListItemThumbnailFile
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onclick={() => onPreview?.(item)}
/>
{:else}
<ChatAttachmentsListItemThumbnailFile
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onclick={() => onPreview?.(item)}
/>
{/if}

View File

@@ -1,40 +1,41 @@
<script lang="ts">
import { ChatMessageMcpPromptContent, ActionIconRemove } from '$lib/components/app';
import { ChatMessageMcpPromptContent, ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { McpPromptVariant } from '$lib/enums';
interface Props {
class?: string;
prompt: DatabaseMessageExtraMcpPrompt;
readonly?: boolean;
isLoading?: boolean;
loadError?: string;
onRemove?: () => void;
prompt: DatabaseMessageExtraMcpPrompt;
readonly?: boolean;
}
let {
class: className = '',
prompt,
readonly = false,
isLoading = false,
loadError,
onRemove
onRemove,
prompt,
readonly = false
}: Props = $props();
</script>
<div class="group relative {className}">
<ChatMessageMcpPromptContent
{prompt}
variant={McpPromptVariant.ATTACHMENT}
{isLoading}
{loadError}
{prompt}
variant={McpPromptVariant.ATTACHMENT}
/>
{#if !readonly && onRemove}
<div
class="absolute top-10 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
>
<ActionIconRemove id={prompt.name} onRemove={() => onRemove?.()} />
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.()} />
</div>
{/if}
</div>

View File

@@ -1,46 +1,47 @@
<script lang="ts">
import { Loader2, AlertCircle } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceAttachment } from '$lib/types';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIconRemove } from '$lib/components/app';
import { ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
import { getResourceIcon, getResourceDisplayName } from '$lib/utils';
interface Props {
attachment: MCPResourceAttachment;
onRemove?: (attachmentId: string) => void;
onClick?: () => void;
class?: string;
onclick?: () => void;
onRemove?: (attachmentId: string) => void;
}
let { attachment, onRemove, onClick, class: className }: Props = $props();
function getStatusClass(attachment: MCPResourceAttachment): string {
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
if (attachment.loading) return 'border-border/50 bg-muted/30';
return 'border-border/50 bg-muted/30';
}
let { attachment, class: className, onclick, onRemove }: Props = $props();
const ResourceIcon = $derived(
getResourceIcon(attachment.resource.mimeType, attachment.resource.uri)
);
const serverName = $derived(mcpStore.getServerDisplayName(attachment.resource.serverName));
const favicon = $derived(mcpStore.getServerFavicon(attachment.resource.serverName));
function getStatusClass(attachment: MCPResourceAttachment): string {
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
if (attachment.loading) return 'border-border/50 bg-muted/30';
return 'border-border/50 bg-muted/30';
}
</script>
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class={cn(
class={[
'flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.75 text-sm transition-colors',
getStatusClass(attachment),
onClick && 'cursor-pointer hover:bg-muted/50',
onclick && 'cursor-pointer hover:bg-muted/50',
className
)}
onclick={onClick}
disabled={!onClick}
]}
disabled={!onclick}
{onclick}
type="button"
>
{#if attachment.loading}
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
@@ -55,11 +56,13 @@
</span>
{#if onRemove}
<ActionIconRemove
<ActionIcon
class="-my-2 -mr-1.5 bg-transparent"
iconSize={2}
id={attachment.id}
{onRemove}
icon={X}
iconSize="h-2 w-2"
onclick={() => onRemove?.(attachment.id)}
stopPropagationOnClick
tooltip="Remove"
/>
{/if}
</button>
@@ -69,12 +72,12 @@
<div class="flex items-center gap-1 text-xs">
{#if favicon}
<img
src={favicon}
alt=""
alt={attachment.resource.serverName}
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import {
formatFileSize,
getFileTypeLabel,
getPreviewText,
isPdfFile,
isTextFile
} from '$lib/utils';
import { ActionIcon } from '$lib/components/app';
import { AttachmentType } from '$lib/enums';
interface Props {
attachment?: DatabaseMessageExtra;
class?: string;
id: string;
onclick?: (event: MouseEvent) => void;
onRemove?: (id: string) => void;
name: string;
readonly?: boolean;
size?: number;
textContent?: string;
// Either uploaded file or stored attachment
uploadedFile?: ChatUploadedFile;
}
let {
attachment,
class: className = '',
id,
onclick,
onRemove,
name,
readonly = false,
size,
textContent,
uploadedFile
}: Props = $props();
let isPdf = $derived(isPdfFile(attachment, uploadedFile));
let isPdfWithContent = $derived(isPdf && !!textContent);
let isText = $derived(isTextFile(attachment, uploadedFile));
let isTextWithContent = $derived(isText && !!textContent);
let fileTypeLabel = $derived.by(() => {
if (uploadedFile?.type) {
return getFileTypeLabel(uploadedFile.type);
}
if (attachment) {
if ('mimeType' in attachment && attachment.mimeType) {
return getFileTypeLabel(attachment.mimeType);
}
if (attachment.type) {
return getFileTypeLabel(attachment.type);
}
}
return getFileTypeLabel(name);
});
let pdfProcessingMode = $derived.by(() => {
if (attachment?.type === AttachmentType.PDF) {
const pdfAttachment = attachment as DatabaseMessageExtraPdfFile;
return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text';
}
return null;
});
</script>
{#snippet textPreview(content: string)}
<div class="relative">
<div
class="font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground {!readonly
? 'max-h-3rem line-height-1.2'
: ''}"
>
{getPreviewText(content)}
</div>
{#if content.length > 150}
<div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent {readonly
? 'h-6'
: ''}"
></div>
{/if}
</div>
{/snippet}
{#snippet removeButton()}
<div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.(id)} />
</div>
{/snippet}
{#snippet fileIcon()}
<div
class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
>
{fileTypeLabel}
</div>
{/snippet}
{#snippet info(text: string | undefined)}
{#if text}
<span class="text-xs text-muted-foreground">{text}</span>
{/if}
{/snippet}
{#if isTextWithContent || isPdfWithContent}
<button
aria-label={readonly ? `Preview ${name}` : undefined}
class="rounded-lg border border-border bg-muted p-3 {className} cursor-pointer {readonly
? 'w-full max-w-2xl transition-shadow hover:shadow-md'
: `group relative text-left ${textContent ? 'max-h-24 max-w-72' : 'max-w-36'}`} overflow-hidden"
{onclick}
type="button"
>
{#if !readonly}
{@render removeButton()}
{/if}
<div class={[!readonly && 'pr-8', 'overflow-hidden']}>
{#if readonly}
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col items-start text-left">
<span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
{@render info(pdfProcessingMode || (size ? formatFileSize(size) : undefined))}
{#if textContent}
{@render textPreview(textContent)}
{/if}
</div>
</div>
{:else}
<span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
{#if textContent}
{@render textPreview(textContent)}
{/if}
{/if}
</div>
</button>
{:else}
<button
class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
{onclick}
type="button"
>
{@render fileIcon()}
<div class="flex flex-col items-start gap-0.5">
<span
class="max-w-24 truncate text-sm font-medium text-foreground {readonly
? ''
: 'group-hover:pr-6'} md:max-w-32"
>
{name}
</span>
{@render info(pdfProcessingMode || (size ? formatFileSize(size) : undefined))}
</div>
{#if !readonly}
{@render removeButton()}
{/if}
</button>
{/if}

View File

@@ -1,64 +1,65 @@
<script lang="ts">
import { ActionIconRemove } from '$lib/components/app';
import { ActionIcon } from '$lib/components/app';
import { X } from '@lucide/svelte';
interface Props {
class?: string;
height?: string;
id: string;
imageClass?: string;
onclick?: (event?: MouseEvent) => void;
onRemove?: (id: string) => void;
name: string;
preview: string;
readonly?: boolean;
onRemove?: (id: string) => void;
onClick?: (event?: MouseEvent) => void;
class?: string;
// Customizable size props
width?: string;
height?: string;
imageClass?: string;
}
let {
class: className = '',
height = 'h-16',
id,
imageClass = '',
onclick,
onRemove,
name,
preview,
readonly = false,
onRemove,
onClick,
class: className = '',
// Default to small size for form previews
width = 'w-auto',
height = 'h-16',
imageClass = ''
width = 'w-auto'
}: Props = $props();
</script>
{#snippet image()}
<img src={preview} alt={name} class="{height} {width} cursor-pointer object-cover {imageClass}" />
{/snippet}
<div
class="group relative overflow-hidden rounded-lg bg-muted shadow-lg dark:border dark:border-muted {className}"
>
{#if onClick}
{#if onclick}
<button
type="button"
class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
onclick={onClick}
aria-label="Preview {name}"
class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
{onclick}
type="button"
>
<img
src={preview}
alt={name}
class="{height} {width} cursor-pointer object-cover {imageClass}"
/>
{@render image()}
</button>
{:else}
<img
src={preview}
alt={name}
class="{height} {width} cursor-pointer object-cover {imageClass}"
/>
{@render image()}
{/if}
{#if !readonly}
<div
class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
>
<ActionIconRemove {id} {onRemove} class="text-white" />
<ActionIcon
class="text-white"
icon={X}
onclick={() => onRemove?.(id)}
stopPropagationOnClick
tooltip="Remove"
/>
</div>
{/if}
</div>

View File

@@ -0,0 +1,190 @@
<script lang="ts">
import {
ChatAttachmentsPreviewCurrentItem,
ChatAttachmentsPreviewFileInfo,
ChatAttachmentsPreviewNavButtons,
ChatAttachmentsPreviewThumbnailStrip
} from '$lib/components/app';
import { modelsStore } from '$lib/stores/models.svelte';
import {
createBase64DataUrl,
formatFileSize,
getAttachmentDisplayItems,
getLanguageFromFilename,
isAudioFile,
isImageFile,
isMcpPrompt,
isMcpResource,
isPdfFile,
isTextFile
} from '$lib/utils';
interface PreviewItem {
id: string;
name: string;
size?: number;
preview?: string;
uploadedFile?: ChatUploadedFile;
attachment?: DatabaseMessageExtra;
textContent?: string;
isImage: boolean;
isAudio: boolean;
}
interface Props {
uploadedFiles?: ChatUploadedFile[];
attachments?: DatabaseMessageExtra[];
activeModelId?: string;
class?: string;
previewFocusIndex?: number;
}
let {
uploadedFiles = [],
attachments = [],
activeModelId,
class: className = '',
previewFocusIndex = 0
}: Props = $props();
let allItems = $derived(
getAttachmentDisplayItems({ uploadedFiles, attachments })
.filter((item) => !isMcpPrompt(item) && !isMcpResource(item))
.map(
(item): PreviewItem => ({
...item,
isImage: isImageFile(item.attachment, item.uploadedFile),
isAudio: isAudioFile(item.attachment, item.uploadedFile)
})
)
);
let currentIndex = $state(0);
$effect(() => {
if (previewFocusIndex >= 0 && previewFocusIndex < allItems.length) {
currentIndex = previewFocusIndex;
}
});
$effect(() => {
const handler = (e: Event) => {
const delta = (e as CustomEvent).detail;
if (delta < 0) {
currentIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1;
} else {
currentIndex = currentIndex < allItems.length - 1 ? currentIndex + 1 : 0;
}
};
document.addEventListener('chat-attachments-nav', handler);
return () => document.removeEventListener('chat-attachments-nav', handler);
});
$effect(() => {
const index = currentIndex;
setTimeout(() => {
const thumbnail = document.querySelector(`[data-thumbnail-index="${index}"]`);
thumbnail?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}, 0);
});
let currentItem = $derived(allItems[currentIndex] ?? null);
let displayName = $derived(
currentItem?.name ||
currentItem?.uploadedFile?.name ||
currentItem?.attachment?.name ||
'Unknown File'
);
let isAudio = $derived(
currentItem ? isAudioFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isImage = $derived(
currentItem ? isImageFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isPdf = $derived(
currentItem ? isPdfFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isText = $derived(
currentItem ? isTextFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let displayPreview = $derived(
currentItem?.uploadedFile?.preview ||
(isImage && currentItem?.attachment && 'base64Url' in currentItem.attachment
? currentItem.attachment.base64Url
: currentItem?.preview)
);
let displayTextContent = $derived(
currentItem?.uploadedFile?.textContent ||
(currentItem?.attachment && 'content' in currentItem.attachment
? currentItem.attachment.content
: currentItem?.textContent)
);
let language = $derived(getLanguageFromFilename(displayName));
let fileSize = $derived(currentItem?.size ? formatFileSize(currentItem.size) : '');
let hasVisionModality = $derived(
currentItem && activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false
);
let audioSrc = $derived(
isAudio && currentItem
? (currentItem.uploadedFile?.preview ??
(currentItem.attachment &&
'mimeType' in currentItem.attachment &&
'base64Data' in currentItem.attachment
? createBase64DataUrl(
currentItem.attachment.mimeType,
currentItem.attachment.base64Data
)
: null))
: null
);
export function prev() {
currentIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1;
}
export function next() {
currentIndex = currentIndex < allItems.length - 1 ? currentIndex + 1 : 0;
}
function onNavigate(index: number) {
currentIndex = index;
}
</script>
<div class="{className} flex flex-col text-white">
<div class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden">
<ChatAttachmentsPreviewNavButtons onPrev={prev} onNext={next} show={allItems.length > 1} />
<div class="flex h-full w-full flex-col items-center justify-start overflow-auto py-4">
{#if currentItem}
<ChatAttachmentsPreviewFileInfo {displayName} {fileSize} />
<ChatAttachmentsPreviewCurrentItem
{currentItem}
{isImage}
{isAudio}
{isPdf}
{isText}
{displayPreview}
{displayTextContent}
{audioSrc}
{language}
{hasVisionModality}
{activeModelId}
/>
{/if}
<ChatAttachmentsPreviewThumbnailStrip items={allItems} {currentIndex} {onNavigate} />
</div>
</div>
</div>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { Image, Music, FileText, FileIcon } from '@lucide/svelte';
import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
import ChatAttachmentsPreviewCurrentItemAudio from './ChatAttachmentsPreviewCurrentItemAudio.svelte';
import ChatAttachmentsPreviewCurrentItemText from './ChatAttachmentsPreviewCurrentItemText.svelte';
import ChatAttachmentsPreviewCurrentItemUnavailable from './ChatAttachmentsPreviewCurrentItemUnavailable.svelte';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
isImage: boolean;
isAudio: boolean;
isPdf: boolean;
isText: boolean;
displayPreview: string | undefined;
displayTextContent: string | undefined;
audioSrc: string | null;
language: string;
hasVisionModality: boolean;
activeModelId?: string;
}
let {
currentItem,
isImage,
isAudio,
isPdf,
isText,
displayPreview,
displayTextContent,
audioSrc,
language,
hasVisionModality,
activeModelId
}: Props = $props();
let IconComponent = $derived(
isImage ? Image : isText || isPdf ? FileText : isAudio ? Music : FileIcon
);
let isUnavailable = $derived(!isPdf && !isImage && !(isText && displayTextContent) && !isAudio);
</script>
{#if currentItem}
{#key currentItem.id}
{#if isPdf}
<ChatAttachmentsPreviewCurrentItemPdf
{currentItem}
displayName={currentItem.name}
{displayTextContent}
{hasVisionModality}
{activeModelId}
/>
{:else if isImage}
<ChatAttachmentsPreviewCurrentItemImage {currentItem} {displayPreview} />
{:else if isText && displayTextContent}
<ChatAttachmentsPreviewCurrentItemText {displayTextContent} {language} />
{:else if isAudio}
<ChatAttachmentsPreviewCurrentItemAudio {currentItem} {audioSrc} />
{:else if isUnavailable}
<ChatAttachmentsPreviewCurrentItemUnavailable {IconComponent} />
{/if}
{/key}
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Music } from '@lucide/svelte';
interface Props {
currentItem: { name?: string } | null;
audioSrc: string | null;
}
let { currentItem, audioSrc }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
<div class="w-full max-w-md text-center">
<Music class="mx-auto mb-4 h-16 w-16 text-white/50" />
{#if audioSrc}
<audio controls class="mb-4 w-full" src={audioSrc}>
Your browser does not support the audio element.
</audio>
{:else}
<p class="mb-4 text-white/70">Audio preview not available</p>
{/if}
<p class="text-sm text-white/50">{currentItem?.name || 'Audio'}</p>
</div>
</div>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
interface Props {
currentItem: { name?: string } | null;
displayPreview: string | undefined;
}
let { currentItem, displayPreview }: Props = $props();
</script>
{#if displayPreview}
<div class="flex flex-1 items-center justify-center">
<img
src={displayPreview}
alt={currentItem?.name || 'preview'}
class="max-h-[80vh] max-w-[80vw] rounded-lg object-contain shadow-lg"
/>
</div>
{/if}

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { FileText, Eye, Info } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Alert from '$lib/components/ui/alert';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { getLanguageFromFilename } from '$lib/utils';
import { convertPDFToImage } from '$lib/utils/browser-only';
import { PdfViewMode } from '$lib/enums';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
displayName: string;
displayTextContent: string | undefined;
hasVisionModality: boolean;
activeModelId?: string;
}
let { currentItem, displayName, displayTextContent, hasVisionModality, activeModelId }: Props =
$props();
let pdfViewMode = $state<PdfViewMode>(PdfViewMode.PAGES);
let pdfImages = $state<string[]>([]);
let pdfImagesLoading = $state(false);
let pdfImagesError = $state<string | null>(null);
let language = $derived(getLanguageFromFilename(displayName));
async function loadPdfImages() {
if (pdfImages.length > 0 || pdfImagesLoading || !currentItem) return;
pdfImagesLoading = true;
pdfImagesError = null;
try {
let file: File | null = null;
if (currentItem.uploadedFile?.file) {
file = currentItem.uploadedFile.file;
} else if (currentItem.attachment) {
// Check if we have pre-processed images
if (
'images' in currentItem.attachment &&
currentItem.attachment.images &&
Array.isArray(currentItem.attachment.images) &&
currentItem.attachment.images.length > 0
) {
pdfImages = currentItem.attachment.images;
return;
}
// Convert base64 back to File for processing
if ('base64Data' in currentItem.attachment && currentItem.attachment.base64Data) {
const base64Data = currentItem.attachment.base64Data;
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
file = new File([byteArray], displayName, { type: 'application/pdf' });
}
}
if (file) {
pdfImages = await convertPDFToImage(file);
} else {
throw new Error('No PDF file available for conversion');
}
} catch (error) {
pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
} finally {
pdfImagesLoading = false;
}
}
$effect(() => {
if (pdfViewMode === PdfViewMode.PAGES) {
loadPdfImages();
}
});
</script>
<div class="mb-4 flex items-center justify-end gap-2">
<Button
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
size="sm"
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
disabled={pdfImagesLoading}
>
<FileText class="mr-1 h-4 w-4" />
Text
</Button>
<Button
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
size="sm"
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
disabled={pdfImagesLoading}
>
{#if pdfImagesLoading}
<div
class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
></div>
{:else}
<Eye class="mr-1 h-4 w-4" />
{/if}
Pages
</Button>
</div>
{#if !hasVisionModality && activeModelId && currentItem}
<Alert.Root class="mb-4 max-w-4xl">
<Info class="h-4 w-4" />
<Alert.Title>Preview only</Alert.Title>
<Alert.Description>
<span class="inline-flex">
The selected model does not support vision. Only the extracted
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="mx-1 cursor-pointer underline"
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
>
text
</span>
will be sent to the model.
</span>
</Alert.Description>
</Alert.Root>
{/if}
{#if pdfImagesLoading}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-white border-t-transparent"
></div>
<p class="text-white/70">Converting PDF to images...</p>
</div>
</div>
{:else if pdfImagesError}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="mb-4 text-white/70">Failed to load PDF images</p>
<p class="text-sm text-white/50">{pdfImagesError}</p>
</div>
</div>
{:else if pdfImages.length > 0}
{#each pdfImages as image, index (image)}
<p class="mb-2 text-sm text-white/50">Page {index + 1}</p>
<img src={image} alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" />
<div class="h-4"></div>
{/each}
{:else}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="text-white/70">No PDF pages available</p>
</div>
</div>
{/if}
{#if pdfViewMode === PdfViewMode.TEXT && displayTextContent}
<div class="px-4 pb-4">
<SyntaxHighlightedCode
class="max-w-4xl"
code={displayTextContent}
{language}
maxHeight="none"
/>
</div>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { SyntaxHighlightedCode } from '$lib/components/app';
interface Props {
displayTextContent: string | undefined;
language: string;
}
let { displayTextContent, language }: Props = $props();
</script>
{#if displayTextContent}
<div class="px-4 pb-4">
<SyntaxHighlightedCode
class="max-w-4xl"
code={displayTextContent}
{language}
maxHeight="none"
/>
</div>
{/if}

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Props {
IconComponent: Component;
}
let { IconComponent }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<IconComponent class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="text-white/70">Preview not available for this file type</p>
</div>
</div>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
interface Props {
displayName: string;
fileSize: string;
}
let { displayName, fileSize }: Props = $props();
</script>
<div class="sticky top-0 z-[20] mb-4 rounded-lg bg-black/5 px-4 py-2 text-center backdrop-blur-md">
<p class="font-medium text-white">{displayName}</p>
{#if fileSize}
<p class="text-xs text-white/60">{fileSize}</p>
{/if}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
onPrev: () => void;
onNext: () => void;
show: boolean;
}
let { onPrev, onNext, show }: Props = $props();
</script>
{#if show}
<Button
variant="secondary"
size="icon"
class="absolute top-1/2 left-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
onclick={onPrev}
aria-label="Previous"
>
<ChevronLeft class="size-4" />
</Button>
<Button
variant="secondary"
size="icon"
class="absolute top-1/2 right-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
onclick={onNext}
aria-label="Next"
>
<ChevronRight class="size-4" />
</Button>
{/if}

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { Music, FileText } from '@lucide/svelte';
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
interface PreviewItem {
id: string;
name: string;
isImage: boolean;
isAudio: boolean;
preview?: string;
}
interface Props {
items: PreviewItem[];
currentIndex: number;
onNavigate: (index: number) => void;
}
let { items, currentIndex, onNavigate }: Props = $props();
function getFileExtension(name: string): string {
const parts = name.split('.');
if (parts.length > 1) {
return parts.pop()?.toUpperCase() ?? '';
}
return '';
}
</script>
{#if items.length > 1}
<div class="sticky bottom-0 z-10 mt-4 flex-shrink-0">
<HorizontalScrollCarousel class="max-w-full">
{#each items as item, index (item.id)}
<button
data-thumbnail-index={index}
class={[
'relative flex-shrink-0 cursor-pointer overflow-hidden rounded border-2 bg-black/80 backdrop-blur-sm transition-all hover:opacity-90',
index === currentIndex ? 'border-white' : 'border-transparent opacity-60',
'[&:not(:first-child)]:last:mr-4 [&:not(:last-child)]:first:ml-4'
]}
onclick={() => onNavigate(index)}
aria-label={`Go to ${item.name}`}
>
{#if item.isImage && item.preview}
<img src={item.preview} alt={item.name} class="h-12 w-12 object-cover" />
{:else}
<div
class="bg-foreground-muted/50 flex h-12 w-12 flex-col items-center justify-center gap-0.5 py-1"
>
{#if item.isAudio}
<Music class="h-4 w-4 text-white/70" />
{:else}
<FileText class="h-4 w-4 text-white/70" />
{/if}
<span class="font-mono text-[9px] text-white/60">{getFileExtension(item.name)}</span>
</div>
{/if}
</button>
{/each}
</HorizontalScrollCarousel>
</div>
{/if}

View File

@@ -1,117 +0,0 @@
<script lang="ts">
import {
ChatAttachmentThumbnailImage,
ChatAttachmentThumbnailFile,
DialogChatAttachmentPreview
} from '$lib/components/app';
import { getAttachmentDisplayItems } from '$lib/utils';
interface Props {
uploadedFiles?: ChatUploadedFile[];
attachments?: DatabaseMessageExtra[];
readonly?: boolean;
onFileRemove?: (fileId: string) => void;
imageHeight?: string;
imageWidth?: string;
imageClass?: string;
activeModelId?: string;
}
let {
uploadedFiles = [],
attachments = [],
readonly = false,
onFileRemove,
imageHeight = 'h-24',
imageWidth = 'w-auto',
imageClass = '',
activeModelId
}: Props = $props();
let previewDialogOpen = $state(false);
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
let imageItems = $derived(displayItems.filter((item) => item.isImage));
let fileItems = $derived(displayItems.filter((item) => !item.isImage));
function openPreview(item: (typeof displayItems)[0], event?: Event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
previewItem = {
uploadedFile: item.uploadedFile,
attachment: item.attachment,
preview: item.preview,
name: item.name,
size: item.size,
textContent: item.textContent
};
previewDialogOpen = true;
}
</script>
<div class="space-y-4">
<div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
{#if fileItems.length > 0}
<div>
<h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
<div class="flex flex-wrap items-start gap-3">
{#each fileItems as item (item.id)}
<ChatAttachmentThumbnailFile
class="cursor-pointer"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onClick={(event?: MouseEvent) => openPreview(item, event)}
/>
{/each}
</div>
</div>
{/if}
{#if imageItems.length > 0}
<div>
<h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
<div class="flex flex-wrap items-start gap-3">
{#each imageItems as item (item.id)}
{#if item.preview}
<ChatAttachmentThumbnailImage
class="cursor-pointer"
id={item.id}
name={item.name}
preview={item.preview}
{readonly}
onRemove={onFileRemove}
height={imageHeight}
width={imageWidth}
{imageClass}
onClick={(event) => openPreview(item, event)}
/>
{/if}
{/each}
</div>
</div>
{/if}
</div>
</div>
{#if previewItem}
<DialogChatAttachmentPreview
bind:open={previewDialogOpen}
uploadedFile={previewItem.uploadedFile}
attachment={previewItem.attachment}
preview={previewItem.preview}
name={previewItem.name}
size={previewItem.size}
textContent={previewItem.textContent}
{activeModelId}
/>
{/if}

View File

@@ -1,14 +1,13 @@
<script lang="ts">
import {
ChatAttachmentsList,
ChatAttachmentMcpResources,
ChatFormActions,
ChatFormFileInputInvisible,
ChatFormPromptPicker,
ChatFormResourcePicker,
ChatFormTextarea
ChatFormMcpResourcesList,
ChatFormPickers,
ChatFormTextarea,
DialogMcpResourcesBrowser
} from '$lib/components/app';
import { DialogMcpResources } from '$lib/components/app/dialogs';
import {
CLIPBOARD_CONTENT_QUOTE_PREFIX,
INPUT_CLASSES,
@@ -54,6 +53,8 @@
isLoading?: boolean;
placeholder?: string;
showMcpPromptButton?: boolean;
showAddButton?: boolean;
showModelSelector?: boolean;
// Event Handlers
onAttachmentRemove?: (index: number) => void;
@@ -73,6 +74,8 @@
isLoading = false,
placeholder = 'Type a message...',
showMcpPromptButton = false,
showAddButton = true,
showModelSelector = true,
uploadedFiles = $bindable([]),
value = $bindable(''),
onAttachmentRemove,
@@ -85,31 +88,21 @@
onValueChange
}: Props = $props();
/**
*
*
* STATE
*
*
*/
// Component References
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let promptPickerRef: ChatFormPromptPicker | undefined = $state(undefined);
let resourcePickerRef: ChatFormResourcePicker | undefined = $state(undefined);
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
$state(undefined);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
// Audio Recording State
let isRecording = $state(false);
let recordingSupported = $state(false);
// Prompt Picker State
// Picker State
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
// Inline Resource Picker State (triggered by @)
let isInlineResourcePickerOpen = $state(false);
let resourceSearchQuery = $state('');
@@ -117,22 +110,12 @@
let isResourceDialogOpen = $state(false);
let preSelectedResourceUri = $state<string | undefined>(undefined);
/**
*
*
* 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;
});
// Model Selection Logic
let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
@@ -158,7 +141,6 @@
return null;
});
// Form Validation State
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let hasAttachments = $derived(
@@ -166,27 +148,11 @@
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
/**
*
*
* LIFECYCLE
*
*
*/
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
/**
*
*
* PUBLIC API
*
*
*/
export function focus() {
textareaRef?.focus();
}
@@ -199,10 +165,6 @@
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) {
chatFormActionsRef?.openModelSelector();
@@ -211,14 +173,6 @@
return true;
}
/**
*
*
* EVENT HANDLERS - File Management
*
*
*/
function handleFileSelect(files: File[]) {
onFilesAdd?.(files);
}
@@ -238,14 +192,6 @@
}
}
/**
*
*
* EVENT HANDLERS - Input & Keyboard
*
*
*/
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
@@ -273,11 +219,7 @@
}
function handleKeydown(event: KeyboardEvent) {
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
return;
}
if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
if (pickersRef?.handleKeydown(event)) {
return;
}
@@ -388,14 +330,6 @@
}
}
/**
*
*
* EVENT HANDLERS - Prompt Picker
*
*
*/
function handlePromptLoadStart(
placeholderId: string,
promptInfo: MCPPromptInfo,
@@ -474,14 +408,6 @@
textareaRef?.focus();
}
/**
*
*
* EVENT HANDLERS - Inline Resource Picker
*
*
*/
function handleInlineResourcePickerClose() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
@@ -489,7 +415,6 @@
}
function handleInlineResourceSelect() {
// Clear the @query from input after resource is attached
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
@@ -512,14 +437,6 @@
isResourceDialogOpen = true;
}
/**
*
*
* EVENT HANDLERS - Audio Recording
*
*
*/
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
@@ -552,29 +469,27 @@
<form
class="relative {className}"
onsubmit={(e) => {
e.preventDefault();
onsubmit={(event) => {
event.preventDefault();
if (!canSubmit || disabled || hasLoadingAttachments) return;
onSubmit?.();
}}
>
<ChatFormPromptPicker
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
searchQuery={promptSearchQuery}
onClose={handlePromptPickerClose}
<ChatFormPickers
bind:this={pickersRef}
{isPromptPickerOpen}
{promptSearchQuery}
{isInlineResourcePickerOpen}
{resourceSearchQuery}
onPromptPickerClose={handlePromptPickerClose}
onInlineResourcePickerClose={handleInlineResourcePickerClose}
onInlineResourceSelect={handleInlineResourceSelect}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
/>
<ChatFormResourcePicker
bind:this={resourcePickerRef}
isOpen={isInlineResourcePickerOpen}
searchQuery={resourceSearchQuery}
onClose={handleInlineResourcePickerClose}
onResourceSelect={handleInlineResourceSelect}
onBrowse={handleBrowseResources}
onInlineResourceBrowse={handleBrowseResources}
/>
<div
@@ -611,7 +526,7 @@
/>
{#if mcpHasResourceAttachments()}
<ChatAttachmentMcpResources
<ChatFormMcpResourcesList
class="mb-3"
onResourceClick={(uri) => {
preSelectedResourceUri = uri;
@@ -624,10 +539,11 @@
class="px-3"
bind:this={chatFormActionsRef}
canSend={canSubmit}
hasText={value.trim().length > 0}
{disabled}
{isLoading}
{isRecording}
{showAddButton}
{showModelSelector}
{uploadedFiles}
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
@@ -640,7 +556,7 @@
</div>
</form>
<DialogMcpResources
<DialogMcpResourcesBrowser
bind:open={isResourceDialogOpen}
preSelectedUri={preSelectedResourceUri}
onAttach={(resource: MCPResourceInfo) => {

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
interface Props {
disabled?: boolean;
onclick?: (e: MouseEvent) => void;
}
let { disabled = false, onclick }: Props = $props();
</script>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<Button
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
{onclick}
variant="secondary"
type="button"
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
<Plus class="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{ATTACHMENT_TOOLTIP_TEXT}</p>
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -1,17 +1,18 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import type { Snippet } from 'svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_EXTRA_ITEMS,
ATTACHMENT_MCP_ITEMS,
ATTACHMENT_TOOLTIP_TEXT,
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
import { AttachmentMenuItemId } from '$lib/enums';
import { ChatFormActionToolsSubmenu, ChatFormActionMcpServersSubmenu } from '$lib/components/app';
import {
ChatFormActionAddToolsSubmenu,
ChatFormActionAddMcpServersSubmenu
} from '$lib/components/app';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
@@ -27,6 +28,7 @@
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
trigger: Snippet<[{ disabled: boolean }]>;
}
let {
@@ -40,7 +42,8 @@
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
onMcpResourcesClick,
trigger
}: Props = $props();
let dropdownOpen = $state(false);
@@ -62,24 +65,7 @@
<div class="flex items-center gap-1 {className}">
<DropdownMenu.Root bind:open={dropdownOpen}>
<DropdownMenu.Trigger name="Attach files" {disabled}>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<Button
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
variant="secondary"
type="button"
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
<Plus class="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{ATTACHMENT_TOOLTIP_TEXT}</p>
</Tooltip.Content>
</Tooltip.Root>
{@render trigger({ disabled })}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-48">
@@ -161,9 +147,9 @@
{/if}
{/each}
<ChatFormActionToolsSubmenu />
<ChatFormActionAddToolsSubmenu />
<ChatFormActionMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
{#each ATTACHMENT_MCP_ITEMS as item (item.id)}
{#if attachmentMenu.isItemVisible(item.visibleWhen)}

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import { Settings, Plus } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
import { goto } from '$app/navigation';
interface Props {
onMcpSettingsClick?: () => void;
}
let { onMcpSettingsClick }: Props = $props();
let mcpSearchQuery = $state('');
let allMcpServers = $derived(mcpStore.getServersSorted());
let mcpServers = $derived(allMcpServers.filter((s) => s.enabled));
let hasMcpServers = $derived(mcpServers.length > 0);
// let hasAnyMcpServers = $derived(allMcpServers.length > 0);
let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers;
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
});
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
function handleMcpSubMenuOpen(open: boolean) {
if (open) {
mcpSearchQuery = '';
mcpStore.runHealthChecksForServers(allMcpServers);
}
}
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
goto(`${hasMcpServers ? '' : '?add'}#/settings/mcp`);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class="h-4 w-4" />
<span>MCP Servers</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 pt-0">
{#if hasMcpServers}
<DropdownMenuSearchable
placeholder="Search servers..."
bind:searchValue={mcpSearchQuery}
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if mcpStore.getServerFavicon(server.id)}
<img
src={mcpStore.getServerFavicon(server.id)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate text-sm">{getServerLabel(server)}</span>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</button>
{/each}
</div>
{#snippet footer()}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
{:else}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No MCP servers configured
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Plus class="h-4 w-4" />
<span>Add MCP Servers</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Root>

View File

@@ -1,18 +1,17 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import type { Snippet } from 'svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Sheet from '$lib/components/ui/sheet';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_EXTRA_ITEMS,
ATTACHMENT_MCP_ITEMS,
ATTACHMENT_TOOLTIP_TEXT
ATTACHMENT_MCP_ITEMS
} from '$lib/constants/attachment-menu';
import { ChatFormActionToolsSubmenu, ChatFormActionMcpServersSubmenu } from '$lib/components/app';
import { McpLogo } from '$lib/components/app';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { AttachmentMenuItemId } from '$lib/enums';
import { PencilRuler } from '@lucide/svelte';
interface Props {
class?: string;
@@ -24,8 +23,8 @@
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
trigger: Snippet<[{ disabled: boolean; onclick?: () => void }]>;
}
let {
@@ -38,8 +37,8 @@
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
onMcpResourcesClick,
trigger
}: Props = $props();
let sheetOpen = $state(false);
@@ -52,28 +51,14 @@
}
);
function handleMcpSettingsClick() {
sheetOpen = false;
onMcpSettingsClick?.();
}
const sheetItemClass =
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
</script>
<div class="flex items-center gap-1 {className}">
<Sheet.Root bind:open={sheetOpen}>
<Button
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
variant="secondary"
type="button"
onclick={() => (sheetOpen = true)}
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
<Plus class="h-4 w-4" />
</Button>
{@render trigger({ disabled, onclick: () => (sheetOpen = true) })}
<!-- <ChatFormActionAddButton {disabled} onclick={() => (sheetOpen = true)} /> -->
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
<Sheet.Header>
@@ -161,9 +146,17 @@
<div class="my-2 border-t"></div>
<ChatFormActionToolsSubmenu />
<a href="#/settings/mcp" class="flex items-center gap-3 px-3 py-2">
<McpLogo class="inline h-4 w-4" />
<ChatFormActionMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
<span class="text-sm">MCP Servers</span>
</a>
<a href="#/settings/chat/tools" class="flex items-center gap-3 px-3 py-2">
<PencilRuler class="inline h-4 w-4" />
<span class="text-sm">Tools</span>
</a>
{#each ATTACHMENT_MCP_ITEMS as item (item.id)}
{#if attachmentMenu.isItemVisible(item.visibleWhen)}

View File

@@ -24,6 +24,7 @@
{#if toolsStore.loading}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
<Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
Loading tools...
</div>
{:else if toolsStore.isToolsEndpointUnreachable}
@@ -31,19 +32,21 @@
<span class="flex gap-2">
<Info class="mt-0.5 h-4 w-4 shrink-0" />
<span
>Run llama-server with <code>--tools</code> flag to enable
<strong>Built-in Tools</strong>.</span
>
<span>
Run llama-server with <code>--tools</code> flag to enable
<strong>Built-in Tools</strong>.
</span>
</span>
<span class="flex gap-2">
<Info class="mt-0.5 h-4 w-4 shrink-0" />
<span
>{hasMcpServersAvailable ? 'Enable' : 'Add'} MCP Server(s) to access
<strong>MCP Tools</strong>.</span
>
<span>
{hasMcpServersAvailable ? 'Enable' : 'Add'} MCP Server(s) to access
<strong>MCP Tools</strong>.
</span>
</span>
</div>
{:else if toolsStore.error}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
interface Props {
disabled?: boolean;
hasAudioModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
hasVisionModality?: boolean;
onFileUpload?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
onSystemPromptClick?: () => void;
}
let {
disabled = false,
hasAudioModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
hasVisionModality = false,
onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick,
onSystemPromptClick
}: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<ChatFormActionAddSheet
{disabled}
{hasAudioModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onMcpPromptClick}
{onMcpResourcesClick}
>
{#snippet trigger({ disabled, onclick })}
<ChatFormActionAddButton {disabled} {onclick} />
{/snippet}
</ChatFormActionAddSheet>
{:else}
<ChatFormActionAddDropdown
{disabled}
{hasAudioModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onMcpPromptClick}
{onMcpResourcesClick}
{onMcpSettingsClick}
{onSystemPromptClick}
>
{#snippet trigger()}
<ChatFormActionAddButton {disabled} />
{/snippet}
</ChatFormActionAddDropdown>
{/if}

View File

@@ -1,147 +0,0 @@
<script lang="ts">
import { Settings, Plus } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
import { goto } from '$app/navigation';
interface Props {
onMcpSettingsClick?: () => void;
}
let { onMcpSettingsClick }: Props = $props();
let mcpSearchQuery = $state('');
let allMcpServers = $derived(mcpStore.getServersSorted());
let mcpServers = $derived(allMcpServers.filter((s) => s.enabled));
let hasMcpServers = $derived(mcpServers.length > 0);
// let hasAnyMcpServers = $derived(allMcpServers.length > 0);
let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers;
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
});
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
function handleMcpSubMenuOpen(open: boolean) {
if (open) {
mcpSearchQuery = '';
mcpStore.runHealthChecksForServers(allMcpServers);
}
}
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
goto(`${hasMcpServers ? '' : '?add'}#/settings/mcp`);
}
</script>
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class="h-4 w-4" />
<span>MCP Servers</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 pt-0">
{#if hasMcpServers}
<DropdownMenuSearchable
placeholder="Search servers..."
bind:searchValue={mcpSearchQuery}
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if mcpStore.getServerFavicon(server.id)}
<img
src={mcpStore.getServerFavicon(server.id)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate text-sm">{getServerLabel(server)}</span>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</button>
{/each}
</div>
{#snippet footer()}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
{:else}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No MCP servers configured
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Plus class="h-4 w-4" />
<span>Add MCP Servers</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { chatStore } from '$lib/stores/chat.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
interface Props {
currentModel?: string;
disabled?: boolean;
forceForegroundText?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
hasModelSelected?: boolean;
isSelectedModelInCache?: boolean;
submitTooltip?: string;
useGlobalSelection?: boolean;
}
let {
currentModel,
disabled = false,
forceForegroundText = false,
hasAudioModality = $bindable(false),
hasVisionModality = $bindable(false),
hasModelSelected = $bindable(false),
isSelectedModelInCache = $bindable(true),
submitTooltip = $bindable(''),
useGlobalSelection = false
}: Props = $props();
let isRouter = $derived(isRouterMode());
let isOffline = $derived(!!serverError());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let lastSyncedConversationModel: string | null = null;
$effect(() => {
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
lastSyncedConversationModel = conversationModel;
modelsStore.selectModelByName(conversationModel);
} else if (isRouter && !modelsStore.selectedModelId && modelsStore.loadedModelIds.length > 0) {
lastSyncedConversationModel = null;
// auto-select the first loaded model only when nothing is selected yet
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
if (first) modelsStore.selectModelById(first.id);
}
});
let activeModelId = $derived.by(() => {
const options = modelOptions();
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
}
});
$effect(() => {
hasAudioModality = activeModelId ? modelsStore.modelSupportsAudio(activeModelId) : false;
});
$effect(() => {
void modelPropsVersion;
hasVisionModality = activeModelId ? modelsStore.modelSupportsVision(activeModelId) : false;
});
$effect(() => {
hasModelSelected = !isRouter || !!conversationModel || !!selectedModelId();
});
$effect(() => {
if (!isRouter) {
isSelectedModelInCache = true;
} else if (conversationModel) {
isSelectedModelInCache = modelOptions().some((option) => option.model === conversationModel);
} else {
const currentModelId = selectedModelId();
if (!currentModelId) {
isSelectedModelInCache = false;
} else {
isSelectedModelInCache = modelOptions().some((option) => option.id === currentModelId);
}
}
});
$effect(() => {
if (!hasModelSelected) {
submitTooltip = 'Please select a model first';
} else if (!isSelectedModelInCache) {
submitTooltip = 'Selected model is not available, please select another';
} else {
submitTooltip = '';
}
});
let selectorModelRef: ModelsSelectorDropdown | ModelsSelectorSheet | undefined =
$state(undefined);
let isMobile = new IsMobile();
export function open() {
selectorModelRef?.open();
}
</script>
{#if isMobile.current}
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
{currentModel}
{forceForegroundText}
{useGlobalSelection}
/>
{:else}
<ModelsSelectorDropdown
disabled={disabled || isOffline}
bind:this={selectorModelRef}
{currentModel}
{forceForegroundText}
{useGlobalSelection}
/>
{/if}

View File

@@ -2,7 +2,6 @@
import { ArrowUp } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { cn } from '$lib/components/ui/utils';
interface Props {
canSend?: boolean;
@@ -20,12 +19,11 @@
<Button
type="submit"
disabled={isDisabled}
class={cn(
class={[
'h-8 w-8 rounded-full p-0',
showErrorState
? 'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
: ''
)}
showErrorState &&
'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
]}
{...props}
>
<span class="sr-only">Send</span>

View File

@@ -2,31 +2,27 @@
import { Square } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import {
ChatFormActionAttachmentsDropdown,
ChatFormActionAttachmentsSheet,
ChatFormActionsAdd,
ChatFormActionModels,
ChatFormActionRecord,
ChatFormActionSubmit,
ModelsSelectorDropdown,
ModelsSelectorSheet
ChatFormActionSubmit
} from '$lib/components/app';
import { FileTypeCategory } from '$lib/enums';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { config } from '$lib/stores/settings.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { getFileTypeCategory } from '$lib/utils';
import { goto } from '$app/navigation';
interface Props {
canSend?: boolean;
canSubmit?: boolean;
class?: string;
disabled?: boolean;
isLoading?: boolean;
isRecording?: boolean;
hasText?: boolean;
showAddButton?: boolean;
showModelSelector?: boolean;
uploadedFiles?: ChatUploadedFile[];
onFileUpload?: () => void;
onMicClick?: () => void;
@@ -38,11 +34,13 @@
let {
canSend = false,
canSubmit = false,
class: className = '',
disabled = false,
isLoading = false,
isRecording = false,
hasText = false,
showAddButton = true,
showModelSelector = true,
uploadedFiles = [],
onFileUpload,
onMicClick,
@@ -53,124 +51,6 @@
}: Props = $props();
let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode());
let isOffline = $derived(!!serverError());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let lastSyncedConversationModel: string | null = null;
$effect(() => {
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
lastSyncedConversationModel = conversationModel;
modelsStore.selectModelByName(conversationModel);
} else if (isRouter && !modelsStore.selectedModelId && modelsStore.loadedModelIds.length > 0) {
lastSyncedConversationModel = null;
// auto-select the first loaded model only when nothing is selected yet
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
if (first) modelsStore.selectModelById(first.id);
}
});
let activeModelId = $derived.by(() => {
const options = modelOptions();
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
}
});
let hasAudioModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsAudio(activeModelId);
}
return false;
});
let hasVisionModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVision(activeModelId);
}
return false;
});
let hasAudioAttachments = $derived(
uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
);
let shouldShowRecordButton = $derived(
hasAudioModality && !hasText && !hasAudioAttachments && currentConfig.autoMicOnEmpty
);
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
let isSelectedModelInCache = $derived.by(() => {
if (!isRouter) return true;
if (conversationModel) {
return modelOptions().some((option) => option.model === conversationModel);
}
const currentModelId = selectedModelId();
if (!currentModelId) return false;
return modelOptions().some((option) => option.id === currentModelId);
});
let submitTooltip = $derived.by(() => {
if (!hasModelSelected) {
return 'Please select a model first';
}
if (!isSelectedModelInCache) {
return 'Selected model is not available, please select another';
}
return '';
});
let selectorModelRef: ModelsSelectorDropdown | ModelsSelectorSheet | undefined =
$state(undefined);
let isMobile = new IsMobile();
export function openModelSelector() {
selectorModelRef?.open();
}
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
@@ -183,25 +63,34 @@
return mcpStore.hasResourcesCapability(perChatOverrides);
});
let hasAudioModality = $state(false);
let hasVisionModality = $state(false);
let hasModelSelected = $state(false);
let isSelectedModelInCache = $state(true);
let submitTooltip = $state('');
let hasAudioAttachments = $derived(
uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
);
let shouldShowRecordButton = $derived(
hasAudioModality && !canSubmit && !hasAudioAttachments && currentConfig.autoMicOnEmpty
);
let selectorModelRef: ChatFormActionModels | undefined = $state(undefined);
export function openModelSelector() {
selectorModelRef?.open();
}
</script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
<div class="mr-auto flex items-center gap-2">
{#if isMobile.current}
<ChatFormActionAttachmentsSheet
{disabled}
{hasAudioModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onSystemPromptClick}
{onMcpPromptClick}
onMcpSettingsClick={() => goto('#/settings/mcp')}
{onMcpResourcesClick}
/>
{:else}
<ChatFormActionAttachmentsDropdown
<div
class="flex w-full items-center gap-3 {className} {showAddButton ? '' : 'justify-end'}"
style="container-type: inline-size"
>
{#if showAddButton}
<div class="mr-auto flex items-center gap-2">
<ChatFormActionsAdd
{disabled}
{hasAudioModality}
{hasVisionModality}
@@ -213,30 +102,24 @@
{onMcpResourcesClick}
onMcpSettingsClick={() => goto('#/settings/mcp')}
/>
{/if}
</div>
</div>
{/if}
<div class="ml-auto flex items-center gap-2">
{#if isMobile.current}
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={conversationModel}
forceForegroundText
useGlobalSelection
/>
{:else}
<ModelsSelectorDropdown
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={conversationModel}
forceForegroundText
useGlobalSelection
/>
{/if}
</div>
{#if showModelSelector}
<ChatFormActionModels
{disabled}
bind:this={selectorModelRef}
bind:hasAudioModality
bind:hasVisionModality
bind:hasModelSelected
bind:isSelectedModelInCache
bind:submitTooltip
forceForegroundText
useGlobalSelection
/>
{/if}
{#if isLoading && !hasText}
{#if isLoading && !canSubmit}
<Button
type="button"
variant="secondary"
@@ -253,10 +136,10 @@
<ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
{:else}
<ChatFormActionSubmit
canSend={canSend && hasModelSelected && isSelectedModelInCache}
canSend={canSend && (showModelSelector ? hasModelSelected && isSelectedModelInCache : true)}
{disabled}
tooltipLabel={submitTooltip}
showErrorState={hasModelSelected && !isSelectedModelInCache}
showErrorState={showModelSelector && hasModelSelected && !isSelectedModelInCache}
/>
{/if}
</div>

View File

@@ -15,6 +15,7 @@
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files) {
onFileSelect?.(Array.from(input.files));
}

View File

@@ -1,31 +0,0 @@
<script lang="ts">
import { browser } from '$app/environment';
import { config } from '$lib/stores/settings.svelte';
interface Props {
class?: string;
show?: boolean;
}
let { class: className = '', show = true }: Props = $props();
let sendOnEnter = $derived(config().sendOnEnter !== false);
let modKey = browser && /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? 'Cmd' : 'Ctrl';
</script>
{#if show}
<div class="mt-6 items-center justify-center {className} hidden md:flex">
{#if sendOnEnter}
<p class="text-xs text-muted-foreground">
Press <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Enter</kbd> to send,
<kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Shift + Enter</kbd> for new line
</p>
{:else}
<p class="text-xs text-muted-foreground">
Press <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">{modKey} + Enter</kbd> to
send,
<kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Enter</kbd> for new line
</p>
{/if}
</div>
{/if}

View File

@@ -4,7 +4,10 @@
mcpResourceAttachments,
mcpHasResourceAttachments
} from '$lib/stores/mcp-resources.svelte';
import { ChatAttachmentMcpResource, HorizontalScrollCarousel } from '$lib/components/app';
import {
ChatAttachmentsListItemMcpResource,
HorizontalScrollCarousel
} from '$lib/components/app';
interface Props {
class?: string;
@@ -29,11 +32,11 @@
<div class={className}>
<HorizontalScrollCarousel gapSize="2">
{#each attachments as attachment, i (attachment.id)}
<ChatAttachmentMcpResource
<ChatAttachmentsListItemMcpResource
class={i === 0 ? 'ml-3' : ''}
{attachment}
onRemove={handleRemove}
onClick={() => handleResourceClick(attachment.resource.uri)}
onclick={() => handleResourceClick(attachment.resource.uri)}
/>
{/each}
</HorizontalScrollCarousel>

View File

@@ -60,8 +60,7 @@
<div
bind:this={listContainer}
class="{CHAT_FORM_POPOVER_MAX_HEIGHT} p-2"
class:pt-13={showSearchInput}
class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, showSearchInput && 'pt-13']}
>
{#if isLoading}
{#if skeleton}

View File

@@ -3,18 +3,18 @@
interface Props {
isSelected?: boolean;
onClick: () => void;
onclick: () => void;
dataIndex?: number;
children: Snippet;
}
let { isSelected = false, onClick, dataIndex, children }: Props = $props();
let { isSelected = false, onclick, dataIndex, children }: Props = $props();
</script>
<button
type="button"
data-picker-index={dataIndex}
onclick={onClick}
{onclick}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
? 'bg-accent/50'
: ''}"

View File

@@ -39,7 +39,7 @@
sideOffset={12}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
onkeydown={onKeydown}
onOpenAutoFocus={(e) => e.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
>
{@render children()}
</Popover.Content>

View File

@@ -5,7 +5,6 @@
import { KeyboardKey } from '$lib/enums';
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import Badge from '$lib/components/ui/badge/badge.svelte';
import {
ChatFormPickerPopover,
ChatFormPickerList,
@@ -14,6 +13,7 @@
ChatFormPickerListItemSkeleton,
ChatFormPromptPickerArgumentForm
} from '$lib/components/app/chat';
import Badge from '$lib/components/ui/badge/badge.svelte';
interface Props {
class?: string;
@@ -97,7 +97,7 @@
prompts = await mcpStore.getAllPrompts();
} catch (error) {
console.error('[ChatFormPromptPicker] Failed to load prompts:', error);
console.error('[ChatFormPickerMcpPrompts] Failed to load prompts:', error);
prompts = [];
} finally {
isLoading = false;
@@ -163,7 +163,7 @@
}
if (import.meta.env.DEV) {
console.log('[ChatFormPromptPicker] Fetching completions for:', {
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
serverName: selectedPrompt.serverName,
promptName: selectedPrompt.name,
argName,
@@ -182,7 +182,7 @@
);
if (import.meta.env.DEV) {
console.log('[ChatFormPromptPicker] Autocomplete result:', {
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
argName,
value,
result,
@@ -205,7 +205,7 @@
suggestions[argName] = [];
}
} catch (error) {
console.error('[ChatFormPromptPicker] Failed to fetch completions:', error);
console.error('[ChatFormPickerMcpPrompts] Failed to fetch completions:', error);
suggestions[argName] = [];
} finally {
loadingSuggestions[argName] = false;
@@ -408,7 +408,7 @@
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onClick={() => handlePromptClick(prompt)}
onclick={() => handlePromptClick(prompt)}
>
<ChatFormPickerItemHeader
{server}

View File

@@ -67,7 +67,6 @@
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
@@ -79,7 +78,7 @@
await mcpStore.fetchAllResources();
resources = mcpResourceStore.getAllResourceInfos();
} catch (error) {
console.error('[ChatFormResourcePicker] Failed to load resources:', error);
console.error('[ChatFormPickerMcpResources] Failed to load resources:', error);
resources = [];
} finally {
isLoading = false;
@@ -88,6 +87,7 @@
function handleResourceClick(resource: MCPResourceInfo) {
mcpStore.attachResource(resource.uri);
onResourceSelect?.(resource);
onClose?.();
}
@@ -144,6 +144,7 @@
const sortedResources = [...resources].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
@@ -186,7 +187,7 @@
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onClick={() => handleResourceClick(resource)}
onclick={() => handleResourceClick(resource)}
>
<ChatFormPickerItemHeader
{server}

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
import ChatFormPickerMcpResources from './ChatFormPickerMcpResources.svelte';
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
interface Props {
isPromptPickerOpen?: boolean;
promptSearchQuery?: string;
isInlineResourcePickerOpen?: boolean;
resourceSearchQuery?: string;
onPromptPickerClose?: () => void;
onInlineResourcePickerClose?: () => void;
onInlineResourceSelect?: () => void;
onPromptLoadStart?: (
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) => void;
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
onPromptLoadError?: (placeholderId: string, error: string) => void;
onInlineResourceBrowse?: () => void;
}
let {
isPromptPickerOpen,
promptSearchQuery,
isInlineResourcePickerOpen,
resourceSearchQuery,
onPromptPickerClose,
onInlineResourcePickerClose,
onInlineResourceSelect,
onPromptLoadStart,
onPromptLoadComplete,
onPromptLoadError,
onInlineResourceBrowse
}: Props = $props();
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
let resourcePickerRef: ChatFormPickerMcpResources | undefined = $state(undefined);
/**
* Delegates keyboard events to the active picker child.
* Returns true if the event was handled.
*/
export function handleKeydown(event: KeyboardEvent): boolean {
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
return true;
}
if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
return true;
}
return false;
}
</script>
<ChatFormPickerMcpPrompts
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
searchQuery={promptSearchQuery}
onClose={onPromptPickerClose}
{onPromptLoadStart}
{onPromptLoadComplete}
{onPromptLoadError}
/>
<ChatFormPickerMcpResources
bind:this={resourcePickerRef}
isOpen={isInlineResourcePickerOpen}
searchQuery={resourceSearchQuery}
onClose={onInlineResourcePickerClose}
onResourceSelect={onInlineResourceSelect}
onBrowse={onInlineResourceBrowse}
/>

View File

@@ -51,8 +51,10 @@
<textarea
bind:this={textareaElement}
bind:value
class="text-md min-h-12 w-full resize-none border-0 bg-transparent p-0 leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0"
class:cursor-not-allowed={disabled}
class={[
'text-md min-h-12 w-full resize-none border-0 bg-transparent p-0 leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0',
disabled && 'cursor-not-allowed'
]}
style="max-height: var(--max-message-height);"
{disabled}
onkeydown={onKeydown}

View File

@@ -5,7 +5,8 @@
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services/database.service';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { MessageRole, AttachmentType } from '$lib/enums';
import { REASONING_TAGS } from '$lib/constants/agentic';
import { MessageRole, AttachmentType, AgenticSectionType } from '$lib/enums';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import {
ChatMessageAssistant,
@@ -14,6 +15,7 @@
ChatMessageMcpPrompt
} from '$lib/components/app/chat';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { deriveAgenticSections } from '$lib/utils';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
@@ -41,6 +43,50 @@
messageTypes: string[];
} | null>(null);
let editedContent = $derived(message.content);
let rawEditContent = $derived.by(() => {
if (message.role !== MessageRole.ASSISTANT) return undefined;
const sections = deriveAgenticSections(message, toolMessages, [], false);
const parts: string[] = [];
for (const section of sections) {
switch (section.type) {
case AgenticSectionType.REASONING:
case AgenticSectionType.REASONING_PENDING:
parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`);
break;
case AgenticSectionType.TEXT:
parts.push(section.content);
break;
case AgenticSectionType.TOOL_CALL:
case AgenticSectionType.TOOL_CALL_PENDING:
case AgenticSectionType.TOOL_CALL_STREAMING: {
const callObj: Record<string, unknown> = { name: section.toolName };
if (section.toolArgs) {
try {
callObj.arguments = JSON.parse(section.toolArgs);
} catch {
callObj.arguments = section.toolArgs;
}
}
parts.push(JSON.stringify(callObj, null, 2));
if (section.toolResult) {
parts.push(`[Tool Result]\n${section.toolResult}`);
}
break;
}
}
}
return parts.join('\n\n\n');
});
let editedExtras = $derived<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
let isEditing = $state(false);
@@ -49,6 +95,7 @@
let textareaElement: HTMLTextAreaElement | undefined = $state();
let showSaveOnlyOption = $derived(message.role === MessageRole.USER);
let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT);
setMessageEditContext({
get isEditing() {
@@ -64,7 +111,9 @@
return editedUploadedFiles;
},
get originalContent() {
return message.content;
return message.role === MessageRole.ASSISTANT
? (rawEditContent ?? message.content)
: message.content;
},
get originalExtras() {
return message.extra || [];
@@ -72,6 +121,18 @@
get showSaveOnlyOption() {
return showSaveOnlyOption;
},
get showBranchAfterEditOption() {
return showBranchAfterEditOption;
},
get shouldBranchAfterEdit() {
return shouldBranchAfterEdit;
},
get messageRole() {
return message.role;
},
get rawEditContent() {
return rawEditContent;
},
setContent: (content: string) => {
editedContent = content;
},
@@ -81,6 +142,9 @@
setUploadedFiles: (files: ChatUploadedFile[]) => {
editedUploadedFiles = files;
},
setShouldBranchAfterEdit: (value: boolean) => {
shouldBranchAfterEdit = value;
},
save: handleSaveEdit,
saveOnly: handleSaveEditOnly,
cancel: handleCancelEdit,
@@ -124,7 +188,10 @@
return;
}
editedContent = message.content;
editedContent =
message.role === MessageRole.ASSISTANT
? rawEditContent || message.content || ''
: message.content;
editedExtras = message.extra ? [...message.extra] : [];
editedUploadedFiles = [];
}
@@ -155,10 +222,14 @@
function handleEdit() {
isEditing = true;
// Clear temporary placeholder content for system messages
editedContent =
message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER
? ''
: message.content;
if (message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER) {
editedContent = '';
} else if (message.role === MessageRole.ASSISTANT) {
editedContent = rawEditContent || message.content || '';
} else {
editedContent = message.content;
}
textareaElement?.focus();
editedExtras = message.extra ? [...message.extra] : [];
editedUploadedFiles = [];

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import {
ChatMessageAgenticContent,
ChatMessageActions,
ChatMessageActionIcons,
ChatMessageEditForm,
ChatMessageStatistics,
ModelBadge,
ModelsSelectorDropdown
@@ -9,22 +10,12 @@
import { getMessageEditContext } from '$lib/contexts';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import {
autoResizeTextarea,
copyToClipboard,
isIMEComposing,
deriveAgenticSections
} from '$lib/utils';
import { copyToClipboard, deriveAgenticSections } from '$lib/utils';
import { AgenticSectionType } from '$lib/enums';
import { REASONING_TAGS } from '$lib/constants/agentic';
import { tick } from 'svelte';
import { fade } from 'svelte/transition';
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';
import { MessageRole, KeyboardKey, ChatMessageStatsView } from '$lib/enums';
import Label from '$lib/components/ui/label/label.svelte';
import { MessageRole, ChatMessageStatsView } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
@@ -82,19 +73,6 @@
// 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 isAgentic = $derived(hasAgenticContent(message, toolMessages));
const hasReasoning = $derived(!!message.reasoningContent);
const processingState = useProcessingState();
@@ -227,12 +205,6 @@
void copyToClipboard(displayedModel ?? '');
}
$effect(() => {
if (editCtx.isEditing && textareaElement) {
autoResizeTextarea(textareaElement);
}
});
$effect(() => {
if (showProcessingInfoTop || showProcessingInfoBottom) {
processingState.startMonitoring();
@@ -258,48 +230,7 @@
{/if}
{#if editCtx.isEditing}
<div class="w-full">
<textarea
bind:this={textareaElement}
value={editCtx.editedContent}
class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
onkeydown={handleEditKeydown}
oninput={(e) => {
autoResizeTextarea(e.currentTarget);
editCtx.setContent(e.currentTarget.value);
}}
placeholder="Edit assistant message..."
></textarea>
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center space-x-2">
<Checkbox
id="branch-after-edit"
bind:checked={shouldBranchAfterEdit}
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={editCtx.cancel} size="sm" variant="outline">
<X class="mr-1 h-3 w-3" />
Cancel
</Button>
<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>
<ChatMessageEditForm />
{:else if message.role === MessageRole.ASSISTANT}
{#if showRawOutput}
<pre class="raw-output">{rawOutputContent || ''}</pre>
@@ -388,7 +319,7 @@
</div>
{#if message.timestamp && !editCtx.isEditing}
<ChatMessageActions
<ChatMessageActionIcons
role={MessageRole.ASSISTANT}
justify="start"
actionsPosition="left"

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import {
ChatMessageActions,
ChatMessageActionIcons,
ChatMessageEditForm,
ChatMessageMcpPromptContent
} from '$lib/components/app';
@@ -63,7 +63,7 @@
{#if message.timestamp}
<div class="max-w-[80%]">
<ChatMessageActions
<ChatMessageActionIcons
actionsPosition="right"
{deletionInfo}
justify="end"

View File

@@ -1,14 +1,13 @@
<script lang="ts">
import { Check, X } from '@lucide/svelte';
import { Card } from '$lib/components/ui/card';
import { ChatMessageActionIcons, MarkdownContent } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { MarkdownContent } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { Card } from '$lib/components/ui/card';
import { INPUT_CLASSES } from '$lib/constants';
import { getMessageEditContext } from '$lib/contexts';
import { KeyboardKey, MessageRole } from '$lib/enums';
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;
@@ -213,7 +212,7 @@
{#if message.timestamp}
<div class="max-w-[80%]">
<ChatMessageActions
<ChatMessageActionIcons
actionsPosition="right"
{deletionInfo}
justify="end"

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import {
ChatMessageActionIcons,
ChatMessageEditForm,
ChatMessageUserBubble
} from '$lib/components/app/chat';
import { getMessageEditContext } from '$lib/contexts';
import ChatMessageActions from './ChatMessageActions.svelte';
import ChatMessageEditForm from './ChatMessageEditForm.svelte';
import { MessageRole } from '$lib/enums';
import ChatMessageUserBubble from './ChatMessageUserBubble.svelte';
interface Props {
class?: string;
@@ -60,7 +62,7 @@
{#if message.timestamp}
<div class="max-w-[80%]">
<ChatMessageActions
<ChatMessageActionIcons
actionsPosition="right"
{deletionInfo}
justify="end"

View File

@@ -1,11 +1,9 @@
<script lang="ts">
import { ActionIcon } from '$lib/components/app';
import ChatMessageEditForm from './ChatMessageEditForm.svelte';
import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
import { getProcessingInfoContext } from '$lib/contexts';
import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte';
import ChatMessageUserBubble from './ChatMessageUserBubble.svelte';
interface Props {
class?: string;

View File

@@ -7,12 +7,12 @@
actions: Snippet;
}
let { icon: Icon, message, actions }: Props = $props();
let { icon: IconComponent, message, actions }: Props = $props();
</script>
<div class="my-2 rounded-lg border border-border bg-card p-3">
<div class="mb-3 flex items-center gap-2 text-sm">
<Icon class="h-4 w-4 shrink-0 text-muted-foreground" />
<IconComponent class="h-4 w-4 shrink-0 text-muted-foreground" />
<span>
{@render message()}
</span>

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { ChevronDown, ShieldQuestion } from '@lucide/svelte';
import { ChatMessageActionCard } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import * as ButtonGroup from '$lib/components/ui/button-group';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { ToolSource, ToolPermissionDecision } from '$lib/enums';
import { TOOL_SERVER_LABELS } from '$lib/constants';
import { toolsStore } from '$lib/stores/tools.svelte';
import ChatMessageActionCard from './ChatMessageActionCard.svelte';
interface Props {
toolName: string;

View File

@@ -2,7 +2,7 @@
import { Edit, Copy, RefreshCw, Trash2, ArrowRight, GitBranch } from '@lucide/svelte';
import {
ActionIcon,
ChatMessageBranchingControls,
ChatMessageActionIconsBranchingControls,
DialogConfirmation
} from '$lib/components/app';
import { Switch } from '$lib/components/ui/switch';
@@ -89,7 +89,7 @@
: 'right-0'} flex items-center gap-2 opacity-100 transition-opacity"
>
{#if siblingInfo && siblingInfo.totalSiblings > 1}
<ChatMessageBranchingControls {siblingInfo} {onNavigateToSibling} />
<ChatMessageActionIconsBranchingControls {siblingInfo} {onNavigateToSibling} />
{/if}
<div

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
interface Props {
class?: string;
siblingInfo: ChatMessageSiblingInfo | null;
onNavigateToSibling?: (siblingId: string) => void;
}
let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props();
let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0);
let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1);
let nextSiblingId = $derived(
hasNext ? siblingInfo!.siblingIds[siblingInfo!.currentIndex + 1] : null
);
let previousSiblingId = $derived(
hasPrevious ? siblingInfo!.siblingIds[siblingInfo!.currentIndex - 1] : null
);
</script>
{#if siblingInfo && siblingInfo.totalSiblings > 1}
<div
aria-label="Message version {siblingInfo.currentIndex + 1} of {siblingInfo.totalSiblings}"
class="flex items-center gap-1 text-xs text-muted-foreground {className}"
role="navigation"
>
<ActionIcon
icon={ChevronLeft}
tooltip="Previous version"
disabled={!hasPrevious}
class="h-5 w-5 p-0 {!hasPrevious ? '!cursor-not-allowed opacity-30' : ''}"
onclick={() => onNavigateToSibling?.(previousSiblingId!)}
/>
<span class="px-1 font-mono text-xs">
{siblingInfo.currentIndex + 1}/{siblingInfo.totalSiblings}
</span>
<ActionIcon
icon={ChevronRight}
tooltip="Next version"
disabled={!hasNext}
class="h-5 w-5 p-0 {!hasNext ? 'opacity-30' : ''}"
onclick={() => onNavigateToSibling?.(nextSiblingId!)}
/>
</div>
{/if}

View File

@@ -5,8 +5,8 @@
CollapsibleContentBlock,
MarkdownContent,
SyntaxHighlightedCode,
ChatMessagePermissionRequest,
ChatMessageContinueRequest
ChatMessageActionCardPermissionRequest,
ChatMessageActionCardContinueRequest
} from '$lib/components/app';
import {
@@ -359,7 +359,7 @@
{/if}
{#if pendingPermission && !permissionDismissed}
<ChatMessagePermissionRequest
<ChatMessageActionCardPermissionRequest
toolName={pendingPermission.toolName}
serverLabel={pendingPermission.serverLabel}
onDecision={handlePermission}
@@ -367,7 +367,7 @@
{/if}
{#if pendingContinue && !continueDismissed}
<ChatMessageContinueRequest onDecision={handleContinue} />
<ChatMessageActionCardContinueRequest onDecision={handleContinue} />
{/if}
</div>

View File

@@ -1,84 +0,0 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
class?: string;
siblingInfo: ChatMessageSiblingInfo | null;
onNavigateToSibling?: (siblingId: string) => void;
}
let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props();
let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0);
let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1);
let nextSiblingId = $derived(
hasNext ? siblingInfo!.siblingIds[siblingInfo!.currentIndex + 1] : null
);
let previousSiblingId = $derived(
hasPrevious ? siblingInfo!.siblingIds[siblingInfo!.currentIndex - 1] : null
);
function handleNext() {
if (nextSiblingId) {
onNavigateToSibling?.(nextSiblingId);
}
}
function handlePrevious() {
if (previousSiblingId) {
onNavigateToSibling?.(previousSiblingId);
}
}
</script>
{#if siblingInfo && siblingInfo.totalSiblings > 1}
<div
aria-label="Message version {siblingInfo.currentIndex + 1} of {siblingInfo.totalSiblings}"
class="flex items-center gap-1 text-xs text-muted-foreground {className}"
role="navigation"
>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
aria-label="Previous message version"
class="h-5 w-5 p-0 {!hasPrevious ? 'cursor-not-allowed opacity-30' : ''}"
disabled={!hasPrevious}
onclick={handlePrevious}
size="sm"
variant="ghost"
>
<ChevronLeft class="h-3 w-3" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Previous version</p>
</Tooltip.Content>
</Tooltip.Root>
<span class="px-1 font-mono text-xs">
{siblingInfo.currentIndex + 1}/{siblingInfo.totalSiblings}
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
aria-label="Next message version"
class="h-5 w-5 p-0 {!hasNext ? 'cursor-not-allowed opacity-30' : ''}"
disabled={!hasNext}
onclick={handleNext}
size="sm"
variant="ghost"
>
<ChevronRight class="h-3 w-3" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Next version</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}

View File

@@ -4,15 +4,18 @@
import { Switch } from '$lib/components/ui/switch';
import { ChatForm, DialogConfirmation } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { KeyboardKey } from '$lib/enums';
import { KeyboardKey, MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte';
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
const editCtx = getMessageEditContext();
let inputAreaRef: ChatForm | undefined = $state(undefined);
let saveWithoutRegenerate = $state(false);
let showDiscardDialog = $state(false);
let branchAfterEdit = $state(false);
let isUserMessage = $derived(editCtx.messageRole === MessageRole.USER);
let isAssistantMessage = $derived(editCtx.messageRole === MessageRole.ASSISTANT);
let hasUnsavedChanges = $derived.by(() => {
if (editCtx.editedContent !== editCtx.originalContent) return true;
@@ -52,13 +55,18 @@
function handleSubmit() {
if (!canSubmit) return;
if (saveWithoutRegenerate && editCtx.showSaveOnlyOption) {
if (isUserMessage && saveWithoutRegenerate && editCtx.showSaveOnlyOption) {
editCtx.saveOnly();
} else {
if (isAssistantMessage && editCtx.setShouldBranchAfterEdit) {
editCtx.setShouldBranchAfterEdit(branchAfterEdit);
}
editCtx.save();
}
saveWithoutRegenerate = false;
branchAfterEdit = false;
}
function handleAttachmentRemove(index: number) {
@@ -94,12 +102,13 @@
<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..."
showMcpPromptButton
showAddButton={editCtx.messageRole === MessageRole.USER}
showModelSelector={editCtx.messageRole === MessageRole.USER}
onValueChange={editCtx.setContent}
onAttachmentRemove={handleAttachmentRemove}
onUploadedFileRemove={handleUploadedFileRemove}
@@ -110,7 +119,7 @@
</div>
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
{#if editCtx.showSaveOnlyOption}
{#if isUserMessage && editCtx.showSaveOnlyOption}
<div class="flex items-center gap-2">
<Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
@@ -118,6 +127,14 @@
Update without re-sending
</label>
</div>
{:else if isAssistantMessage}
<div class="flex items-center gap-2">
<Switch id="branch-after-edit" bind:checked={branchAfterEdit} class="scale-75" />
<label for="branch-after-edit" class="cursor-pointer text-xs text-muted-foreground">
Branch conversation after edit
</label>
</div>
{:else}
<div></div>
{/if}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
import { BadgeChatStatistic } from '$lib/components/app';
import { ChatMessageStatisticsBadge } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums';
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
@@ -215,84 +215,84 @@
<div class="flex items-center gap-1 px-2">
{#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
value="{predictedTokens?.toLocaleString()} tokens"
tooltipLabel="Generated tokens"
/>
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedTime}
tooltipLabel="Generation time"
/>
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Gauge}
value="{tokensPerSecond.toFixed(2)} t/s"
tooltipLabel="Generation speed"
/>
{:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats}
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Wrench}
value="{agenticTimings!.toolCallsCount} calls"
tooltipLabel="Tool calls executed"
/>
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedAgenticToolsTime}
tooltipLabel="Tool execution time"
/>
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Gauge}
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
tooltipLabel="Tool execution rate"
/>
{:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats}
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Layers}
value="{agenticTimings!.turns} turns"
tooltipLabel="Agentic turns (LLM calls)"
/>
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
tooltipLabel="Total tokens generated"
/>
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedAgenticTotalTime}
tooltipLabel="Total time (LLM + tools)"
/>
{:else if hasPromptStats}
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
value="{promptTokens} tokens"
tooltipLabel="Prompt tokens"
/>
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedPromptTime ?? '0s'}
tooltipLabel="Prompt processing time"
/>
<BadgeChatStatistic
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Gauge}
value="{promptTokensPerSecond!.toFixed(2)} tokens/s"

View File

@@ -11,7 +11,7 @@
tooltipLabel?: string;
}
let { class: className = '', icon: Icon, value, tooltipLabel }: Props = $props();
let { class: className = '', icon: IconComponent, value, tooltipLabel }: Props = $props();
function handleClick() {
void copyToClipboard(String(value));
@@ -23,7 +23,7 @@
<Tooltip.Trigger>
<BadgeInfo class={className} onclick={handleClick}>
{#snippet icon()}
<Icon class="h-3 w-3" />
<IconComponent class="h-3 w-3" />
{/snippet}
{value}
@@ -36,7 +36,7 @@
{:else}
<BadgeInfo class={className} onclick={handleClick}>
{#snippet icon()}
<Icon class="h-3 w-3" />
<IconComponent class="h-3 w-3" />
{/snippet}
{value}

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { ChatMessage } from '$lib/components/app';
import ChatMessageUserPending from './ChatMessageUserPending.svelte';
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
import { setChatActionsContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte';
@@ -33,6 +32,7 @@
let { messages = [], onUserAction }: Props = $props();
let allConversationMessages = $state<DatabaseMessage[]>([]);
const currentConfig = config();
setChatActionsContext({

View File

@@ -1,16 +1,22 @@
<script lang="ts">
import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import {
ChatScreenForm,
ChatMessages,
ChatScreenDragOverlay,
ChatScreenProcessingInfo,
DialogEmptyFileAlert,
DialogFileUploadError,
DialogChatError,
ServerLoadingSplash,
DialogConfirmation
} from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { setProcessingInfoContext } from '$lib/contexts';
import { ErrorDialogType } from '$lib/enums';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import {
@@ -32,13 +38,7 @@
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
import { ErrorDialogType } from '$lib/enums';
import { onMount } from 'svelte';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
import ChatScreenDragOverlay from './ChatScreenDragOverlay.svelte';
import { page } from '$app/state';
import { setProcessingInfoContext } from '$lib/contexts';
let { showCenteredEmpty = false } = $props();
@@ -87,12 +87,6 @@
activeProcessingState() !== null
);
setProcessingInfoContext({
get showProcessingInfo() {
return showProcessingInfo;
}
});
let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
@@ -122,9 +116,16 @@
let modelPropsVersion = $state(0);
setProcessingInfoContext({
get showProcessingInfo() {
return showProcessingInfo;
}
});
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
@@ -136,6 +137,7 @@
let hasAudioModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsAudio(activeModelId);
}
@@ -428,7 +430,6 @@
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd}
showHelperText={false}
bind:uploadedFiles
/>
</div>
@@ -437,74 +438,7 @@
</div>
{/if}
<!-- File Upload Error Alert Dialog -->
<AlertDialog.Root bind:open={showFileErrorDialog}>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content class="flex max-w-md flex-col">
<AlertDialog.Header>
<AlertDialog.Title>File Upload Error</AlertDialog.Title>
<AlertDialog.Description class="text-sm text-muted-foreground">
Some files cannot be uploaded with the current model.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="!max-h-[50vh] min-h-0 flex-1 space-y-4 overflow-y-auto">
{#if fileErrorData.generallyUnsupported.length > 0}
<div class="space-y-2">
<h4 class="text-sm font-medium text-destructive">Unsupported File Types</h4>
<div class="space-y-1">
{#each fileErrorData.generallyUnsupported as file (file.name)}
<div class="rounded-md bg-destructive/10 px-3 py-2">
<p class="font-mono text-sm break-all text-destructive">
{file.name}
</p>
<p class="mt-1 text-xs text-muted-foreground">File type not supported</p>
</div>
{/each}
</div>
</div>
{/if}
{#if fileErrorData.modalityUnsupported.length > 0}
<div class="space-y-2">
<div class="space-y-1">
{#each fileErrorData.modalityUnsupported as file (file.name)}
<div class="rounded-md bg-destructive/10 px-3 py-2">
<p class="font-mono text-sm break-all text-destructive">
{file.name}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{fileErrorData.modalityReasons[file.name] || 'Not supported by current model'}
</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
<div class="rounded-md bg-muted/50 p-3">
<h4 class="mb-2 text-sm font-medium">This model supports:</h4>
<p class="text-sm text-muted-foreground">
{fileErrorData.supportedTypes.join(', ')}
</p>
</div>
<AlertDialog.Footer>
<AlertDialog.Action onclick={() => (showFileErrorDialog = false)}>
Got it
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
<DialogFileUploadError bind:open={showFileErrorDialog} {fileErrorData} />
<DialogConfirmation
bind:open={showDeleteDialog}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { ChatFormHelperText, ChatForm } from '$lib/components/app';
import { ChatForm } from '$lib/components/app';
import { onMount } from 'svelte';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
@@ -15,7 +15,6 @@
onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
onStop?: () => void;
onSystemPromptAdd?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
showHelperText?: boolean;
uploadedFiles?: ChatUploadedFile[];
}
@@ -29,12 +28,12 @@
onSend,
onStop,
onSystemPromptAdd,
showHelperText = true,
uploadedFiles = $bindable([])
}: Props = $props();
let chatFormRef: ChatForm | undefined = $state(undefined);
let chatId = $derived(page.params.id as string | undefined);
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let message = $derived(initialMessage);
let previousIsLoading = $derived(isLoading);
let previousInitialMessage = $derived(initialMessage);
@@ -48,19 +47,10 @@
getInitialMessage: () => initialMessage
});
$effect(() => {
if (initialMessage !== previousInitialMessage) {
message = initialMessage;
previousInitialMessage = initialMessage;
}
});
function handleSystemPromptClick() {
onSystemPromptAdd?.({ message, files: uploadedFiles });
function handleFilesAdd(files: File[]) {
onFileUpload?.(files);
}
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
async function handleSubmit() {
if ((!message.trim() && uploadedFiles.length === 0) || disabled || hasLoadingAttachments)
return;
@@ -84,8 +74,8 @@
}
}
function handleFilesAdd(files: File[]) {
onFileUpload?.(files);
function handleSystemPromptClick() {
onSystemPromptAdd?.({ message, files: uploadedFiles });
}
function handleUploadedFileRemove(fileId: string) {
@@ -102,6 +92,13 @@
}
});
$effect(() => {
if (initialMessage !== previousInitialMessage) {
message = initialMessage;
previousInitialMessage = initialMessage;
}
});
$effect(() => {
if (previousIsLoading && !isLoading) {
setTimeout(() => chatFormRef?.focus(), 10);
@@ -127,5 +124,3 @@
onUploadedFileRemove={handleUploadedFileRemove}
/>
</div>
<ChatFormHelperText show={showHelperText} />

View File

@@ -59,7 +59,9 @@
});
</script>
<div class="chat-processing-info-container pointer-events-none" class:visible={showProcessingInfo}>
<div
class={['chat-processing-info-container pointer-events-none', showProcessingInfo && 'visible']}
>
<div class="chat-processing-info-content">
{#each processingDetails as detail (detail)}
<span class="chat-processing-info-detail pointer-events-auto backdrop-blur-sm">{detail}</span>

View File

@@ -1,558 +0,0 @@
<script lang="ts">
import {
Settings,
Funnel,
AlertTriangle,
Code,
Monitor,
ChevronLeft,
ChevronRight,
ListRestart,
Sliders
} from '@lucide/svelte';
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import {
SETTINGS_SECTION_TITLES,
type SettingsSectionTitle,
NUMERIC_FIELDS,
POSITIVE_INTEGER_FIELDS,
SETTINGS_COLOR_MODES_CONFIG,
SETTINGS_KEYS
} from '$lib/constants';
import { setMode } from 'mode-watcher';
import { ColorMode } from '$lib/enums/ui';
import { SettingsFieldType } from '$lib/enums/settings';
import { fade } from 'svelte/transition';
import type { Component } from 'svelte';
interface Props {
class?: string;
onSave?: () => void;
initialSection?: SettingsSectionTitle;
}
let { class: className, onSave, initialSection }: Props = $props();
const settingSections: Array<{
fields: SettingsFieldConfig[];
icon: Component;
title: SettingsSectionTitle;
}> = [
{
title: SETTINGS_SECTION_TITLES.GENERAL,
icon: Sliders,
fields: [
{
key: SETTINGS_KEYS.THEME,
label: 'Theme',
type: SettingsFieldType.SELECT,
options: SETTINGS_COLOR_MODES_CONFIG
},
{ key: SETTINGS_KEYS.API_KEY, label: 'API Key', type: SettingsFieldType.INPUT },
{
key: SETTINGS_KEYS.SYSTEM_MESSAGE,
label: 'System Message',
type: SettingsFieldType.TEXTAREA
},
{
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
label: 'Paste long text to file length',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.SEND_ON_ENTER,
label: 'Send message on Enter',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
label: 'Copy text attachments as plain text',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
label: 'Enable "Continue" button',
type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
key: SETTINGS_KEYS.PDF_AS_IMAGE,
label: 'Parse PDF as image',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
label: 'Ask for confirmation before changing conversation title',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
label: 'Use first non-empty line for conversation title',
type: SettingsFieldType.CHECKBOX
}
]
},
{
title: SETTINGS_SECTION_TITLES.DISPLAY,
icon: Monitor,
fields: [
{
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
label: 'Show message generation statistics',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
label: 'Show thought in progress',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
label: 'Show tool call in progress',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
label: 'Keep stats visible after generation',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input',
type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
label: 'Render user content as Markdown',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
label: 'Disable automatic scroll',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
label: 'Always show sidebar on desktop',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
label: 'Show raw model names',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
label: 'Always show agentic turns in conversation',
type: SettingsFieldType.CHECKBOX
}
]
},
{
title: SETTINGS_SECTION_TITLES.SAMPLING,
icon: Funnel,
fields: [
{
key: SETTINGS_KEYS.TEMPERATURE,
label: 'Temperature',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DYNATEMP_RANGE,
label: 'Dynamic temperature range',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
label: 'Dynamic temperature exponent',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.TOP_K,
label: 'Top K',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.TOP_P,
label: 'Top P',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.MIN_P,
label: 'Min P',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.XTC_PROBABILITY,
label: 'XTC probability',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.XTC_THRESHOLD,
label: 'XTC threshold',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.TYP_P,
label: 'Typical P',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.MAX_TOKENS,
label: 'Max tokens',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.SAMPLERS,
label: 'Samplers',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.BACKEND_SAMPLING,
label: 'Backend sampling',
type: SettingsFieldType.CHECKBOX
}
]
},
{
title: SETTINGS_SECTION_TITLES.PENALTIES,
icon: AlertTriangle,
fields: [
{
key: SETTINGS_KEYS.REPEAT_LAST_N,
label: 'Repeat last N',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.REPEAT_PENALTY,
label: 'Repeat penalty',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.PRESENCE_PENALTY,
label: 'Presence penalty',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
label: 'Frequency penalty',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_MULTIPLIER,
label: 'DRY multiplier',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_BASE,
label: 'DRY base',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
label: 'DRY allowed length',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
label: 'DRY penalty last N',
type: SettingsFieldType.INPUT
}
]
},
{
title: SETTINGS_SECTION_TITLES.AGENTIC,
icon: ListRestart,
fields: [
{
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
label: 'Agentic turns',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
label: 'Max lines per tool preview',
type: SettingsFieldType.INPUT
}
]
},
{
title: SETTINGS_SECTION_TITLES.DEVELOPER,
icon: Code,
fields: [
{
key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION,
label: 'Pre-fill KV cache after response',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
label: 'Disable server-side thinking extraction',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
label: 'Strip thinking from message history',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
label: 'Enable raw output toggle',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.CUSTOM,
label: 'Custom JSON',
type: SettingsFieldType.TEXTAREA
}
]
}
// TODO: Experimental features section will be implemented after initial release
// This includes Python interpreter (Pyodide integration) and other experimental features
// {
// title: 'Experimental',
// icon: Beaker,
// fields: [
// {
// key: 'pyInterpreterEnabled',
// label: 'Enable Python interpreter',
// type: 'checkbox'
// }
// ]
// }
];
let activeSection = $derived<SettingsSectionTitle>(
initialSection ?? SETTINGS_SECTION_TITLES.GENERAL
);
let currentSection = $derived(
settingSections.find((section) => section.title === activeSection) || settingSections[0]
);
let localConfig: SettingsConfigType = $state({ ...config() });
let canScrollLeft = $state(false);
let canScrollRight = $state(false);
let scrollContainer: HTMLDivElement | undefined = $state();
$effect(() => {
if (initialSection) {
activeSection = initialSection;
}
});
function handleThemeChange(newTheme: string) {
localConfig.theme = newTheme;
setMode(newTheme as ColorMode);
}
function handleConfigChange(key: string, value: string | boolean) {
localConfig[key] = value;
}
function handleReset() {
localConfig = { ...config() };
setMode(localConfig.theme as ColorMode);
}
function handleSave() {
if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
try {
JSON.parse(localConfig.custom);
} catch (error) {
alert('Invalid JSON in custom parameters. Please check the format and try again.');
console.error(error);
return;
}
}
// Convert numeric strings to numbers for numeric fields
const processedConfig = { ...localConfig };
for (const field of NUMERIC_FIELDS) {
if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
const numValue = Number(processedConfig[field]);
if (!isNaN(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;
}
}
}
settingsStore.updateMultipleConfig(processedConfig);
onSave?.();
}
function scrollToCenter(element: HTMLElement) {
if (!scrollContainer) return;
const containerRect = scrollContainer.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const elementCenter = elementRect.left + elementRect.width / 2;
const containerCenter = containerRect.left + containerRect.width / 2;
const scrollOffset = elementCenter - containerCenter;
scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
}
function scrollLeft() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
}
function scrollRight() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
}
function updateScrollButtons() {
if (!scrollContainer) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
canScrollLeft = scrollLeft > 0;
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
}
export function reset() {
localConfig = { ...config() };
setTimeout(updateScrollButtons, 100);
}
$effect(() => {
if (scrollContainer) {
updateScrollButtons();
}
});
</script>
<div class="flex h-full flex-col overflow-y-auto {className} w-full" in:fade={{ duration: 150 }}>
<div class="flex flex-1 flex-col gap-4 md:flex-row">
<!-- Desktop Sidebar -->
<div class="sticky top-0 hidden w-64 flex-col self-start bg-background pt-8 pb-4 md:flex">
<div class="flex items-center gap-2 pb-8">
<Settings class="h-6 w-6" />
<h1 class="text-2xl font-semibold">Settings</h1>
</div>
<nav class="space-y-1">
{#each settingSections as section (section.title)}
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
section.title
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={() => (activeSection = section.title)}
>
<section.icon class="h-4 w-4" />
<span class="ml-2">{section.title}</span>
</button>
{/each}
</nav>
</div>
<!-- Mobile Header with Horizontal Scrollable Menu -->
<div class="sticky top-0 z-10 flex flex-col bg-background md:hidden">
<div class="flex items-center gap-2 px-4 pt-4 pb-2 md:pt-6">
<Settings class="h-5 w-5 md:h-6 md:w-6" />
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
</div>
<div class="border-b border-border/30 py-2">
<!-- Horizontal Scrollable Category Menu with Navigation -->
<div class="relative flex items-center" style="scroll-padding: 1rem;">
<button
class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {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 overflow-x-auto py-2"
bind:this={scrollContainer}
onscroll={updateScrollButtons}
>
<div class="flex min-w-max gap-2">
{#each settingSections as section (section.title)}
<button
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
section.title
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={(e: MouseEvent) => {
activeSection = section.title;
scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="h-4 w-4 flex-shrink-0" />
<span>{section.title}</span>
</button>
{/each}
</div>
</div>
<button
class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={scrollRight}
aria-label="Scroll right"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</div>
<div class="mx-auto max-w-3xl flex-1">
<div class="space-y-6 p-4 md:p-6 md:pt-28">
<div class="grid">
<div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
<currentSection.icon class="h-5 w-5" />
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div>
{#if currentSection.fields}
<div class="space-y-6">
<ChatSettingsFields
fields={currentSection.fields}
{localConfig}
onConfigChange={handleConfigChange}
onThemeChange={handleThemeChange}
/>
</div>
{/if}
</div>
<div class="mt-8 border-t border-border/30 pt-6">
<p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
</div>
</div>
<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
</div>
</div>
</div>

View File

@@ -1,9 +0,0 @@
import { useSidebar } from '$lib/components/ui/sidebar';
const sidebar = useSidebar();
export function handleMobileSidebarItemClick() {
if (sidebar.isMobile) {
sidebar.toggle();
}
}

View File

@@ -22,7 +22,7 @@
* **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
* - Integrates with DialogChatAttachmentsPreview for full-size gallery/single viewing
* - Validates vision modality support via `activeModelId` prop
*
* **Features:**
@@ -49,55 +49,53 @@
* />
* ```
*/
export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachmentsList.svelte';
export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte';
/**
* Renders a single attachment item based on its type (image, file, MCP prompt, or MCP resource).
* Delegates to specialized sub-components: ChatAttachmentsListItemThumbnailImage, ChatAttachmentsListItemThumbnailFile,
* ChatAttachmentsListItemMcpPrompt, or ChatAttachmentsListItemMcpResource.
*/
export { default as ChatAttachmentsListItem } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte';
/**
* Displays MCP Prompt attachment with expandable content preview.
* Shows server name, prompt name, and allows expanding to view full prompt arguments
* and content. Used when user selects a prompt from ChatFormPromptPicker.
* and content. Used when user selects a prompt from ChatFormPickerMcpPrompts.
*/
export { default as ChatAttachmentMcpPrompt } from './ChatAttachments/ChatAttachmentMcpPrompt.svelte';
export { default as ChatAttachmentsListItemMcpPrompt } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte';
/**
* Displays a single MCP Resource attachment with icon, name, and server info.
* Shows loading/error states and supports remove action.
* Used within ChatAttachmentMcpResources for individual resource display.
*/
export { default as ChatAttachmentMcpResource } from './ChatAttachments/ChatAttachmentMcpResource.svelte';
/**
* Full-size attachment preview component for dialog display. Handles different file types:
* images (full-size display), text files (syntax highlighted), PDFs (text extraction or image preview),
* audio (placeholder with download), and generic files (download option).
*/
export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachmentPreview.svelte';
/**
* Displays MCP Resource attachments as a horizontal carousel.
* Shows resource name, URI, and allows clicking to view resource content.
*/
export { default as ChatAttachmentMcpResources } from './ChatAttachments/ChatAttachmentMcpResources.svelte';
export { default as ChatAttachmentsListItemMcpResource } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpResource.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';
export { default as ChatAttachmentsListItemThumbnailFile } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.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';
export { default as ChatAttachmentsListItemThumbnailImage } from './ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailImage.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.
* Unified attachment preview component for dialog display. Shows a single file
* preview without carousel, or a gallery/carousel view when multiple items exist.
* Uses ChatAttachmentPreviewSingle internally for each item's content.
*/
export { default as ChatAttachmentsViewAll } from './ChatAttachments/ChatAttachmentsViewAll.svelte';
export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview.svelte';
export { default as ChatAttachmentsPreviewNavButtons } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte';
export { default as ChatAttachmentsPreviewFileInfo } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte';
export { default as ChatAttachmentsPreviewThumbnailStrip } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte';
export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItem.svelte';
/**
*
@@ -122,7 +120,7 @@ export { default as ChatAttachmentsViewAll } from './ChatAttachments/ChatAttachm
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
*
* **Architecture:**
* - Composes ChatFormTextarea, ChatFormActions, and ChatFormPromptPicker
* - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts
* - Manages file upload state via `uploadedFiles` bindable prop
* - Integrates with ModelsSelectorDropdown for model selection in router mode
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
@@ -164,18 +162,12 @@ export { default as ChatAttachmentsViewAll } from './ChatAttachments/ChatAttachm
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.
* Wrapper component for the "add to chat" button (Plus icon).
* Exposes a `button` snippet that can be used inside DropdownMenu.Trigger (desktop)
* or Sheet.Root (mobile) to maintain consistent styling while allowing
* platform-specific trigger wrappers.
*/
export { default as ChatFormActionAttachmentsDropdown } from './ChatForm/ChatFormActions/ChatFormActionsAttachments/ChatFormActionAttachmentsDropdown.svelte';
/**
* Mobile sheet variant of the file attachment selector. Renders a bottom sheet
* with the same options as ChatFormActionAttachmentsDropdown, optimized for
* touch interaction on mobile devices.
*/
export { default as ChatFormActionAttachmentsSheet } from './ChatForm/ChatFormActions/ChatFormActionsAttachments/ChatFormActionAttachmentsSheet.svelte';
export { default as ChatFormActionsAdd } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionsAdd.svelte';
/**
* Audio recording button with real-time recording indicator. Records audio
@@ -198,6 +190,13 @@ export { default as ChatFormActions } from './ChatForm/ChatFormActions/ChatFormA
*/
export { default as ChatFormActionSubmit } from './ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
/**
* Model selector component for the chat form action bar. Renders either a dropdown
* (desktop) or bottom sheet (mobile) for selecting the conversation model in router mode.
* Exposes an `open` method for programmatically opening the selector.
*/
export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/ChatFormActionModels.svelte';
/**
* Dropdown submenu for managing tool permissions in the chat form.
*
@@ -215,10 +214,10 @@ export { default as ChatFormActionSubmit } from './ChatForm/ChatFormActions/Chat
*
* @example
* ```svelte
* <ChatFormActionToolsSubmenu />
* <ChatFormActionAddToolsSubmenu />
* ```
*/
export { default as ChatFormActionToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionToolsSubmenu.svelte';
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
/**
* Dropdown submenu for managing MCP servers in the chat form.
@@ -236,10 +235,10 @@ export { default as ChatFormActionToolsSubmenu } from './ChatForm/ChatFormAction
*
* @example
* ```svelte
* <ChatFormActionMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
* <ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
* ```
*/
export { default as ChatFormActionMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionMcpServersSubmenu.svelte';
export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte';
/**
* Hidden file input element for programmatic file selection.
@@ -247,9 +246,10 @@ export { default as ChatFormActionMcpServersSubmenu } from './ChatForm/ChatFormA
export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileInputInvisible.svelte';
/**
* Helper text display below chat.
* Displays MCP Resource attachments as a horizontal carousel.
* Shows resource name, URI, and allows clicking to view resource content.
*/
export { default as ChatFormHelperText } from './ChatForm/ChatFormHelperText.svelte';
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
/**
* Auto-resizing textarea with IME composition support. Automatically adjusts
@@ -259,10 +259,10 @@ export { default as ChatFormHelperText } from './ChatForm/ChatFormHelperText.sve
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
/**
* **ChatFormPromptPicker** - MCP prompt selection interface
* **ChatFormPickerMcpPrompts** - MCP prompt selection interface
*
* Floating picker for browsing and selecting MCP Server Prompts.
* Triggered by typing `/` in the chat input or choosing `MCP Prompt` option in ChatFormActionAttachmentsDropdown.
* Triggered by typing `/` in the chat input or choosing `MCP Prompt` option in ChatFormActionAddDropdown.
* Loads prompts from connected MCP servers and allows users to select and configure them.
*
* **Architecture:**
@@ -290,7 +290,7 @@ export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte'
*
* @example
* ```svelte
* <ChatFormPromptPicker
* <ChatFormPickerMcpPrompts
* bind:this={pickerRef}
* isOpen={showPicker}
* searchQuery={promptQuery}
@@ -301,58 +301,58 @@ export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte'
* />
* ```
*/
export { default as ChatFormPromptPicker } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte';
export { default as ChatFormPickerMcpPrompts } from './ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
/**
* Form for entering MCP prompt arguments. Displays input fields for each
* required argument defined by the prompt. Validates input and submits
* when all required fields are filled. Shows argument descriptions as hints.
*/
export { default as ChatFormPromptPickerArgumentForm } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentForm.svelte';
export { default as ChatFormPromptPickerArgumentForm } from './ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentForm.svelte';
/**
* Single argument input field with autocomplete suggestions. Fetches suggestions
* from MCP server based on argument type. Supports keyboard navigation through
* suggestions list. Used within ChatFormPromptPickerArgumentForm.
*/
export { default as ChatFormPromptPickerArgumentInput } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentInput.svelte';
export { default as ChatFormPromptPickerArgumentInput } from './ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte';
/**
* Shared popover wrapper for inline picker popovers (prompts, resources).
* Provides consistent positioning, styling, and open/close behavior.
*/
export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickerPopover.svelte';
export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte';
/**
* Generic scrollable list for picker popovers. Provides search input,
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
* Shared by ChatFormPromptPicker and ChatFormResourcePicker.
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
*/
export { default as ChatFormPickerList } from './ChatForm/ChatFormPicker/ChatFormPickerList.svelte';
export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
/**
* Generic button wrapper for picker list items. Provides consistent styling,
* hover/selected states, and data-picker-index attribute for scroll-into-view.
* Shared by ChatFormPromptPicker and ChatFormResourcePicker.
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
*/
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPicker/ChatFormPickerListItem.svelte';
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
/**
* Generic header for picker items displaying server favicon, label, item title,
* and optional description. Accepts `titleExtra` and `subtitle` snippets for
* custom content like badges or URIs. Shared by both pickers.
*/
export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPicker/ChatFormPickerItemHeader.svelte';
export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerItemHeader.svelte';
/**
* Generic skeleton loading placeholder for picker list items. Configurable
* title width and optional badge skeleton. Shared by both pickers.
*/
export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
/**
* **ChatFormResourcePicker** - MCP resource selection interface
* **ChatFormPickerMcpResources** - MCP resource selection interface
*
* Floating picker for browsing and attaching MCP Server Resources.
* Triggered by typing `@` in the chat input.
@@ -368,7 +368,16 @@ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPi
* **Exported API:**
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
*/
export { default as ChatFormResourcePicker } from './ChatForm/ChatFormResourcePicker/ChatFormResourcePicker.svelte';
export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte';
/**
* **ChatFormPickers** - Chat input picker container
*
* Container component that hosts both MCP prompt and MCP resource pickers.
* Manages shared state, keyboard navigation, and coordination between the two
* picker interfaces. Used within ChatForm for `@`-triggered pickers.
*/
export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';
/**
*
@@ -435,7 +444,7 @@ export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
* - Displays branching controls when message has siblings
*
* **User Messages:**
* - Shows attachments via ChatAttachmentsList
* - Shows attachments via ChatAttachments
* - Displays MCP prompts if present
* - Edit creates new branch or preserves responses
*
@@ -463,7 +472,7 @@ export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
* />
* ```
*/
export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
export { default as ChatMessage } from './ChatMessages/ChatMessage/ChatMessage.svelte';
/**
* **ChatMessageAgenticContent** - Agentic workflow output display
@@ -499,8 +508,9 @@ export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
* ```
*/
export { default as ChatMessageAgenticContent } from './ChatMessages/ChatMessageAgenticContent.svelte';
export { default as ChatMessagePermissionRequest } from './ChatMessages/ChatMessagePermissionRequest.svelte';
export { default as ChatMessageContinueRequest } from './ChatMessages/ChatMessageContinueRequest.svelte';
export { default as ChatMessageActionCardPermissionRequest } from './ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardPermissionRequest.svelte';
export { default as ChatMessageActionCard } from './ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte';
export { default as ChatMessageActionCardContinueRequest } from './ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCardContinueRequest.svelte';
/**
* Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate
@@ -508,7 +518,7 @@ export { default as ChatMessageContinueRequest } from './ChatMessages/ChatMessag
* Shows delete confirmation dialog with cascade delete count. Handles raw output toggle
* for assistant messages.
*/
export { default as ChatMessageActions } from './ChatMessages/ChatMessageActions.svelte';
export { default as ChatMessageActionIcons } from './ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIcons.svelte';
/**
* Navigation controls for message siblings (conversation branches). Displays
@@ -516,41 +526,29 @@ export { default as ChatMessageActions } from './ChatMessages/ChatMessageActions
* 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';
export { default as ChatMessageActionIconsBranchingControls } from './ChatMessages/ChatMessageActions/ChatMessageActionIcons/ChatMessageActionIconsBranchingControls.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';
export { default as ChatMessageStatistics } from './ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte';
export { default as ChatMessageStatisticsBadge } from './ChatMessages/ChatMessageStatistics/ChatMessageStatisticsBadge.svelte';
/**
* MCP prompt display in user messages. Shows when user selected an MCP prompt
* via ChatFormPromptPicker. Displays server name, prompt name, and expandable
* via ChatFormPickerMcpPrompts. Displays server name, prompt name, and expandable
* content preview. Stored in message.extra as DatabaseMessageExtraMcpPrompt.
*/
export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessageMcpPrompt.svelte';
export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte';
/**
* Formatted content display for MCP prompt messages. Renders the full prompt
* content with arguments in a readable format. Used within ChatMessageMcpPrompt
* for the expanded view.
*/
export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessageMcpPromptContent.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, and MCP prompts if present.
* Supports inline editing mode with ChatMessageEditForm integration.
*/
export { default as ChatMessageUser } from './ChatMessages/ChatMessageUser.svelte';
export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPromptContent.svelte';
/**
* Assistant message display component. Renders assistant responses with left-aligned styling.
@@ -558,7 +556,7 @@ export { default as ChatMessageUser } from './ChatMessages/ChatMessageUser.svelt
* (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';
export { default as ChatMessageAssistant } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte';
/**
* Inline message editing form. Provides textarea for editing message content with
@@ -568,6 +566,21 @@ export { default as ChatMessageAssistant } from './ChatMessages/ChatMessageAssis
*/
export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditForm.svelte';
/**
* User message display component. Renders user messages with right-aligned bubble styling.
* Shows message content, attachments via ChatAttachmentsList, and MCP prompts if present.
* Supports inline editing mode with ChatMessageEditForm integration.
*/
export { default as ChatMessageUser } from './ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte';
export { default as ChatMessageUserBubble } from './ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserBubble.svelte';
export { default as ChatMessageUserPending } from './ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte';
/**
* System message display component. Renders system messages with distinct styling.
* Visibility controlled by `showSystemMessage` config setting.
*/
export { default as ChatMessageSystem } from './ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte';
/**
*
* SCREEN
@@ -654,131 +667,3 @@ export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte';
* 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.
*
*/
/**
* 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';
/**
* 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';
/**
* **ChatSettingsToolsTab** - Tools configuration tab for chat settings
*
* Displays available tools grouped by source (built-in, MCP, custom) with
* toggles to enable/disable individual tools and tool groups. Shows MCP
* server favicons and permission management controls.
*/
export { default as ChatSettingsToolsTab } from './ChatSettings/ChatSettingsToolsTab.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';

View File

@@ -22,7 +22,7 @@
let {
open = $bindable(false),
class: className = '',
icon: Icon,
icon: IconComponent,
iconClass = 'h-4 w-4',
title,
subtitle,
@@ -32,6 +32,7 @@
}: Props = $props();
let contentContainer: HTMLDivElement | undefined = $state();
const autoScroll = createAutoScrollController();
$effect(() => {
@@ -59,8 +60,8 @@
<Card class="gap-0 border-muted bg-muted/30 py-0">
<Collapsible.Trigger class="flex w-full cursor-pointer items-center justify-between p-3">
<div class="flex items-center gap-2 text-muted-foreground">
{#if Icon}
<Icon class={iconClass} />
{#if IconComponent}
<IconComponent class={iconClass} />
{/if}
<span class="font-mono text-sm font-medium">{title}</span>

View File

@@ -13,12 +13,12 @@
import { browser } from '$app/environment';
import { onDestroy, tick } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { rehypeRestoreTableHtml } from '$lib/markdown/table-html-restorer';
import { rehypeEnhanceLinks } from '$lib/markdown/enhance-links';
import { rehypeEnhanceCodeBlocks } from '$lib/markdown/enhance-code-blocks';
import { rehypeResolveAttachmentImages } from '$lib/markdown/resolve-attachment-images';
import { rehypeRtlSupport } from '$lib/markdown/rehype-rtl-support';
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
import { remarkLiteralHtml } from './plugins/remark/literal-html';
import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
import {
IMAGE_NOT_ERROR_BOUND_SELECTOR,
@@ -34,7 +34,7 @@
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { mode } from 'mode-watcher';
import { ActionIconsCodeBlock, DialogCodePreview } from '$lib/components/app';
import { CodeBlockActions, DialogCodePreview } from '$lib/components/app';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { config } from '$lib/stores/settings.svelte';
@@ -620,7 +620,7 @@
<div class="code-block-wrapper streaming-code-block relative">
<div class="code-block-header">
<span class="code-language">{incompleteCodeBlock.language || 'text'}</span>
<ActionIconsCodeBlock
<CodeBlockActions
code={incompleteCodeBlock.code}
language={incompleteCodeBlock.language || 'text'}
disabled

View File

@@ -34,45 +34,35 @@ const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height
const PREVIEW_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye lucide-eye-icon"><path d="M2.062 12.345a1 1 0 0 1 0-.69C3.5 7.73 7.36 5 12 5s8.5 2.73 9.938 6.655a1 1 0 0 1 0 .69C20.5 16.27 16.64 19 12 19s-8.5-2.73-9.938-6.655"/><circle cx="12" cy="12" r="3"/></svg>`;
/**
* Creates an SVG element node from raw SVG string.
* Since we can't parse HTML in HAST directly, we use the raw property.
*/
function createRawHtmlElement(html: string): Element {
function createIconElement(svg: string): Element {
return {
type: 'element',
tagName: 'span',
properties: {},
children: [{ type: 'raw', value: html } as unknown as ElementContent]
children: [{ type: 'raw', value: svg } as unknown as ElementContent]
};
}
function createButton(className: string, title: string, iconSvg: string, codeId: string): Element {
return {
type: 'element',
tagName: 'button',
properties: {
className: [className],
'data-code-id': codeId,
title,
type: 'button'
},
children: [createIconElement(iconSvg)]
};
}
function createCopyButton(codeId: string): Element {
return {
type: 'element',
tagName: 'button',
properties: {
className: [COPY_CODE_BTN_CLASS],
'data-code-id': codeId,
title: 'Copy code',
type: 'button'
},
children: [createRawHtmlElement(COPY_ICON_SVG)]
};
return createButton(COPY_CODE_BTN_CLASS, 'Copy code', COPY_ICON_SVG, codeId);
}
function createPreviewButton(codeId: string): Element {
return {
type: 'element',
tagName: 'button',
properties: {
className: [PREVIEW_CODE_BTN_CLASS],
'data-code-id': codeId,
title: 'Preview code',
type: 'button'
},
children: [createRawHtmlElement(PREVIEW_ICON_SVG)]
};
return createButton(PREVIEW_CODE_BTN_CLASS, 'Preview code', PREVIEW_ICON_SVG, codeId);
}
function createHeader(language: string, codeId: string): Element {

Some files were not shown because too many files have changed in this diff Show More