ui: Agentic Content UX improvements (#25450)
* feat: Add shimmer text animation for processing state indicators * feat: Redesign CollapsibleContentBlock component with improved UX * feat: Add conditional setting display support with dependsOn field * feat: Add showAgenticTurnStats setting for per-turn statistics * feat: Update ChatMessageAgenticContent with improved UI and new features * feat: Enhance file read tool UI/UX * feat: Refine styling of collapsible content and code preview blocks * feat: add terminal variant to CollapsibleContentBlock * feat: add built-in tools UI registry * feat: extract ChatMessageReasoningBlock and ChatMessageToolCallBlock * refactor: simplify ChatMessageAgenticContent to use extracted blocks * fix: correct markdown content block margin spacing * fix: reorganize SettingsChatFields layout and reset button positioning * fix: use direct map access in agentic store session methods * refactor: remove reasoning preview/throttle system from CollapsibleContentBlock * feat: add auto-scroll to reasoning block and remove showThoughtInProgress * feat: add ChatMessageToolCallDateTime component and support for new tool types * feat: improve auto-scroll reliability in reasoning block with RAF coalescing and MutationObserver * feat: show MCP server favicon for tools without a built-in icon * feat: add search-results parsing utilities and tests * feat: add ChatMessageToolCallSearchResults component * feat: integrate search results rendering into ChatMessageAgenticContent * feat: display tool call input alongside output in ChatMessageToolCallBlock * style: use muted foreground color in reasoning block content * chore: Format * feat: Refine reasoning block layout and make pending thoughts display configurable * feat: Stream tool call code blocks with auto-scroll and handle partial JSON * feat: add streaming permission gate infrastructure * feat: wire permission gate into the agentic loop * fix: bail out on abort and skip already-approved tool calls * fix: clear partial tool calls on abort and savePartialResponse * test: cover partial tool call cleanup end-to-end * refactor: Remove streaming permission gate logic * fix: Correct autoscroll and streaming gates for tool calls and reasoning blocks * refactor: Chat Message Assistant componentization * fix: Show health metadata for disabled MCP servers and promote connections on enable * fix: Inherit global enabled state for missing MCP per-chat overrides * refactor: Cleanup * refactor: Split ChatMessageToolCallBlock into dedicated components * feat: Add live streaming and auto-scroll for tool execution output * feat: Add line numbers and change markers to file edit diffs * chore: Formatting * feat: Add type definitions and utilities for recommended MCP servers * feat: Add recommended MCP servers configuration and storage key * feat: Add McpServerCardCompact component for recommended servers * feat: Add recommended servers section to Add New Server dialog * feat: Update McpServerForm to support authorization requirements * feat: Add select-none classes for text selection prevention * feat: Add recommended MCP server icon assets * refactor: Store dismissed MCP recommendations as a boolean flag * feat: Render tool results as JSON or Markdown based on detected content type * feat: UI improvement * feat: Render search block early and update heading to show execution state * fix: Prevent non-web-search tools from triggering the search UI block * refactor: Cleanup * refactor: Extract hardcoded icon size classes into shared constants * refactor: Extract hardcoded tool result separator into a shared constant * refactor: Tool Calls UI/logic * refactor: Cleanup * refactor: Cleanup * refactor: Cleanup
This commit is contained in:
@@ -29,6 +29,13 @@ export default ts.config(
|
|||||||
// This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
|
// This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
|
||||||
'svelte/no-navigation-without-resolve': 'off',
|
'svelte/no-navigation-without-resolve': 'off',
|
||||||
|
|
||||||
|
// Snippet bodies often ignore one or more of the parent's params
|
||||||
|
// (e.g. `{#snippet children(_meta, ctx)}` when only ctx is read).
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
||||||
|
],
|
||||||
|
|
||||||
// Enforce empty line at end of file
|
// Enforce empty line at end of file
|
||||||
'eol-last': 'error'
|
'eol-last': 'error'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,33 @@
|
|||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shimmer-text {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--muted-foreground),
|
||||||
|
var(--foreground),
|
||||||
|
var(--muted-foreground)
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: shimmer 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.shimmer-text {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mermaidTooltip {
|
.mermaidTooltip {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Copy } from '@lucide/svelte';
|
import { Copy } from '@lucide/svelte';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard } from '$lib/utils';
|
||||||
import ActionIcon from './ActionIcon.svelte';
|
import ActionIcon from './ActionIcon.svelte';
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon={Copy}
|
icon={Copy}
|
||||||
tooltip={ariaLabel}
|
tooltip={ariaLabel}
|
||||||
iconSize="h-4 w-4"
|
iconSize={ICON_CLASS_DEFAULT}
|
||||||
disabled={!canCopy}
|
disabled={!canCopy}
|
||||||
onclick={() => canCopy && copyToClipboard(text)}
|
onclick={() => canCopy && copyToClipboard(text)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { X, Music, Video } from '@lucide/svelte';
|
import { X, Music, Video } from '@lucide/svelte';
|
||||||
import {
|
import {
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
@@ -109,9 +110,9 @@
|
|||||||
class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
|
class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
|
||||||
>
|
>
|
||||||
{#if isAudio}
|
{#if isAudio}
|
||||||
<Music class="h-4 w-4 text-white/70" />
|
<Music class="{ICON_CLASS_DEFAULT} text-white/70" />
|
||||||
{:else if isVideo}
|
{:else if isVideo}
|
||||||
<Video class="h-4 w-4 text-white/70" />
|
<Video class="{ICON_CLASS_DEFAULT} text-white/70" />
|
||||||
{:else}
|
{:else}
|
||||||
{fileTypeLabel}
|
{fileTypeLabel}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import type { ChatAttachmentDisplayItem } from '$lib/types';
|
import type { ChatAttachmentDisplayItem } from '$lib/types';
|
||||||
import { FileText, Eye, Info } from '@lucide/svelte';
|
import { FileText, Eye, Info } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -88,7 +89,7 @@
|
|||||||
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
|
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
|
||||||
disabled={pdfImagesLoading}
|
disabled={pdfImagesLoading}
|
||||||
>
|
>
|
||||||
<FileText class="mr-1 h-4 w-4" />
|
<FileText class="mr-1 {ICON_CLASS_DEFAULT}" />
|
||||||
Text
|
Text
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -100,10 +101,10 @@
|
|||||||
>
|
>
|
||||||
{#if pdfImagesLoading}
|
{#if pdfImagesLoading}
|
||||||
<div
|
<div
|
||||||
class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
|
class="mr-1 {ICON_CLASS_DEFAULT} animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||||
></div>
|
></div>
|
||||||
{:else}
|
{:else}
|
||||||
<Eye class="mr-1 h-4 w-4" />
|
<Eye class="mr-1 {ICON_CLASS_DEFAULT}" />
|
||||||
{/if}
|
{/if}
|
||||||
Pages
|
Pages
|
||||||
</Button>
|
</Button>
|
||||||
@@ -111,7 +112,7 @@
|
|||||||
|
|
||||||
{#if !hasVisionModality && activeModelId && currentItem}
|
{#if !hasVisionModality && activeModelId && currentItem}
|
||||||
<Alert.Root class="mb-4 max-w-4xl">
|
<Alert.Root class="mb-4 max-w-4xl">
|
||||||
<Info class="h-4 w-4" />
|
<Info class={ICON_CLASS_DEFAULT} />
|
||||||
<Alert.Title>Preview only</Alert.Title>
|
<Alert.Title>Preview only</Alert.Title>
|
||||||
<Alert.Description>
|
<Alert.Description>
|
||||||
<span class="inline-flex">
|
<span class="inline-flex">
|
||||||
|
|||||||
+4
-3
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Music, Video, FileText } from '@lucide/svelte';
|
import { Music, Video, FileText } from '@lucide/svelte';
|
||||||
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
|
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
|
||||||
|
|
||||||
@@ -49,11 +50,11 @@
|
|||||||
class="bg-foreground-muted/50 flex h-12 w-12 flex-col items-center justify-center gap-0.5 py-1"
|
class="bg-foreground-muted/50 flex h-12 w-12 flex-col items-center justify-center gap-0.5 py-1"
|
||||||
>
|
>
|
||||||
{#if item.isAudio}
|
{#if item.isAudio}
|
||||||
<Music class="h-4 w-4 text-white/70" />
|
<Music class="{ICON_CLASS_DEFAULT} text-white/70" />
|
||||||
{:else if item.isVideo}
|
{:else if item.isVideo}
|
||||||
<Video class="h-4 w-4 text-white/70" />
|
<Video class="{ICON_CLASS_DEFAULT} text-white/70" />
|
||||||
{:else}
|
{:else}
|
||||||
<FileText class="h-4 w-4 text-white/70" />
|
<FileText class="{ICON_CLASS_DEFAULT} text-white/70" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="font-mono text-[9px] text-white/60">{getFileExtension(item.name)}</span>
|
<span class="font-mono text-[9px] text-white/60">{getFileExtension(item.name)}</span>
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Plus } from '@lucide/svelte';
|
import { Plus } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
>
|
>
|
||||||
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
|
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
|
||||||
|
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class={ICON_CLASS_DEFAULT} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Plus, File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
|
import { Plus, File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
>
|
>
|
||||||
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
|
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
|
||||||
|
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class={ICON_CLASS_DEFAULT} />
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
@@ -100,7 +101,7 @@
|
|||||||
|
|
||||||
<DropdownMenu.Sub>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||||
<File class="h-4 w-4" />
|
<File class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>Add files</span>
|
<span>Add files</span>
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
@@ -113,7 +114,7 @@
|
|||||||
class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
|
class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
|
||||||
onclick={() => attachmentMenu.callbacks[item.action]()}
|
onclick={() => attachmentMenu.callbacks[item.action]()}
|
||||||
>
|
>
|
||||||
<item.icon class="h-4 w-4" />
|
<item.icon class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -126,7 +127,7 @@
|
|||||||
class="{item.class ?? ''} flex items-center gap-2"
|
class="{item.class ?? ''} flex items-center gap-2"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<item.icon class="h-4 w-4" />
|
<item.icon class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -147,7 +148,7 @@
|
|||||||
class="flex cursor-pointer items-center gap-2"
|
class="flex cursor-pointer items-center gap-2"
|
||||||
onclick={onSystemPromptClick}
|
onclick={onSystemPromptClick}
|
||||||
>
|
>
|
||||||
<MessageSquare class="h-4 w-4" />
|
<MessageSquare class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>System Message</span>
|
<span>System Message</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -163,7 +164,7 @@
|
|||||||
class="flex cursor-pointer items-center gap-2"
|
class="flex cursor-pointer items-center gap-2"
|
||||||
onclick={onMcpPromptClick}
|
onclick={onMcpPromptClick}
|
||||||
>
|
>
|
||||||
<Zap class="h-4 w-4" />
|
<Zap class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>MCP Prompt</span>
|
<span>MCP Prompt</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -174,7 +175,7 @@
|
|||||||
class="flex cursor-pointer items-center gap-2"
|
class="flex cursor-pointer items-center gap-2"
|
||||||
onclick={onMcpResourcesClick}
|
onclick={onMcpResourcesClick}
|
||||||
>
|
>
|
||||||
<FolderOpen class="h-4 w-4" />
|
<FolderOpen class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>MCP Resources</span>
|
<span>MCP Resources</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Settings, Plus } from '@lucide/svelte';
|
import { Settings, Plus } from '@lucide/svelte';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
|
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
|
||||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||||
<McpLogo class="h-4 w-4" />
|
<McpLogo class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>MCP Servers</span>
|
<span>MCP Servers</span>
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
<McpServerIdentity
|
<McpServerIdentity
|
||||||
{displayName}
|
{displayName}
|
||||||
{faviconUrl}
|
{faviconUrl}
|
||||||
iconClass="h-4 w-4"
|
iconClass={ICON_CLASS_DEFAULT}
|
||||||
iconRounded="rounded-sm"
|
iconRounded="rounded-sm"
|
||||||
showVersion={false}
|
showVersion={false}
|
||||||
nameClass="text-sm"
|
nameClass="text-sm"
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
class="flex cursor-pointer items-center gap-2"
|
class="flex cursor-pointer items-center gap-2"
|
||||||
onclick={handleMcpSettingsClick}
|
onclick={handleMcpSettingsClick}
|
||||||
>
|
>
|
||||||
<Settings class="h-4 w-4" />
|
<Settings class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>Manage MCP Servers</span>
|
<span>Manage MCP Servers</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -140,7 +141,7 @@
|
|||||||
class="flex cursor-pointer items-center gap-2"
|
class="flex cursor-pointer items-center gap-2"
|
||||||
onclick={handleMcpSettingsClick}
|
onclick={handleMcpSettingsClick}
|
||||||
>
|
>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>Add MCP Servers</span>
|
<span>Add MCP Servers</span>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
|
import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
@@ -13,9 +14,9 @@
|
|||||||
<DropdownMenu.Sub bind:open={subOpen}>
|
<DropdownMenu.Sub bind:open={subOpen}>
|
||||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||||
{#if reasoning.thinkingEnabled}
|
{#if reasoning.thinkingEnabled}
|
||||||
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
|
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||||
{:else}
|
{:else}
|
||||||
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
|
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -46,9 +47,9 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if reasoning.isSelected(level)}
|
{#if reasoning.isSelected(level)}
|
||||||
<Check class="h-4 w-4 shrink-0 text-foreground" />
|
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-4 w-4 shrink-0"></div>
|
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="flex-1">{level.label}</span>
|
<span class="flex-1">{level.label}</span>
|
||||||
|
|||||||
+24
-23
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import * as Sheet from '$lib/components/ui/sheet';
|
import * as Sheet from '$lib/components/ui/sheet';
|
||||||
@@ -108,15 +109,15 @@
|
|||||||
>
|
>
|
||||||
<Collapsible.Trigger class={sheetItemClass}>
|
<Collapsible.Trigger class={sheetItemClass}>
|
||||||
{#if reasoningExpanded}
|
{#if reasoningExpanded}
|
||||||
<ChevronDown class="h-4 w-4 shrink-0" />
|
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight class="h-4 w-4 shrink-0" />
|
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if reasoning.thinkingEnabled}
|
{#if reasoning.thinkingEnabled}
|
||||||
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
|
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||||
{:else}
|
{:else}
|
||||||
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
|
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="flex-1">Reasoning</span>
|
<span class="flex-1">Reasoning</span>
|
||||||
@@ -138,9 +139,9 @@
|
|||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
{#if reasoning.isSelected(level)}
|
{#if reasoning.isSelected(level)}
|
||||||
<Check class="h-4 w-4 shrink-0 text-foreground" />
|
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-4 w-4 shrink-0"></div>
|
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="text-sm">{level.label}</span>
|
<span class="text-sm">{level.label}</span>
|
||||||
@@ -161,12 +162,12 @@
|
|||||||
<Collapsible.Root open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
|
<Collapsible.Root open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
|
||||||
<Collapsible.Trigger class={sheetItemClass}>
|
<Collapsible.Trigger class={sheetItemClass}>
|
||||||
{#if filesExpanded}
|
{#if filesExpanded}
|
||||||
<ChevronDown class="h-4 w-4 shrink-0" />
|
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight class="h-4 w-4 shrink-0" />
|
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<File class="h-4 w-4 shrink-0" />
|
<File class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span class="flex-1">Add files</span>
|
<span class="flex-1">Add files</span>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
@@ -181,7 +182,7 @@
|
|||||||
class={sheetItemClass}
|
class={sheetItemClass}
|
||||||
onclick={() => attachmentMenu.callbacks[item.action]()}
|
onclick={() => attachmentMenu.callbacks[item.action]()}
|
||||||
>
|
>
|
||||||
<item.icon class="h-4 w-4 shrink-0" />
|
<item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -189,7 +190,7 @@
|
|||||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
<button type="button" class={sheetItemClass} disabled>
|
<button type="button" class={sheetItemClass} disabled>
|
||||||
<item.icon class="h-4 w-4 shrink-0" />
|
<item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -208,12 +209,12 @@
|
|||||||
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
|
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
|
||||||
<Collapsible.Trigger class={sheetItemClass}>
|
<Collapsible.Trigger class={sheetItemClass}>
|
||||||
{#if mcpExpanded}
|
{#if mcpExpanded}
|
||||||
<ChevronDown class="h-4 w-4 shrink-0" />
|
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight class="h-4 w-4 shrink-0" />
|
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<McpLogo class="inline h-4 w-4 shrink-0" />
|
<McpLogo class="inline {ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span class="flex-1">MCP Servers</span>
|
<span class="flex-1">MCP Servers</span>
|
||||||
|
|
||||||
@@ -242,7 +243,7 @@
|
|||||||
<img
|
<img
|
||||||
src={faviconUrl}
|
src={faviconUrl}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-4 w-4 shrink-0 rounded-sm"
|
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
|
||||||
onerror={(e) => {
|
onerror={(e) => {
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
}}
|
}}
|
||||||
@@ -280,12 +281,12 @@
|
|||||||
<Collapsible.Root open={toolsExpanded} onOpenChange={(open) => (toolsExpanded = open)}>
|
<Collapsible.Root open={toolsExpanded} onOpenChange={(open) => (toolsExpanded = open)}>
|
||||||
<Collapsible.Trigger class={sheetItemClass}>
|
<Collapsible.Trigger class={sheetItemClass}>
|
||||||
{#if toolsExpanded}
|
{#if toolsExpanded}
|
||||||
<ChevronDown class="h-4 w-4 shrink-0" />
|
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight class="h-4 w-4 shrink-0" />
|
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<PencilRuler class="inline h-4 w-4 shrink-0" />
|
<PencilRuler class="inline {ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span class="flex-1">Tools</span>
|
<span class="flex-1">Tools</span>
|
||||||
|
|
||||||
@@ -310,7 +311,7 @@
|
|||||||
<img
|
<img
|
||||||
src={favicon}
|
src={favicon}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-4 w-4 shrink-0 rounded-sm"
|
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
|
||||||
onerror={(e) => {
|
onerror={(e) => {
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
}}
|
}}
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
{checked}
|
{checked}
|
||||||
class="h-4 w-4 shrink-0"
|
class="{ICON_CLASS_DEFAULT} shrink-0"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
|
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
|
||||||
/>
|
/>
|
||||||
@@ -341,7 +342,7 @@
|
|||||||
class={sheetItemClass}
|
class={sheetItemClass}
|
||||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
|
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
|
||||||
>
|
>
|
||||||
<MessageSquare class="h-4 w-4 shrink-0" />
|
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span>System Message</span>
|
<span>System Message</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -352,7 +353,7 @@
|
|||||||
class={sheetItemClass}
|
class={sheetItemClass}
|
||||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
|
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
|
||||||
>
|
>
|
||||||
<Zap class="h-4 w-4 shrink-0" />
|
<Zap class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span>MCP Prompt</span>
|
<span>MCP Prompt</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -364,7 +365,7 @@
|
|||||||
class={sheetItemClass}
|
class={sheetItemClass}
|
||||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
|
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
|
||||||
>
|
>
|
||||||
<FolderOpen class="h-4 w-4 shrink-0" />
|
<FolderOpen class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span>MCP Resources</span>
|
<span>MCP Resources</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+8
-7
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info, Check } from '@lucide/svelte';
|
import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info, Check } from '@lucide/svelte';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
|
|
||||||
<DropdownMenu.Sub onOpenChange={(open) => open && toolsPanel.handleOpen()}>
|
<DropdownMenu.Sub onOpenChange={(open) => open && toolsPanel.handleOpen()}>
|
||||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||||
<PencilRuler class="h-4 w-4" />
|
<PencilRuler class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>Tools</span>
|
<span>Tools</span>
|
||||||
</DropdownMenu.SubTrigger>
|
</DropdownMenu.SubTrigger>
|
||||||
@@ -24,14 +25,14 @@
|
|||||||
{#if toolsPanel.totalToolCount === 0}
|
{#if toolsPanel.totalToolCount === 0}
|
||||||
{#if toolsStore.loading}
|
{#if toolsStore.loading}
|
||||||
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
|
<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" />
|
<Loader2 class="mx-auto mb-1 {ICON_CLASS_DEFAULT} animate-spin" />
|
||||||
|
|
||||||
Loading tools...
|
Loading tools...
|
||||||
</div>
|
</div>
|
||||||
{:else if toolsStore.isToolsEndpointUnreachable}
|
{:else if toolsStore.isToolsEndpointUnreachable}
|
||||||
<div class="grid gap-2.5 px-3 py-4 text-sm text-muted-foreground">
|
<div class="grid gap-2.5 px-3 py-4 text-sm text-muted-foreground">
|
||||||
<span class="flex gap-2">
|
<span class="flex gap-2">
|
||||||
<Info class="mt-0.5 h-4 w-4 shrink-0" />
|
<Info class="mt-0.5 {ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
Run llama-server with <code>{CLI_FLAGS.TOOLS}</code> flag to enable
|
Run llama-server with <code>{CLI_FLAGS.TOOLS}</code> flag to enable
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="flex gap-2">
|
<span class="flex gap-2">
|
||||||
<Info class="mt-0.5 h-4 w-4 shrink-0" />
|
<Info class="mt-0.5 {ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
{hasMcpServersAvailable ? 'Enable' : 'Add'} MCP Server(s) to access
|
{hasMcpServersAvailable ? 'Enable' : 'Add'} MCP Server(s) to access
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
<div class="px-3 py-4 text-center text-sm text-muted-foreground">Failed to load tools</div>
|
<div class="px-3 py-4 text-center text-sm text-muted-foreground">Failed to load tools</div>
|
||||||
{:else if toolsPanel.noToolsInfoMessage}
|
{:else if toolsPanel.noToolsInfoMessage}
|
||||||
<div class="flex gap-2 px-3 py-4 text-sm text-muted-foreground">
|
<div class="flex gap-2 px-3 py-4 text-sm text-muted-foreground">
|
||||||
<Info class="mt-0.5 h-4 w-4 shrink-0" />
|
<Info class="mt-0.5 {ICON_CLASS_DEFAULT} shrink-0" />
|
||||||
|
|
||||||
<span>{toolsPanel.noToolsInfoMessage}</span>
|
<span>{toolsPanel.noToolsInfoMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +88,7 @@
|
|||||||
<img
|
<img
|
||||||
src={favicon}
|
src={favicon}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-4 w-4 shrink-0 rounded-sm"
|
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
|
||||||
onerror={(e) => {
|
onerror={(e) => {
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
}}
|
}}
|
||||||
@@ -109,7 +110,7 @@
|
|||||||
{...props}
|
{...props}
|
||||||
{checked}
|
{checked}
|
||||||
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
|
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
|
||||||
class="mr-2 h-4 w-4 shrink-0"
|
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Mic, Square } from '@lucide/svelte';
|
import { Mic, Square } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
@@ -36,9 +37,9 @@
|
|||||||
<span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
|
<span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
|
||||||
|
|
||||||
{#if isRecording}
|
{#if isRecording}
|
||||||
<Square class="h-4 w-4 animate-pulse fill-white" />
|
<Square class="{ICON_CLASS_DEFAULT} animate-pulse fill-white" />
|
||||||
{:else}
|
{:else}
|
||||||
<Mic class="h-4 w-4" />
|
<Mic class={ICON_CLASS_DEFAULT} />
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Square, SkipForward } from '@lucide/svelte';
|
import { Square, SkipForward } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ChatService } from '$lib/services';
|
import { ChatService } from '$lib/services';
|
||||||
@@ -185,7 +186,9 @@
|
|||||||
>
|
>
|
||||||
<span class="sr-only">Skip reasoning</span>
|
<span class="sr-only">Skip reasoning</span>
|
||||||
|
|
||||||
<SkipForward class="h-4 w-4 stroke-muted-foreground group-hover:stroke-foreground" />
|
<SkipForward
|
||||||
|
class="{ICON_CLASS_DEFAULT} stroke-muted-foreground group-hover:stroke-foreground"
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -6,7 +6,7 @@
|
|||||||
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
|
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
|
||||||
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
|
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
|
||||||
import type { ReasoningEffortLevel } from '$lib/types';
|
import type { ReasoningEffortLevel } from '$lib/types';
|
||||||
import { DIALOG_SUBMENU_CONTENT } from '$lib/constants/css-classes';
|
import { DIALOG_SUBMENU_CONTENT, ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import {
|
import {
|
||||||
modelsStore,
|
modelsStore,
|
||||||
checkModelSupportsThinking,
|
checkModelSupportsThinking,
|
||||||
@@ -74,9 +74,9 @@
|
|||||||
<DropdownMenu.Sub bind:open={subOpen}>
|
<DropdownMenu.Sub bind:open={subOpen}>
|
||||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||||
{#if thinkingEnabled}
|
{#if thinkingEnabled}
|
||||||
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
|
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||||
{:else}
|
{:else}
|
||||||
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
|
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="flex-1">Thinking</span>
|
<span class="flex-1">Thinking</span>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isSelected(level)}
|
{#if isSelected(level)}
|
||||||
<Check class="h-4 w-4 shrink-0 text-foreground" />
|
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -384,7 +384,6 @@
|
|||||||
{isLastAssistantMessage}
|
{isLastAssistantMessage}
|
||||||
{message}
|
{message}
|
||||||
{toolMessages}
|
{toolMessages}
|
||||||
messageContent={message.content}
|
|
||||||
onConfirmDelete={handleConfirmDelete}
|
onConfirmDelete={handleConfirmDelete}
|
||||||
onContinue={handleContinue}
|
onContinue={handleContinue}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
|
|||||||
+23
-178
@@ -2,23 +2,20 @@
|
|||||||
import {
|
import {
|
||||||
ChatMessageAgenticContent,
|
ChatMessageAgenticContent,
|
||||||
ChatMessageActionIcons,
|
ChatMessageActionIcons,
|
||||||
ChatMessageEditForm,
|
ChatMessageAssistantModel,
|
||||||
ChatMessageStatistics,
|
ChatMessageAssistantProcessingInfo,
|
||||||
ModelBadge,
|
ChatMessageAssistantRawOutput,
|
||||||
ModelsSelectorDropdown
|
ChatMessageAssistantStatistics,
|
||||||
|
ChatMessageEditForm
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { getMessageEditContext } from '$lib/contexts';
|
import { getMessageEditContext } from '$lib/contexts';
|
||||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||||
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
|
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
|
||||||
import { copyToClipboard, deriveAgenticSections, modelLoadProgressText } from '$lib/utils';
|
import { modelLoadProgressText } from '$lib/utils';
|
||||||
import { AgenticSectionType, ChatMessageStatisticsMode } from '$lib/enums';
|
|
||||||
import { REASONING_TAGS } from '$lib/constants/agentic';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import { MessageRole } from '$lib/enums';
|
import { MessageRole } from '$lib/enums';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||||
import { modelsStore } from '$lib/stores/models.svelte';
|
import { modelsStore } from '$lib/stores/models.svelte';
|
||||||
import { ServerModelStatus } from '$lib/enums';
|
|
||||||
|
|
||||||
import { hasAgenticContent } from '$lib/utils';
|
import { hasAgenticContent } from '$lib/utils';
|
||||||
|
|
||||||
@@ -33,7 +30,6 @@
|
|||||||
isLastAssistantMessage?: boolean;
|
isLastAssistantMessage?: boolean;
|
||||||
message: DatabaseMessage;
|
message: DatabaseMessage;
|
||||||
toolMessages?: DatabaseMessage[];
|
toolMessages?: DatabaseMessage[];
|
||||||
messageContent: string | undefined;
|
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onConfirmDelete: () => void;
|
onConfirmDelete: () => void;
|
||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
@@ -54,7 +50,6 @@
|
|||||||
isLastAssistantMessage = false,
|
isLastAssistantMessage = false,
|
||||||
message,
|
message,
|
||||||
toolMessages = [],
|
toolMessages = [],
|
||||||
messageContent,
|
|
||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
onContinue,
|
onContinue,
|
||||||
onCopy,
|
onCopy,
|
||||||
@@ -77,55 +72,11 @@
|
|||||||
|
|
||||||
let currentConfig = $derived(config());
|
let currentConfig = $derived(config());
|
||||||
let isRouter = $derived(isRouterMode());
|
let isRouter = $derived(isRouterMode());
|
||||||
|
|
||||||
let showRawOutput = $state(false);
|
let showRawOutput = $state(false);
|
||||||
|
|
||||||
let rawOutputContent = $derived.by(() => {
|
|
||||||
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 displayedModel = $derived(message.model ?? null);
|
let displayedModel = $derived(message.model ?? null);
|
||||||
|
|
||||||
// model being switched to while it loads, so the selector bar tracks it
|
|
||||||
let pendingModel = $state<string | null>(null);
|
|
||||||
|
|
||||||
let isCurrentlyLoading = $derived(isLoading());
|
let isCurrentlyLoading = $derived(isLoading());
|
||||||
let isStreaming = $derived(isChatStreaming());
|
let isStreaming = $derived(isChatStreaming());
|
||||||
let hasNoContent = $derived(!message?.content?.trim());
|
let hasNoContent = $derived(!message?.content?.trim());
|
||||||
@@ -189,10 +140,6 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleCopyModel() {
|
|
||||||
void copyToClipboard(displayedModel ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showProcessingInfoTop || showProcessingInfoBottom) {
|
if (showProcessingInfoTop || showProcessingInfoBottom) {
|
||||||
processingState.startMonitoring();
|
processingState.startMonitoring();
|
||||||
@@ -211,23 +158,14 @@
|
|||||||
aria-label="Assistant message with actions"
|
aria-label="Assistant message with actions"
|
||||||
>
|
>
|
||||||
{#if showProcessingInfoTop}
|
{#if showProcessingInfoTop}
|
||||||
<div class="mt-6 w-full max-w-3xl" in:fade>
|
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="top" />
|
||||||
<div class="processing-container">
|
|
||||||
<span class="processing-text">
|
|
||||||
{modelLoadingText ??
|
|
||||||
processingState.getPromptProgressText() ??
|
|
||||||
processingState.getProcessingMessage() ??
|
|
||||||
'Processing...'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if editCtx.isEditing}
|
{#if editCtx.isEditing}
|
||||||
<ChatMessageEditForm />
|
<ChatMessageEditForm />
|
||||||
{:else if message.role === MessageRole.ASSISTANT}
|
{:else}
|
||||||
{#if showRawOutput}
|
{#if showRawOutput}
|
||||||
<pre class="raw-output">{rawOutputContent || ''}</pre>
|
<ChatMessageAssistantRawOutput {message} {toolMessages} />
|
||||||
{:else}
|
{:else}
|
||||||
<ChatMessageAgenticContent
|
<ChatMessageAgenticContent
|
||||||
{message}
|
{message}
|
||||||
@@ -236,78 +174,28 @@
|
|||||||
{isLastAssistantMessage}
|
{isLastAssistantMessage}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
|
||||||
<div class="text-sm whitespace-pre-wrap">
|
|
||||||
{messageContent}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showProcessingInfoBottom}
|
{#if showProcessingInfoBottom}
|
||||||
<div class="mt-4 w-full max-w-3xl" in:fade>
|
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
|
||||||
<div class="processing-container">
|
|
||||||
<span class="processing-text">
|
|
||||||
{modelLoadingText ??
|
|
||||||
processingState.getPromptProgressText() ??
|
|
||||||
processingState.getProcessingMessage() ??
|
|
||||||
'Processing...'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="info my-6 grid gap-4 tabular-nums">
|
<div class="info my-6 grid gap-4 tabular-nums">
|
||||||
{#if displayedModel}
|
{#if displayedModel}
|
||||||
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||||
{#if isRouter}
|
<ChatMessageAssistantModel
|
||||||
<ModelsSelectorDropdown
|
{displayedModel}
|
||||||
currentModel={pendingModel ?? displayedModel}
|
isLoading={isLoading()}
|
||||||
disabled={isLoading()}
|
{isRouter}
|
||||||
onModelChange={async (modelId: string, modelName: string) => {
|
{onRegenerate}
|
||||||
const status = modelsStore.getModelStatus(modelId);
|
/>
|
||||||
|
|
||||||
if (status !== ServerModelStatus.LOADED) {
|
<ChatMessageAssistantStatistics
|
||||||
pendingModel = modelId;
|
{message}
|
||||||
|
isLoading={isLoading()}
|
||||||
try {
|
{processingState}
|
||||||
await modelsStore.loadModel(modelId);
|
showMessageStats={currentConfig.showMessageStats}
|
||||||
} finally {
|
/>
|
||||||
pendingModel = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onRegenerate(modelName);
|
|
||||||
return true;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
|
||||||
{@const agentic = message.timings.agentic}
|
|
||||||
<ChatMessageStatistics
|
|
||||||
mode={ChatMessageStatisticsMode.GENERATION}
|
|
||||||
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
|
|
||||||
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
|
|
||||||
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
|
|
||||||
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
|
|
||||||
agenticTimings={agentic}
|
|
||||||
/>
|
|
||||||
{:else if isLoading() && currentConfig.showMessageStats}
|
|
||||||
{@const liveStats = processingState.getLiveProcessingStats()}
|
|
||||||
{@const genStats = processingState.getLiveGenerationStats()}
|
|
||||||
|
|
||||||
{#if genStats}
|
|
||||||
<ChatMessageStatistics
|
|
||||||
mode={ChatMessageStatisticsMode.GENERATION}
|
|
||||||
isLive
|
|
||||||
promptTokens={liveStats?.tokensProcessed}
|
|
||||||
promptMs={liveStats?.timeMs}
|
|
||||||
predictedTokens={genStats.tokensGenerated}
|
|
||||||
predictedMs={genStats.timeMs}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -353,47 +241,4 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.processing-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.processing-text {
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--muted-foreground),
|
|
||||||
var(--foreground),
|
|
||||||
var(--muted-foreground)
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
animation: shine 1s linear infinite;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shine {
|
|
||||||
to {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.raw-output {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 48rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: hsl(var(--muted) / 0.3);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ModelBadge, ModelsSelectorDropdown } from '$lib/components/app';
|
||||||
|
import { copyToClipboard } from '$lib/utils';
|
||||||
|
import { modelsStore } from '$lib/stores/models.svelte';
|
||||||
|
import { ServerModelStatus } from '$lib/enums';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
displayedModel: string | null;
|
||||||
|
isRouter: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
onRegenerate: (modelOverride?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { displayedModel, isRouter, isLoading, onRegenerate }: Props = $props();
|
||||||
|
|
||||||
|
let pendingModel = $state<string | null>(null);
|
||||||
|
|
||||||
|
function handleCopyModel() {
|
||||||
|
void copyToClipboard(displayedModel ?? '');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isRouter}
|
||||||
|
<ModelsSelectorDropdown
|
||||||
|
currentModel={pendingModel ?? displayedModel}
|
||||||
|
disabled={isLoading}
|
||||||
|
onModelChange={async (modelId: string, modelName: string) => {
|
||||||
|
const status = modelsStore.getModelStatus(modelId);
|
||||||
|
|
||||||
|
if (status !== ServerModelStatus.LOADED) {
|
||||||
|
pendingModel = modelId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await modelsStore.loadModel(modelId);
|
||||||
|
} finally {
|
||||||
|
pendingModel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onRegenerate(modelName);
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
|
||||||
|
{/if}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelLoadingText: string | null;
|
||||||
|
processingState: UseProcessingStateReturn;
|
||||||
|
position: 'top' | 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { modelLoadingText, processingState, position }: Props = $props();
|
||||||
|
|
||||||
|
const marginClass = position === 'top' ? 'mt-6' : 'mt-4';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="{marginClass} w-full max-w-3xl" in:fade>
|
||||||
|
<div class="flex flex-col items-start gap-2">
|
||||||
|
<span class="shimmer-text text-sm">
|
||||||
|
{modelLoadingText ??
|
||||||
|
processingState.getPromptProgressText() ??
|
||||||
|
processingState.getProcessingMessage() ??
|
||||||
|
'Processing...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { deriveAgenticSections, buildAssistantRawOutput } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: DatabaseMessage;
|
||||||
|
toolMessages?: DatabaseMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { message, toolMessages = [] }: Props = $props();
|
||||||
|
|
||||||
|
let rawOutputContent = $derived.by(() => {
|
||||||
|
const sections = deriveAgenticSections(message, toolMessages, [], false);
|
||||||
|
return buildAssistantRawOutput(sections);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<pre class="raw-output">{rawOutputContent || ''}</pre>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.raw-output {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 48rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: hsl(var(--muted) / 0.3);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ChatMessageStatistics } from '$lib/components/app';
|
||||||
|
import { ChatMessageStatisticsMode } from '$lib/enums';
|
||||||
|
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: DatabaseMessage;
|
||||||
|
isLoading: boolean;
|
||||||
|
processingState: UseProcessingStateReturn;
|
||||||
|
showMessageStats: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { message, isLoading, processingState, showMessageStats }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||||
|
{@const agentic = message.timings.agentic}
|
||||||
|
<ChatMessageStatistics
|
||||||
|
mode={ChatMessageStatisticsMode.GENERATION}
|
||||||
|
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
|
||||||
|
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
|
||||||
|
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
|
||||||
|
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
|
||||||
|
agenticTimings={agentic}
|
||||||
|
/>
|
||||||
|
{:else if isLoading && showMessageStats}
|
||||||
|
{@const liveStats = processingState.getLiveProcessingStats()}
|
||||||
|
{@const genStats = processingState.getLiveGenerationStats()}
|
||||||
|
|
||||||
|
{#if genStats}
|
||||||
|
<ChatMessageStatistics
|
||||||
|
mode={ChatMessageStatisticsMode.GENERATION}
|
||||||
|
isLive
|
||||||
|
promptTokens={liveStats?.tokensProcessed}
|
||||||
|
promptMs={liveStats?.timeMs}
|
||||||
|
predictedTokens={genStats.tokensGenerated}
|
||||||
|
predictedMs={genStats.timeMs}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BuiltInTool } from '$lib/enums';
|
||||||
|
import {
|
||||||
|
extractSearchQuery,
|
||||||
|
extractSearchResults,
|
||||||
|
isWebSearchToolName,
|
||||||
|
type AgenticSection
|
||||||
|
} from '$lib/utils';
|
||||||
|
import type { DatabaseMessageExtra } from '$lib/types';
|
||||||
|
import ChatMessageToolCallBlockDefault from './ChatMessageToolCallBlockDefault.svelte';
|
||||||
|
import ChatMessageToolCallBlockEditFile from './ChatMessageToolCallBlockEditFile.svelte';
|
||||||
|
import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte';
|
||||||
|
import ChatMessageToolCallBlockFileGlobSearch from './ChatMessageToolCallBlockFileGlobSearch.svelte';
|
||||||
|
import ChatMessageToolCallBlockGetDatetime from './ChatMessageToolCallBlockGetDatetime.svelte';
|
||||||
|
import ChatMessageToolCallBlockGrepSearch from './ChatMessageToolCallBlockGrepSearch.svelte';
|
||||||
|
import ChatMessageToolCallBlockReadFile from './ChatMessageToolCallBlockReadFile.svelte';
|
||||||
|
import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
|
||||||
|
import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte';
|
||||||
|
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
attachments?: DatabaseMessageExtra[];
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
isExecuting?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, attachments, open, isStreaming, isExecuting, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
const searchResults = $derived(extractSearchResults(section.toolResult));
|
||||||
|
const searchQuery = $derived(extractSearchQuery(section.toolArgs));
|
||||||
|
const isSearchCall = $derived(
|
||||||
|
searchResults.length > 0 || (searchQuery.length > 0 && isWebSearchToolName(section.toolName))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isSearchCall}
|
||||||
|
<ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
|
||||||
|
{:else if section.toolName === BuiltInTool.GET_DATETIME}
|
||||||
|
<ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
|
||||||
|
{:else if section.toolName === BuiltInTool.READ_FILE}
|
||||||
|
<ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
|
||||||
|
{:else if section.toolName === BuiltInTool.EDIT_FILE}
|
||||||
|
<ChatMessageToolCallBlockEditFile {section} {open} {isStreaming} {onToggle} />
|
||||||
|
{:else if section.toolName === BuiltInTool.WRITE_FILE}
|
||||||
|
<ChatMessageToolCallBlockWriteFile {section} {open} {isStreaming} {onToggle} />
|
||||||
|
{:else if section.toolName === BuiltInTool.EXEC_SHELL_COMMAND}
|
||||||
|
<ChatMessageToolCallBlockExecShellCommand
|
||||||
|
{section}
|
||||||
|
{open}
|
||||||
|
{isStreaming}
|
||||||
|
{isExecuting}
|
||||||
|
{attachments}
|
||||||
|
{onToggle}
|
||||||
|
/>
|
||||||
|
{:else if section.toolName === BuiltInTool.FILE_GLOB_SEARCH}
|
||||||
|
<ChatMessageToolCallBlockFileGlobSearch {section} {open} {isStreaming} {onToggle} />
|
||||||
|
{:else if section.toolName === BuiltInTool.GREP_SEARCH}
|
||||||
|
<ChatMessageToolCallBlockGrepSearch {section} {open} {isStreaming} {onToggle} />
|
||||||
|
{:else if section.toolName === BuiltInTool.RUN_JAVASCRIPT}
|
||||||
|
<ChatMessageToolCallBlockRunJavascript {section} {open} {isStreaming} {onToggle} />
|
||||||
|
{:else}
|
||||||
|
<ChatMessageToolCallBlockDefault {section} {open} {isStreaming} {attachments} {onToggle} />
|
||||||
|
{/if}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Fall-through renderer for tool calls without a dedicated block.
|
||||||
|
// Renders section.toolArgs / section.toolResult directly using the
|
||||||
|
// shared chrome shell.
|
||||||
|
|
||||||
|
import { Loader2 } from '@lucide/svelte';
|
||||||
|
import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
|
||||||
|
import { FileTypeText, ToolResultKind } from '$lib/enums';
|
||||||
|
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
|
||||||
|
import {
|
||||||
|
classifyToolResult,
|
||||||
|
formatJsonPretty,
|
||||||
|
parseToolResultWithImages,
|
||||||
|
type AgenticSection,
|
||||||
|
type ToolResultLine
|
||||||
|
} from '$lib/utils';
|
||||||
|
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
|
||||||
|
import type { DatabaseMessageExtra } from '$lib/types';
|
||||||
|
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
attachments?: DatabaseMessageExtra[];
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, open, isStreaming, attachments, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
|
||||||
|
|
||||||
|
const parsedLines: ToolResultLine[] = $derived(
|
||||||
|
section.toolResult ? parseToolResultWithImages(section.toolResult, attachments) : []
|
||||||
|
);
|
||||||
|
const outputKind = $derived(classifyToolResult(section.toolResult));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ToolCallBlock {section} {open} {isStreaming} meta={null} {title} {onToggle}>
|
||||||
|
{#snippet children(_meta, ctx)}
|
||||||
|
{#if ctx.isStreamingCall}
|
||||||
|
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground/70">
|
||||||
|
<span>Input</span>
|
||||||
|
{#if ctx.isStreaming}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if section.toolArgs}
|
||||||
|
<SyntaxHighlightedCode
|
||||||
|
code={formatJsonPretty(section.toolArgs)}
|
||||||
|
language={FileTypeText.JSON}
|
||||||
|
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||||
|
streaming={ctx.isCodeStreaming}
|
||||||
|
/>
|
||||||
|
{:else if ctx.isStreaming}
|
||||||
|
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||||
|
Receiving arguments...
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
|
||||||
|
>
|
||||||
|
Response was truncated
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{@const showInput = Boolean(section.toolArgs)}
|
||||||
|
{#if showInput}
|
||||||
|
<div class="mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70">
|
||||||
|
<span>Input</span>
|
||||||
|
</div>
|
||||||
|
<SyntaxHighlightedCode
|
||||||
|
code={formatJsonPretty(section.toolArgs ?? '')}
|
||||||
|
language={FileTypeText.JSON}
|
||||||
|
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||||
|
streaming={ctx.isCodeStreaming}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class={showInput
|
||||||
|
? 'mt-4 mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'
|
||||||
|
: 'mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'}
|
||||||
|
>
|
||||||
|
<span>Output</span>
|
||||||
|
{#if ctx.isPending}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if ctx.isPending}
|
||||||
|
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||||
|
Waiting for result...
|
||||||
|
</div>
|
||||||
|
{:else if section.toolResult}
|
||||||
|
{#if outputKind === ToolResultKind.JSON}
|
||||||
|
<SyntaxHighlightedCode
|
||||||
|
code={formatJsonPretty(section.toolResult)}
|
||||||
|
language={FileTypeText.JSON}
|
||||||
|
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||||
|
/>
|
||||||
|
{:else if outputKind === ToolResultKind.MARKDOWN}
|
||||||
|
<MarkdownContent content={section.toolResult} {attachments} />
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-auto">
|
||||||
|
{#each parsedLines as line, i (i)}
|
||||||
|
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
|
||||||
|
{line.text}
|
||||||
|
</div>
|
||||||
|
{#if line.image}
|
||||||
|
<img
|
||||||
|
src={line.image.base64Url}
|
||||||
|
alt={line.image.name}
|
||||||
|
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No output</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ToolCallBlock>
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { XCircle } from '@lucide/svelte';
|
||||||
|
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
|
||||||
|
import { computeLineDiff, prefixFor, type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseEditFileMeta } from './parsers/edit-file';
|
||||||
|
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
const editFileMeta = $derived(parseEditFileMeta(section));
|
||||||
|
|
||||||
|
const editDiffs = $derived(
|
||||||
|
(editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ToolCallBlock {section} {open} {isStreaming} meta={editFileMeta} {onToggle}>
|
||||||
|
{#snippet titleSnippet()}
|
||||||
|
<span class="text-muted-foreground">Edit file </span>
|
||||||
|
<span class="font-mono">{editFileMeta?.filePath}</span>
|
||||||
|
{#if editFileMeta?.errorMessage}
|
||||||
|
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet children(meta, _ctx)}
|
||||||
|
{#if meta?.errorMessage}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||||
|
>
|
||||||
|
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||||
|
<span>{meta.errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if meta && meta.edits.length > 0}
|
||||||
|
{#each editDiffs as diffLines, ei (ei)}
|
||||||
|
<div class={ei === 0 ? '' : 'mt-3'}>
|
||||||
|
<div class="mb-1.5 text-xs text-muted-foreground/70 italic">
|
||||||
|
Edit {ei + 1} of {meta.edits.length}
|
||||||
|
</div>
|
||||||
|
<div class="diff-block" style:max-height={MAX_HEIGHT_CODE_BLOCK}>
|
||||||
|
<div class="diff-pre">
|
||||||
|
{#each diffLines as line, li (li)}
|
||||||
|
<div class="diff-line diff-{line.kind}">
|
||||||
|
<span class="diff-old-num">{line.oldLine ?? ''}</span>
|
||||||
|
<span class="diff-marker">{prefixFor(line.kind)}</span>
|
||||||
|
<span class="diff-new-num">{line.newLine ?? ''}</span>
|
||||||
|
<span class="diff-text">{line.text || ' '}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||||
|
{#if meta.resultMessage}
|
||||||
|
{meta.resultMessage}{meta.editsApplied != null ? RESULT_STAT_SEPARATOR : ''}{/if}
|
||||||
|
{#if meta.editsApplied != null}
|
||||||
|
<span class="font-mono">{meta.editsApplied}</span>
|
||||||
|
{meta.editsApplied === 1 ? 'edit' : 'edits'} applied
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No edits</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ToolCallBlock>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.diff-block {
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: color-mix(in oklch, var(--border) 30%, transparent);
|
||||||
|
background: var(--code-background);
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .diff-block {
|
||||||
|
border-color: color-mix(in oklch, var(--border) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each row is a 4-column grid: old-line#, marker, new-line#, text.
|
||||||
|
* The gutters stay fixed-width so the text column lines up unversally. */
|
||||||
|
.diff-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3.25rem 1.5rem 3.25rem 1fr;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.65;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-old-num,
|
||||||
|
.diff-new-num {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
user-select: none;
|
||||||
|
color: color-mix(in oklch, var(--muted-foreground) 70%, transparent);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-marker {
|
||||||
|
text-align: center;
|
||||||
|
color: color-mix(in oklch, var(--muted-foreground) 70%, transparent);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.diff-add {
|
||||||
|
background-color: #f0fff4;
|
||||||
|
color: #22863a;
|
||||||
|
}
|
||||||
|
.diff-line.diff-add .diff-new-num,
|
||||||
|
.diff-line.diff-add .diff-marker {
|
||||||
|
color: #22863a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.diff-remove {
|
||||||
|
background-color: #ffeef0;
|
||||||
|
color: #b31d28;
|
||||||
|
}
|
||||||
|
.diff-line.diff-remove .diff-old-num,
|
||||||
|
.diff-line.diff-remove .diff-marker {
|
||||||
|
color: #b31d28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.diff-add .diff-old-num,
|
||||||
|
.diff-line.diff-remove .diff-new-num {
|
||||||
|
/* Empty gutter columns for add/remove rows mirror git unification
|
||||||
|
* (added lines don't have an old number, removed lines don't have a
|
||||||
|
* new number). Keep them visible so columns stay aligned across
|
||||||
|
* mixed rows. */
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-text {
|
||||||
|
padding-left: 0.4rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .diff-line.diff-add {
|
||||||
|
background-color: #033a16;
|
||||||
|
color: #aff5b4;
|
||||||
|
}
|
||||||
|
:global(.dark) .diff-line.diff-add .diff-new-num,
|
||||||
|
:global(.dark) .diff-line.diff-add .diff-marker {
|
||||||
|
color: #aff5b4;
|
||||||
|
}
|
||||||
|
:global(.dark) .diff-line.diff-remove {
|
||||||
|
background-color: #67060c;
|
||||||
|
color: #ffdcd7;
|
||||||
|
}
|
||||||
|
:global(.dark) .diff-line.diff-remove .diff-old-num,
|
||||||
|
:global(.dark) .diff-line.diff-remove .diff-marker {
|
||||||
|
color: #ffdcd7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+293
@@ -0,0 +1,293 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Block for `exec_shell_command`. Unlike the other tools, this
|
||||||
|
// renderer uses CollapsibleTerminalBlock (terminal-style frame)
|
||||||
|
// and treats "live" output chunks as active even after the call
|
||||||
|
// resolved, so the spinner stays on while stdout is still flowing.
|
||||||
|
// The scroll-to-bottom auto-scroll logic mirrors what was here
|
||||||
|
// before extraction.
|
||||||
|
|
||||||
|
import { Check, Loader2, XCircle, AlertTriangle } from '@lucide/svelte';
|
||||||
|
import { CollapsibleTerminalBlock } from '$lib/components/app';
|
||||||
|
import { SETTINGS_KEYS } from '$lib/constants';
|
||||||
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
|
import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
|
||||||
|
import {
|
||||||
|
highlightCode,
|
||||||
|
isExitCodeSummaryLine,
|
||||||
|
parseExecShellCommandError,
|
||||||
|
parseExecShellCommandExitStatus,
|
||||||
|
parseToolResultWithImages,
|
||||||
|
type AgenticSection,
|
||||||
|
type ExecShellExitStatus,
|
||||||
|
type ToolResultLine
|
||||||
|
} from '$lib/utils';
|
||||||
|
import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
|
||||||
|
import type { DatabaseMessageExtra } from '$lib/types';
|
||||||
|
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
/** True while the agentic loop is streaming output chunks for THIS
|
||||||
|
* tool call. Drives max-height + auto-scroll while true; releases
|
||||||
|
* them when the loop reports this call as done. */
|
||||||
|
isExecuting?: boolean;
|
||||||
|
attachments?: DatabaseMessageExtra[];
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, open, isStreaming, isExecuting = false, attachments, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
// `isLive` covers all in-flight phases: pre-chunk spinner and
|
||||||
|
// streaming itself. Frozen output (tool done while agent continues)
|
||||||
|
// is not live.
|
||||||
|
const isLive = $derived(isExecuting);
|
||||||
|
|
||||||
|
const execShellMeta = $derived(parseExecShellCommandMeta(section));
|
||||||
|
const execShellError = $derived(parseExecShellCommandError(section.toolResult));
|
||||||
|
const execShellExitStatus: ExecShellExitStatus | undefined = $derived(
|
||||||
|
parseExecShellCommandExitStatus(section.toolResult)
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedLines: ToolResultLine[] = $derived(
|
||||||
|
section.toolResult ? parseToolResultWithImages(section.toolResult, attachments) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop the trailing "[exit code: N]" line - rendered as a colored
|
||||||
|
// badge below. During streaming we keep it so a partial stream still
|
||||||
|
// shows the status once the final chunk lands.
|
||||||
|
const outputLines: ToolResultLine[] = $derived(
|
||||||
|
execShellExitStatus && parsedLines.length > 0
|
||||||
|
? parsedLines.slice(0, parsedLines.length - 1)
|
||||||
|
: parsedLines
|
||||||
|
);
|
||||||
|
|
||||||
|
const isExitCodeFinalLine = $derived(
|
||||||
|
execShellExitStatus !== undefined &&
|
||||||
|
parsedLines.length > 0 &&
|
||||||
|
isExitCodeSummaryLine(parsedLines[parsedLines.length - 1].text, execShellExitStatus)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Highlight just the command for the title; the (typically large)
|
||||||
|
// output blob uses bare monospace to skip hljs per-line highlighting.
|
||||||
|
const highlightedCommandHtml = $derived(
|
||||||
|
execShellMeta ? highlightCode(execShellMeta.command, 'bash') : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitBadgeClass = $derived(
|
||||||
|
execShellExitStatus?.timedOut
|
||||||
|
? 'exit-badge warning'
|
||||||
|
: execShellExitStatus?.code === 0
|
||||||
|
? 'exit-badge success'
|
||||||
|
: 'exit-badge failure'
|
||||||
|
);
|
||||||
|
|
||||||
|
const useFullHeightCodeBlocks = $derived(
|
||||||
|
Boolean(config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS])
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoScroll = $derived(isLive && !useFullHeightCodeBlocks);
|
||||||
|
|
||||||
|
const SCROLL_BOTTOM_THRESHOLD_PX = TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX;
|
||||||
|
|
||||||
|
let scrollEl: HTMLDivElement | undefined = $state();
|
||||||
|
let userScrolledUp = $state(false);
|
||||||
|
let lastScrollTop = 0;
|
||||||
|
let pendingFrame: number | null = null;
|
||||||
|
|
||||||
|
function isAtBottom(): boolean {
|
||||||
|
if (!scrollEl) return false;
|
||||||
|
return (
|
||||||
|
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
|
||||||
|
SCROLL_BOTTOM_THRESHOLD_PX
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottomOnFrame() {
|
||||||
|
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
|
||||||
|
pendingFrame = requestAnimationFrame(() => {
|
||||||
|
pendingFrame = null;
|
||||||
|
|
||||||
|
// Re-check on rAF - user may scroll between scheduling and paint.
|
||||||
|
if (scrollEl && !userScrolledUp) {
|
||||||
|
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScrollEvent() {
|
||||||
|
if (!scrollEl) return;
|
||||||
|
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
|
||||||
|
if (isScrollingUp && !isAtBottom()) {
|
||||||
|
userScrolledUp = true;
|
||||||
|
} else if (isAtBottom()) {
|
||||||
|
userScrolledUp = false;
|
||||||
|
}
|
||||||
|
lastScrollTop = scrollEl.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void section.toolResult;
|
||||||
|
if (!scrollEl || !autoScroll) return;
|
||||||
|
scrollToBottomOnFrame();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Catch layout changes that don't touch toolResult (line-wrap
|
||||||
|
// reflow, image attaches, hljs settle).
|
||||||
|
if (!scrollEl || !autoScroll) return;
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => scrollToBottomOnFrame());
|
||||||
|
observer.observe(scrollEl, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Reset on stream end so the next render (full-height) starts
|
||||||
|
// pinned.
|
||||||
|
if (!isLive) {
|
||||||
|
userScrolledUp = false;
|
||||||
|
lastScrollTop = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet execShellTitle()}
|
||||||
|
{#if highlightedCommandHtml}
|
||||||
|
<span class="font-mono">{@html highlightedCommandHtml}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="font-mono">{execShellMeta?.command}</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<ToolCallBlock
|
||||||
|
{section}
|
||||||
|
{open}
|
||||||
|
{isStreaming}
|
||||||
|
meta={execShellMeta ? { errorMessage: execShellError } : null}
|
||||||
|
wrapper={CollapsibleTerminalBlock}
|
||||||
|
extraLiveStreaming={isLive}
|
||||||
|
spinIconWhenActive={true}
|
||||||
|
{onToggle}
|
||||||
|
>
|
||||||
|
{#snippet titleSnippet()}
|
||||||
|
{@render execShellTitle()}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet children(_meta, ctx)}
|
||||||
|
{#if ctx.isPending}
|
||||||
|
<div class="flex items-start gap-2 text-xs text-muted-foreground/70">
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
Running...
|
||||||
|
</div>
|
||||||
|
{:else if execShellError}
|
||||||
|
<div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
|
||||||
|
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||||
|
<span>{execShellError}</span>
|
||||||
|
</div>
|
||||||
|
{:else if section.toolResult}
|
||||||
|
<div
|
||||||
|
bind:this={scrollEl}
|
||||||
|
class="terminal-output"
|
||||||
|
class:is-clamped={!useFullHeightCodeBlocks}
|
||||||
|
onscroll={handleScrollEvent}
|
||||||
|
>
|
||||||
|
{#each outputLines as line, i (i)}
|
||||||
|
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
|
||||||
|
{#if line.image}
|
||||||
|
<img
|
||||||
|
src={line.image.base64Url}
|
||||||
|
alt={line.image.name}
|
||||||
|
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if isExitCodeFinalLine && execShellExitStatus}
|
||||||
|
<div class={exitBadgeClass}>
|
||||||
|
{#if execShellExitStatus.timedOut}
|
||||||
|
<AlertTriangle class="h-3 w-3" />
|
||||||
|
<span>timed out</span>
|
||||||
|
<span class="exit-sep">·</span>
|
||||||
|
<span>exit {execShellExitStatus.code}</span>
|
||||||
|
{:else if execShellExitStatus.code === 0}
|
||||||
|
<Check class="h-3 w-3" />
|
||||||
|
<span>exit 0</span>
|
||||||
|
{:else}
|
||||||
|
<XCircle class="h-3 w-3" />
|
||||||
|
<span>exit {execShellExitStatus.code}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ToolCallBlock>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.terminal-output {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-output.is-clamped {
|
||||||
|
max-height: 28rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-badge.success {
|
||||||
|
background: color-mix(in oklch, var(--color-green-500, #22c55e) 14%, transparent);
|
||||||
|
color: var(--color-green-700, #15803d);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .exit-badge.success {
|
||||||
|
background: color-mix(in oklch, var(--color-green-400, #4ade80) 18%, transparent);
|
||||||
|
color: var(--color-green-300, #86efac);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-badge.failure {
|
||||||
|
background: color-mix(in oklch, var(--color-red-500, #ef4444) 14%, transparent);
|
||||||
|
color: var(--color-red-700, #b91c1c);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .exit-badge.failure {
|
||||||
|
background: color-mix(in oklch, var(--color-red-400, #f87171) 18%, transparent);
|
||||||
|
color: var(--color-red-300, #fca5a5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-badge.warning {
|
||||||
|
background: color-mix(in oklch, var(--color-amber-500, #f59e0b) 14%, transparent);
|
||||||
|
color: var(--color-amber-700, #b45309);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .exit-badge.warning {
|
||||||
|
background: color-mix(in oklch, var(--color-amber-400, #fbbf24) 18%, transparent);
|
||||||
|
color: var(--color-amber-300, #fcd34d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit-sep {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { XCircle } from '@lucide/svelte';
|
||||||
|
import { type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
|
||||||
|
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ToolCallBlock {section} {open} {isStreaming} meta={fileGlobMeta} {onToggle}>
|
||||||
|
{#snippet titleSnippet()}
|
||||||
|
{#if fileGlobMeta}
|
||||||
|
<span class="text-muted-foreground"
|
||||||
|
>{fileGlobMeta.include === '**' ? 'List files' : 'Search files'} </span
|
||||||
|
>
|
||||||
|
{#if fileGlobMeta.include !== '**'}
|
||||||
|
<span class="font-mono">{fileGlobMeta.include}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-muted-foreground"> in </span>
|
||||||
|
<span class="font-mono">{fileGlobMeta.path}</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet children(meta, ctx)}
|
||||||
|
{#if ctx.isPending}
|
||||||
|
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
{:else if meta?.errorMessage}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||||
|
>
|
||||||
|
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||||
|
<span>{meta.errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if meta && meta.matches.length > 0}
|
||||||
|
<div class="max-h-96 overflow-auto">
|
||||||
|
{#each meta.matches as match, i (i)}
|
||||||
|
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{match}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||||
|
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
|
||||||
|
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||||
|
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ToolCallBlock>
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Clock, Loader2 } from '@lucide/svelte';
|
||||||
|
import { AgenticSectionType } from '$lib/enums';
|
||||||
|
import type { AgenticSection } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, isStreaming = false }: Props = $props();
|
||||||
|
|
||||||
|
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
|
||||||
|
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
|
||||||
|
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
|
||||||
|
|
||||||
|
type GetDatetimeMeta = {
|
||||||
|
dateString?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseGetDatetimeMeta(toolResultString: string | undefined): GetDatetimeMeta {
|
||||||
|
if (!toolResultString) return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(toolResultString);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
if (typeof obj.error === 'string') return { errorMessage: obj.error };
|
||||||
|
if (typeof obj.result === 'string') return { dateString: obj.result.trim() };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { dateString: toolResultString.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMeta = $derived(parseGetDatetimeMeta(section.toolResult));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
|
||||||
|
<Clock class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||||
|
{#if showSpinner}
|
||||||
|
<span class="text-foreground/80 text-sm font-medium">Current time</span>
|
||||||
|
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
|
||||||
|
{:else if dateMeta.errorMessage}
|
||||||
|
<span class="text-foreground/80 text-sm font-medium">Current time </span>
|
||||||
|
<span class="text-red-600 text-xs italic dark:text-red-400">- {dateMeta.errorMessage}</span
|
||||||
|
>
|
||||||
|
{:else if dateMeta.dateString}
|
||||||
|
<span class="text-foreground/80 text-sm font-medium">Current time is </span>
|
||||||
|
<span class="font-mono text-foreground/90 text-sm">{dateMeta.dateString}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-foreground/80 text-sm font-medium">Current time</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { XCircle } from '@lucide/svelte';
|
||||||
|
import { type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseGrepSearchMeta } from './parsers/grep-search';
|
||||||
|
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
const grepMeta = $derived(parseGrepSearchMeta(section));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ToolCallBlock {section} {open} {isStreaming} meta={grepMeta} {onToggle}>
|
||||||
|
{#snippet titleSnippet()}
|
||||||
|
{#if grepMeta}
|
||||||
|
<span class="text-muted-foreground">Search for </span>
|
||||||
|
<span class="font-mono">{grepMeta.pattern}</span>
|
||||||
|
<span class="text-muted-foreground"> in </span>
|
||||||
|
<span class="font-mono">{grepMeta.path}</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet children(meta, ctx)}
|
||||||
|
{#if ctx.isPending}
|
||||||
|
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
{:else if meta?.errorMessage}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||||
|
>
|
||||||
|
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||||
|
<span>{meta.errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if meta && meta.matches.length > 0}
|
||||||
|
<div class="max-h-96 overflow-auto">
|
||||||
|
{#each meta.matches as match, mi (mi)}
|
||||||
|
<div class="font-mono text-[11px] leading-relaxed">
|
||||||
|
<span class="text-muted-foreground/70">{match.file}</span>
|
||||||
|
{#if meta.showLineNumbers && match.line != null}
|
||||||
|
<span class="text-muted-foreground/70">:{match.line}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-muted-foreground/70">:</span>
|
||||||
|
<span>{match.content}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||||
|
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
|
||||||
|
{#if meta.showLineNumbers}
|
||||||
|
<span class="italic">(with line numbers)</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
|
||||||
|
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||||
|
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ToolCallBlock>
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SyntaxHighlightedCode } from '$lib/components/app';
|
||||||
|
import { DEFAULT_LANGUAGE, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
|
||||||
|
import { type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseReadFileMeta } from './parsers/read-file';
|
||||||
|
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
const readFileMeta = $derived(parseReadFileMeta(section));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ToolCallBlock {section} {open} {isStreaming} meta={readFileMeta} {onToggle}>
|
||||||
|
{#snippet titleSnippet()}
|
||||||
|
<span class="text-muted-foreground">Read file </span>
|
||||||
|
<span class="font-mono">{readFileMeta?.fileName}</span>
|
||||||
|
{#if readFileMeta?.lineRange}
|
||||||
|
<span class="text-muted-foreground"
|
||||||
|
> (lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end})</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet children(_meta, _ctx)}
|
||||||
|
{#if section.toolResult}
|
||||||
|
<SyntaxHighlightedCode
|
||||||
|
code={section.toolResult}
|
||||||
|
language={readFileMeta?.language ?? DEFAULT_LANGUAGE}
|
||||||
|
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||||
|
Waiting for file content...
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ToolCallBlock>
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { XCircle, Terminal } from '@lucide/svelte';
|
||||||
|
import { SyntaxHighlightedCode } from '$lib/components/app';
|
||||||
|
import { FileTypeText } from '$lib/enums';
|
||||||
|
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
|
||||||
|
import { getBuiltinToolUi, type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseRunJavascriptMeta } from './parsers/run-javascript';
|
||||||
|
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
const runJsMeta = $derived(parseRunJavascriptMeta(section));
|
||||||
|
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ToolCallBlock {section} {open} {isStreaming} meta={runJsMeta} {title} {onToggle}>
|
||||||
|
{#snippet children(meta, ctx)}
|
||||||
|
{#if ctx.isPending}
|
||||||
|
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">Running...</div>
|
||||||
|
{:else if meta?.errorMessage}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||||
|
>
|
||||||
|
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||||
|
<span>{meta.errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<SyntaxHighlightedCode
|
||||||
|
code={meta.code}
|
||||||
|
language={FileTypeText.JAVASCRIPT}
|
||||||
|
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||||
|
streaming={ctx.isCodeStreaming}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if meta}
|
||||||
|
<SyntaxHighlightedCode
|
||||||
|
code={meta.code}
|
||||||
|
language={FileTypeText.JAVASCRIPT}
|
||||||
|
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||||
|
streaming={ctx.isCodeStreaming}
|
||||||
|
/>
|
||||||
|
<div class="mb-2 mt-3 flex items-center gap-2 text-xs text-muted-foreground/70">
|
||||||
|
<Terminal class="h-3 w-3" />
|
||||||
|
<span>Console</span>
|
||||||
|
{#if meta.timeoutMs != null}
|
||||||
|
<span class="font-mono">· timeout {meta.timeoutMs} ms</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if section.toolResult}
|
||||||
|
<div class="mt-1">
|
||||||
|
<SyntaxHighlightedCode
|
||||||
|
code={section.toolResult}
|
||||||
|
language={FileTypeText.JAVASCRIPT}
|
||||||
|
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No output</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ToolCallBlock>
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
|
||||||
|
import { Globe, Loader2 } from '@lucide/svelte';
|
||||||
|
import { CollapsibleContentBlock } from '$lib/components/app';
|
||||||
|
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||||
|
import { AgenticSectionType } from '$lib/enums';
|
||||||
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
|
import {
|
||||||
|
extractSearchResults,
|
||||||
|
extractSearchQuery,
|
||||||
|
faviconForUrl,
|
||||||
|
sanitizeExternalUrl,
|
||||||
|
type SearchResult,
|
||||||
|
type AgenticSection
|
||||||
|
} from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open?: boolean;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, open = $bindable(false), isStreaming = false, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
|
||||||
|
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
|
||||||
|
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
|
||||||
|
|
||||||
|
const results: SearchResult[] = $derived(extractSearchResults(section.toolResult));
|
||||||
|
const query = $derived(extractSearchQuery(section.toolArgs));
|
||||||
|
|
||||||
|
// Same icon-resolution chain as ChatMessageToolCallBlockDefault so
|
||||||
|
// MCP-server branding is consistent across both views. Spinner wins
|
||||||
|
// while the call is in flight so the user sees execution status.
|
||||||
|
const iconUrl = $derived(showSpinner ? null : mcpStore.getServerFaviconForTool(section.toolName));
|
||||||
|
const icon = $derived(showSpinner ? Loader2 : undefined);
|
||||||
|
const iconClass = $derived(showSpinner ? ICON_CLASS_SPIN : ICON_CLASS_DEFAULT);
|
||||||
|
|
||||||
|
// Verb reflects state: "Searching" while the call is in flight, "Searched"
|
||||||
|
// once results (or a definitive empty response) have arrived. Lets the
|
||||||
|
// heading read as a live progress indicator rather than a completed
|
||||||
|
// retrospective.
|
||||||
|
const title = $derived.by(() => {
|
||||||
|
const verb = showSpinner ? 'Searching' : 'Searched';
|
||||||
|
return query ? `${verb} web for "${query}"` : `${verb} web`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function hideBrokenIcon(event: Event) {
|
||||||
|
(event.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPublishDate(iso: string | undefined): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
try {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return iso;
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostFor(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).host;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDetails(result: SearchResult): boolean {
|
||||||
|
return Boolean(result.highlights || result.published || result.author);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet pill(result: SearchResult)}
|
||||||
|
{@const faviconUrl = faviconForUrl(result.url)}
|
||||||
|
{@const safeUrl = sanitizeExternalUrl(result.url)}
|
||||||
|
{@const showHoverCard = safeUrl !== null && hasDetails(result)}
|
||||||
|
{#if safeUrl}
|
||||||
|
<HoverCard.Root openDelay={150} closeDelay={100}>
|
||||||
|
<HoverCard.Trigger
|
||||||
|
href={safeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="hover:bg-muted/80 focus-visible:ring-ring inline-flex max-w-full items-center gap-1.5 rounded-full border bg-muted px-2.5 py-1 text-xs transition-colors outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
{#if faviconUrl}
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
class="h-3 w-3 shrink-0 rounded-sm"
|
||||||
|
onerror={hideBrokenIcon}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Globe class="text-muted-foreground/70 h-3 w-3 shrink-0" />
|
||||||
|
{/if}
|
||||||
|
<span class="truncate font-medium text-foreground/80">{result.title}</span>
|
||||||
|
</HoverCard.Trigger>
|
||||||
|
{#if showHoverCard}
|
||||||
|
{@const publishDate = formatPublishDate(result.published)}
|
||||||
|
{@const host = hostFor(safeUrl)}
|
||||||
|
<HoverCard.Content
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
sideOffset={6}
|
||||||
|
class="bg-popover text-popover-foreground z-50 w-80 max-w-[90vw] rounded-lg border p-0 shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2 p-3">
|
||||||
|
<a
|
||||||
|
href={safeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="line-clamp-3 text-sm font-medium leading-snug hover:underline"
|
||||||
|
>{result.title}</a
|
||||||
|
>
|
||||||
|
{#if publishDate || result.author}
|
||||||
|
<div class="text-muted-foreground flex items-center gap-1.5 text-[11px]">
|
||||||
|
{#if publishDate}
|
||||||
|
<span>{publishDate}</span>
|
||||||
|
{/if}
|
||||||
|
{#if publishDate && result.author}
|
||||||
|
<span class="opacity-50">·</span>
|
||||||
|
{/if}
|
||||||
|
{#if result.author}
|
||||||
|
<span class="truncate">{result.author}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if result.highlights}
|
||||||
|
<p
|
||||||
|
class="text-popover-foreground/85 line-clamp-5 text-xs leading-relaxed whitespace-pre-line"
|
||||||
|
>
|
||||||
|
{result.highlights}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if host}
|
||||||
|
<div class="text-muted-foreground/80 truncate text-[11px]">{host}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</HoverCard.Content>
|
||||||
|
{/if}
|
||||||
|
</HoverCard.Root>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<CollapsibleContentBlock {open} class="my-2" {icon} {iconClass} {iconUrl} {title} {onToggle}>
|
||||||
|
{#if results.length > 0}
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pb-1">
|
||||||
|
{#each results as result (result.url)}
|
||||||
|
{@render pill(result)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if showSpinner}
|
||||||
|
<div class="text-muted-foreground/70 flex items-center gap-2 py-1 text-xs italic">
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
<span>Searching...</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-muted-foreground/70 py-1 text-xs italic">No results</div>
|
||||||
|
{/if}
|
||||||
|
</CollapsibleContentBlock>
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { XCircle } from '@lucide/svelte';
|
||||||
|
import { SyntaxHighlightedCode } from '$lib/components/app';
|
||||||
|
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
|
||||||
|
import { type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseWriteFileMeta } from './parsers/write-file';
|
||||||
|
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
const writeFileMeta = $derived(parseWriteFileMeta(section));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ToolCallBlock {section} {open} {isStreaming} meta={writeFileMeta} {onToggle}>
|
||||||
|
{#snippet titleSnippet()}
|
||||||
|
<span class="text-muted-foreground">Write file </span>
|
||||||
|
<span class="font-mono">{writeFileMeta?.filePath}</span>
|
||||||
|
{#if writeFileMeta?.errorMessage}
|
||||||
|
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet children(meta, ctx)}
|
||||||
|
{#if meta?.errorMessage}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||||
|
>
|
||||||
|
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||||
|
<span>{meta.errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else if meta}
|
||||||
|
<SyntaxHighlightedCode
|
||||||
|
code={meta.content}
|
||||||
|
language={meta.language}
|
||||||
|
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||||
|
streaming={ctx.isCodeStreaming}
|
||||||
|
/>
|
||||||
|
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||||
|
{#if meta.resultMessage}
|
||||||
|
{meta.resultMessage}{meta.bytesWritten != null ? RESULT_STAT_SEPARATOR : ''}{/if}
|
||||||
|
{#if meta.bytesWritten != null}
|
||||||
|
<span class="font-mono">{meta.bytesWritten}</span>
|
||||||
|
bytes
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ToolCallBlock>
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
<script lang="ts" generics="TMeta">
|
||||||
|
// Generic chrome shell shared by every per-tool block under
|
||||||
|
// `ChatMessageToolCall/`. Owns:
|
||||||
|
// - the collapsible wrapper (defaults to CollapsibleContentBlock;
|
||||||
|
// `exec_shell_command` swaps in CollapsibleTerminalBlock via the
|
||||||
|
// `wrapper` prop);
|
||||||
|
// - the icon, spinner state, and MCP favicon fallback chain;
|
||||||
|
// - the status subtitle pill.
|
||||||
|
// Components supply only their `meta`, a title snippet, and a body
|
||||||
|
// snippet - everything around them is this single source of truth.
|
||||||
|
|
||||||
|
import { Loader2, Wrench } from '@lucide/svelte';
|
||||||
|
import { CollapsibleContentBlock } from '$lib/components/app';
|
||||||
|
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
|
||||||
|
import { AgenticSectionType } from '$lib/enums';
|
||||||
|
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
|
||||||
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
|
import type { Component, Snippet } from 'svelte';
|
||||||
|
import type { AgenticSection, BuiltinToolUiEntry } from '$lib/utils';
|
||||||
|
|
||||||
|
type ToolCallBlockMetaWithError = TMeta & { errorMessage?: string };
|
||||||
|
|
||||||
|
interface ToolCallCtx {
|
||||||
|
isStreaming: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
isStreamingCall: boolean;
|
||||||
|
isCodeStreaming: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
/**
|
||||||
|
* The per-tool meta, including any `errorMessage` field that the
|
||||||
|
* shared chrome uses to compute the status pill subtitle.
|
||||||
|
*/
|
||||||
|
meta: ToolCallBlockMetaWithError | null | undefined;
|
||||||
|
/**
|
||||||
|
* True while the tool's process is actively producing output
|
||||||
|
* chunks after its args finished streaming (used by
|
||||||
|
* `exec_shell_command`'s stdout feed).
|
||||||
|
*/
|
||||||
|
extraLiveStreaming?: boolean;
|
||||||
|
/**
|
||||||
|
* Swap the title-row icon for a spinning `Loader2` while the
|
||||||
|
* spinner is showing. Only meaningful for tools where "live"
|
||||||
|
* is interesting (e.g. exec_shell_command showing the in-flight
|
||||||
|
* process). Other tools leave it off and render the spinner
|
||||||
|
* inline within the body.
|
||||||
|
*/
|
||||||
|
spinIconWhenActive?: boolean;
|
||||||
|
/**
|
||||||
|
* Wrapper component that renders the title row and the body
|
||||||
|
* children. Defaults to CollapsibleContentBlock;
|
||||||
|
* `exec_shell_command` uses CollapsibleTerminalBlock for its
|
||||||
|
* terminal-style frame.
|
||||||
|
*/
|
||||||
|
wrapper?: typeof CollapsibleContentBlock;
|
||||||
|
title?: string;
|
||||||
|
titleSnippet?: Snippet;
|
||||||
|
onToggle?: () => void;
|
||||||
|
children: Snippet<[TMeta | null | undefined, ToolCallCtx]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
section,
|
||||||
|
open,
|
||||||
|
isStreaming,
|
||||||
|
meta,
|
||||||
|
extraLiveStreaming = false,
|
||||||
|
spinIconWhenActive = false,
|
||||||
|
wrapper: Wrapper = CollapsibleContentBlock,
|
||||||
|
title,
|
||||||
|
titleSnippet,
|
||||||
|
onToggle,
|
||||||
|
children
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
|
||||||
|
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
|
||||||
|
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming) || extraLiveStreaming);
|
||||||
|
const isCodeStreaming = $derived(isStreaming && (isPending || isStreamingCall));
|
||||||
|
|
||||||
|
const toolUi: BuiltinToolUiEntry | null = $derived(getBuiltinToolUi(section.toolName));
|
||||||
|
const toolIcon: Component = $derived(
|
||||||
|
spinIconWhenActive && showSpinner ? Loader2 : (toolUi?.icon ?? Wrench)
|
||||||
|
);
|
||||||
|
const toolIconClass = $derived(
|
||||||
|
spinIconWhenActive && showSpinner ? ICON_CLASS_SPIN : ICON_CLASS_DEFAULT
|
||||||
|
);
|
||||||
|
// Drop the MCP favicon while the spinner is on so the title row
|
||||||
|
// signals "in flight" without being overwritten by server branding.
|
||||||
|
const mcpServerFavicon = $derived(
|
||||||
|
showSpinner ? null : mcpStore.getServerFaviconForTool(section.toolName)
|
||||||
|
);
|
||||||
|
const iconUrl = $derived(
|
||||||
|
showSpinner || (toolUi?.icon ?? null) || !mcpServerFavicon ? null : mcpServerFavicon
|
||||||
|
);
|
||||||
|
|
||||||
|
function subtitleFor(errorMessage?: string): string | undefined {
|
||||||
|
if (extraLiveStreaming) return 'streaming...';
|
||||||
|
if (showSpinner) return 'executing...';
|
||||||
|
if (errorMessage) return 'failed';
|
||||||
|
if (isStreamingCall && !isStreaming) return 'incomplete';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitle = $derived(subtitleFor(meta?.errorMessage));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Wrapper
|
||||||
|
{open}
|
||||||
|
class="my-2"
|
||||||
|
icon={toolIcon}
|
||||||
|
iconClass={toolIconClass}
|
||||||
|
{iconUrl}
|
||||||
|
{title}
|
||||||
|
{titleSnippet}
|
||||||
|
{subtitle}
|
||||||
|
{onToggle}
|
||||||
|
>
|
||||||
|
{@render children(meta, {
|
||||||
|
isStreaming,
|
||||||
|
isPending,
|
||||||
|
isStreamingCall,
|
||||||
|
isCodeStreaming
|
||||||
|
})}
|
||||||
|
</Wrapper>
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
// Helpers shared by the per-tool meta parsers under
|
||||||
|
// `src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/`.
|
||||||
|
// Each tool needs the same first three steps (tool-name check,
|
||||||
|
// args-present check, JSON parse) - keeping them here lets each parser
|
||||||
|
// stay focused on its own format quirks.
|
||||||
|
|
||||||
|
import { BuiltInTool } from '$lib/enums';
|
||||||
|
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
|
||||||
|
import type { AgenticSection } from '$lib/utils/agentic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict (final-state) JSON parser for a tool-args blob. Mirrors the
|
||||||
|
* behaviour the per-tool components used before extraction: an
|
||||||
|
* invalid JSON blob, a JSON array, or a JSON primitive all map to
|
||||||
|
* `null` so callers don't have to guard against surprise shapes.
|
||||||
|
*/
|
||||||
|
function parseFinalToolArgs(blob: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(blob);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a section's toolArgs against an expected tool name. Returns
|
||||||
|
* `null` when:
|
||||||
|
* - the section's toolName doesn't match (component isn't for this
|
||||||
|
* tool);
|
||||||
|
* - the section has no args yet (call hasn't started streaming);
|
||||||
|
* - or the args blob can't be parsed.
|
||||||
|
*
|
||||||
|
* Pass `{ partial: true }` for tools that need to render incrementally
|
||||||
|
* as each token lands (read_file, edit_file, write_file).
|
||||||
|
*/
|
||||||
|
export function parseToolArgs(
|
||||||
|
expected: BuiltInTool,
|
||||||
|
section: AgenticSection,
|
||||||
|
options: { partial?: boolean } = {}
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (section.toolName !== expected || !section.toolArgs) return null;
|
||||||
|
return options.partial
|
||||||
|
? parsePartialJsonArgs(section.toolArgs)
|
||||||
|
: parseFinalToolArgs(section.toolArgs);
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
// Meta parser for `edit_file` tool calls. Reads the file path and the
|
||||||
|
// array of edits from the streamed args (partial JSON for incremental
|
||||||
|
// rendering), plus the result blob for `result` / `edits_applied` /
|
||||||
|
// `error` fields.
|
||||||
|
|
||||||
|
import { BuiltInTool } from '$lib/enums';
|
||||||
|
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
|
||||||
|
import { tryParseToolResultObject, type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseToolArgs } from './_shared';
|
||||||
|
|
||||||
|
export type EditFileEdit = {
|
||||||
|
oldText: string;
|
||||||
|
newText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditFileMeta = {
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
edits: EditFileEdit[];
|
||||||
|
resultMessage?: string;
|
||||||
|
editsApplied?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null {
|
||||||
|
const args = parseToolArgs(BuiltInTool.EDIT_FILE, section, { partial: true });
|
||||||
|
if (!args) return null;
|
||||||
|
|
||||||
|
const rawPath = args.path ?? args.file_path ?? args.filePath;
|
||||||
|
if (typeof rawPath !== 'string' || !rawPath) return null;
|
||||||
|
|
||||||
|
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
|
||||||
|
|
||||||
|
// Filter the streamed edits array strictly: each entry must be an
|
||||||
|
// object with a non-empty `old_text`. Edits without an old_text
|
||||||
|
// would diff against empty and render as a full re-write.
|
||||||
|
const rawEdits = Array.isArray(args.edits) ? args.edits : [];
|
||||||
|
const edits: EditFileEdit[] = [];
|
||||||
|
for (const e of rawEdits) {
|
||||||
|
if (!e || typeof e !== 'object' || Array.isArray(e)) continue;
|
||||||
|
const obj = e as Record<string, unknown>;
|
||||||
|
const oldText = typeof obj.old_text === 'string' ? obj.old_text : '';
|
||||||
|
if (!oldText) continue;
|
||||||
|
const newText = typeof obj.new_text === 'string' ? obj.new_text : '';
|
||||||
|
edits.push({ oldText, newText });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultObj = tryParseToolResultObject(section.toolResult);
|
||||||
|
let resultMessage: string | undefined;
|
||||||
|
let editsApplied: number | undefined;
|
||||||
|
let errorMessage: string | undefined;
|
||||||
|
if (typeof resultObj?.error === 'string') {
|
||||||
|
errorMessage = resultObj.error;
|
||||||
|
} else if (resultObj) {
|
||||||
|
if (typeof resultObj.result === 'string') {
|
||||||
|
resultMessage = resultObj.result;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(Number(resultObj.edits_applied))) {
|
||||||
|
editsApplied = Number(resultObj.edits_applied);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
filePath: rawPath,
|
||||||
|
edits,
|
||||||
|
resultMessage,
|
||||||
|
editsApplied,
|
||||||
|
errorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
// Meta parser for `exec_shell_command` tool calls. Surfaces the
|
||||||
|
// command text from args `command` / `cmd` / `shell_command` aliases.
|
||||||
|
// The exit-status and error parsing live in their own utilities
|
||||||
|
// (`parse-exec-shell-status.ts` / `parse-exec-shell-error.ts`) - this
|
||||||
|
// file only deals with what's strictly about *calling* the tool, since
|
||||||
|
// the error / exit status elide from call-section to result-section.
|
||||||
|
|
||||||
|
import { BuiltInTool } from '$lib/enums';
|
||||||
|
import type { AgenticSection } from '$lib/utils';
|
||||||
|
import { parseToolArgs } from './_shared';
|
||||||
|
|
||||||
|
export type ExecShellCommandMeta = {
|
||||||
|
command: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseExecShellCommandMeta(section: AgenticSection): ExecShellCommandMeta | null {
|
||||||
|
const args = parseToolArgs(BuiltInTool.EXEC_SHELL_COMMAND, section);
|
||||||
|
if (!args) return null;
|
||||||
|
|
||||||
|
const commandRaw = args.command ?? args.cmd ?? args.shell_command;
|
||||||
|
if (typeof commandRaw !== 'string' || !commandRaw) return null;
|
||||||
|
return { command: commandRaw };
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
// Meta parser for `file_glob_search` tool calls. Reads the path,
|
||||||
|
// include pattern, and optional exclude from the args (strict parsing)
|
||||||
|
// and the matches from the result blob. Like grep_search, the result
|
||||||
|
// parser keeps the original raw-text fallback for MCP servers that
|
||||||
|
// emit unparseable output.
|
||||||
|
|
||||||
|
import { BuiltInTool } from '$lib/enums';
|
||||||
|
import { splitSearchSummaryList, type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseToolArgs } from './_shared';
|
||||||
|
|
||||||
|
export type FileGlobSearchMeta = {
|
||||||
|
path: string;
|
||||||
|
include: string;
|
||||||
|
exclude?: string;
|
||||||
|
matches: string[];
|
||||||
|
totalMatches?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearchMeta | null {
|
||||||
|
const args = parseToolArgs(BuiltInTool.FILE_GLOB_SEARCH, section);
|
||||||
|
if (!args) return null;
|
||||||
|
|
||||||
|
const path = typeof args.path === 'string' ? args.path : '';
|
||||||
|
const include = typeof args.include === 'string' && args.include ? args.include : '**';
|
||||||
|
const exclude = typeof args.exclude === 'string' && args.exclude ? args.exclude : undefined;
|
||||||
|
if (!path) return null;
|
||||||
|
|
||||||
|
let matches: string[] = [];
|
||||||
|
let totalMatches: number | undefined;
|
||||||
|
let errorMessage: string | undefined;
|
||||||
|
|
||||||
|
const toolResultString = section.toolResult;
|
||||||
|
if (toolResultString) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(toolResultString);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
if (typeof obj.error === 'string') {
|
||||||
|
errorMessage = obj.error;
|
||||||
|
} else if (typeof obj.plain_text_response === 'string') {
|
||||||
|
const split = splitSearchSummaryList(obj.plain_text_response, (total) => {
|
||||||
|
totalMatches = total;
|
||||||
|
});
|
||||||
|
matches = split.lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// See grep-search.ts: same fallback used there.
|
||||||
|
const split = splitSearchSummaryList(toolResultString, (total) => {
|
||||||
|
totalMatches = total;
|
||||||
|
});
|
||||||
|
matches = split.lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path, include, exclude, matches, totalMatches, errorMessage };
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
// Meta parser for `grep_search` tool calls. Reads the path/pattern
|
||||||
|
// triplet from args (strict parsing - we wait for the args to
|
||||||
|
// complete) and the matches from the result blob. The result parser
|
||||||
|
// keeps the original "scan result as raw text on JSON.parse failure"
|
||||||
|
// fallback so MCP servers that return unparseable output still get
|
||||||
|
// surfaced.
|
||||||
|
|
||||||
|
import { BuiltInTool } from '$lib/enums';
|
||||||
|
import { splitSearchSummaryList, type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseToolArgs } from './_shared';
|
||||||
|
|
||||||
|
export type GrepSearchMatch = {
|
||||||
|
file: string;
|
||||||
|
line?: number;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GrepSearchMeta = {
|
||||||
|
path: string;
|
||||||
|
pattern: string;
|
||||||
|
include: string;
|
||||||
|
exclude?: string;
|
||||||
|
showLineNumbers: boolean;
|
||||||
|
matches: GrepSearchMatch[];
|
||||||
|
totalMatches?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | null {
|
||||||
|
const args = parseToolArgs(BuiltInTool.GREP_SEARCH, section);
|
||||||
|
if (!args) return null;
|
||||||
|
|
||||||
|
const path = typeof args.path === 'string' ? args.path : '';
|
||||||
|
const pattern = typeof args.pattern === 'string' ? args.pattern : '';
|
||||||
|
if (!path || !pattern) return null;
|
||||||
|
|
||||||
|
const include = typeof args.include === 'string' && args.include ? args.include : '**';
|
||||||
|
const exclude = typeof args.exclude === 'string' && args.exclude ? args.exclude : undefined;
|
||||||
|
const showLineNumbers = args.return_line_numbers === true;
|
||||||
|
|
||||||
|
let matches: GrepSearchMatch[] = [];
|
||||||
|
let totalMatches: number | undefined;
|
||||||
|
let errorMessage: string | undefined;
|
||||||
|
|
||||||
|
const toolResultString = section.toolResult;
|
||||||
|
if (toolResultString) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(toolResultString);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
if (typeof obj.error === 'string') {
|
||||||
|
errorMessage = obj.error;
|
||||||
|
} else if (typeof obj.plain_text_response === 'string') {
|
||||||
|
const split = splitSearchSummaryList(obj.plain_text_response, (total) => {
|
||||||
|
totalMatches = total;
|
||||||
|
});
|
||||||
|
matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Result wasn't JSON: keep behaviour for MCP servers that
|
||||||
|
// emit raw text and treat each line as a `<file>:<content>`
|
||||||
|
// (or `<file>:<line>:<content>`) match.
|
||||||
|
const split = splitSearchSummaryList(toolResultString, (total) => {
|
||||||
|
totalMatches = total;
|
||||||
|
});
|
||||||
|
matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
pattern,
|
||||||
|
include,
|
||||||
|
exclude,
|
||||||
|
showLineNumbers,
|
||||||
|
matches,
|
||||||
|
totalMatches,
|
||||||
|
errorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGrepLine(line: string, showLineNumbers: boolean): GrepSearchMatch {
|
||||||
|
// Server output:
|
||||||
|
// <file>:<content> when return_line_numbers=false
|
||||||
|
// <file>:<lineno>:<content> when return_line_numbers=true
|
||||||
|
const firstColon = line.indexOf(':');
|
||||||
|
if (firstColon === -1) {
|
||||||
|
return { file: line, content: '' };
|
||||||
|
}
|
||||||
|
const file = line.slice(0, firstColon);
|
||||||
|
const tail = line.slice(firstColon + 1);
|
||||||
|
|
||||||
|
if (!showLineNumbers) {
|
||||||
|
return { file, content: tail };
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondColon = tail.indexOf(':');
|
||||||
|
if (secondColon === -1) {
|
||||||
|
return { file, content: tail };
|
||||||
|
}
|
||||||
|
const lineNum = parseInt(tail.slice(0, secondColon), 10);
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
line: Number.isFinite(lineNum) ? lineNum : undefined,
|
||||||
|
content: tail.slice(secondColon + 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
// Meta parser for `read_file` tool calls. Reads the file path and an
|
||||||
|
// optional line range (either `start_line`+`end_line` or
|
||||||
|
// `start_line`+`line_count`). Args are parsed partially so a header
|
||||||
|
// can render incrementally as the file path streams in.
|
||||||
|
|
||||||
|
import { BuiltInTool } from '$lib/enums';
|
||||||
|
import {
|
||||||
|
DEFAULT_LANGUAGE,
|
||||||
|
FILE_PATH_SEPARATOR_REGEX,
|
||||||
|
TEXT_LANGUAGE_PREFIX_REGEX
|
||||||
|
} from '$lib/constants';
|
||||||
|
import { getFileTypeByExtension, type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseToolArgs } from './_shared';
|
||||||
|
|
||||||
|
export type ReadFileMeta = {
|
||||||
|
fileName: string;
|
||||||
|
lineRange: { start: number; end: number } | null;
|
||||||
|
language: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null {
|
||||||
|
const args = parseToolArgs(BuiltInTool.READ_FILE, section, { partial: true });
|
||||||
|
if (!args) return null;
|
||||||
|
|
||||||
|
const rawPath = args.path ?? args.file_path ?? args.filePath;
|
||||||
|
if (typeof rawPath !== 'string' || !rawPath) return null;
|
||||||
|
|
||||||
|
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
|
||||||
|
|
||||||
|
// Models emit range arguments under several aliases. Accept all to
|
||||||
|
// stay forgiving across prompt variations.
|
||||||
|
const startRaw = args.start_line ?? args.line_start ?? args.startLine ?? args.from_line;
|
||||||
|
const endRaw = args.end_line ?? args.line_end ?? args.endLine ?? args.to_line;
|
||||||
|
const countRaw = args.line_count ?? args.count ?? args.num_lines;
|
||||||
|
|
||||||
|
let lineRange: { start: number; end: number } | null = null;
|
||||||
|
const sNum = Number(startRaw);
|
||||||
|
const eNum = Number(endRaw);
|
||||||
|
if (startRaw != null && endRaw != null && Number.isFinite(sNum) && Number.isFinite(eNum)) {
|
||||||
|
lineRange = { start: sNum, end: eNum };
|
||||||
|
} else if (startRaw != null && countRaw != null) {
|
||||||
|
const cNum = Number(countRaw);
|
||||||
|
if (Number.isFinite(sNum) && Number.isFinite(cNum)) {
|
||||||
|
lineRange = { start: sNum, end: sNum + cNum - 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileType = getFileTypeByExtension(fileName);
|
||||||
|
const language = fileType ? fileType.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') : DEFAULT_LANGUAGE;
|
||||||
|
|
||||||
|
return { fileName, lineRange, language };
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
// Meta parser for `run_javascript` tool calls. Reads the JS code and
|
||||||
|
// optional timeout from args (strict parsing) and surfaces any error
|
||||||
|
// from the result blob. SandboxService.formatReply emits a JSON object
|
||||||
|
// containing an `error` field on failure, but a partial/non-JSON
|
||||||
|
// failure renders as a flat line beginning with `Error:`. Both shapes
|
||||||
|
// are handled.
|
||||||
|
|
||||||
|
import { BuiltInTool } from '$lib/enums';
|
||||||
|
import type { AgenticSection } from '$lib/utils';
|
||||||
|
import { parseToolArgs } from './_shared';
|
||||||
|
|
||||||
|
export type RunJavascriptMeta = {
|
||||||
|
code: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMeta | null {
|
||||||
|
const args = parseToolArgs(BuiltInTool.RUN_JAVASCRIPT, section);
|
||||||
|
if (!args) return null;
|
||||||
|
|
||||||
|
const code = typeof args.code === 'string' ? args.code : '';
|
||||||
|
if (!code) return null;
|
||||||
|
|
||||||
|
const timeoutRaw = Number(args.timeout_ms);
|
||||||
|
const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : undefined;
|
||||||
|
|
||||||
|
let errorMessage: string | undefined;
|
||||||
|
const toolResultString = section.toolResult;
|
||||||
|
if (toolResultString) {
|
||||||
|
// Branches matter here: a JSON object can carry `error`, but a
|
||||||
|
// JSON array always represents successful output (sandbox returns
|
||||||
|
// the array of values). Only when the result isn't a JSON object
|
||||||
|
// do we scan raw lines for the `Error:` prefix.
|
||||||
|
let parsedObject: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(toolResultString);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
parsedObject = parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
parsedObject = null;
|
||||||
|
}
|
||||||
|
if (typeof parsedObject?.error === 'string') {
|
||||||
|
errorMessage = parsedObject.error;
|
||||||
|
} else if (!parsedObject) {
|
||||||
|
const errorLine = toolResultString
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.startsWith('Error:'));
|
||||||
|
if (errorLine) errorMessage = errorLine.slice('Error:'.length).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code, timeoutMs, errorMessage };
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
// Meta parser for `write_file` tool calls. Reads the path/content from
|
||||||
|
// the streamed args (partial JSON so we can render before the call
|
||||||
|
// finishes) and surfaces `bytes`, `result`, and `error` from the
|
||||||
|
// result blob.
|
||||||
|
|
||||||
|
import { BuiltInTool } from '$lib/enums';
|
||||||
|
import {
|
||||||
|
DEFAULT_LANGUAGE,
|
||||||
|
FILE_PATH_SEPARATOR_REGEX,
|
||||||
|
TEXT_LANGUAGE_PREFIX_REGEX
|
||||||
|
} from '$lib/constants';
|
||||||
|
import { getFileTypeByExtension, tryParseToolResultObject, type AgenticSection } from '$lib/utils';
|
||||||
|
import { parseToolArgs } from './_shared';
|
||||||
|
|
||||||
|
export type WriteFileMeta = {
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
language: string;
|
||||||
|
content: string;
|
||||||
|
bytesWritten?: number;
|
||||||
|
resultMessage?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
|
||||||
|
const args = parseToolArgs(BuiltInTool.WRITE_FILE, section, { partial: true });
|
||||||
|
if (!args) return null;
|
||||||
|
|
||||||
|
// Tool contracts drifted over time: some models emit `path`,
|
||||||
|
// others `file_path` / `filePath`. Accept all three.
|
||||||
|
const rawPath = args.path ?? args.file_path ?? args.filePath;
|
||||||
|
if (typeof rawPath !== 'string' || !rawPath) return null;
|
||||||
|
|
||||||
|
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
|
||||||
|
const content = typeof args.content === 'string' ? args.content : '';
|
||||||
|
const language =
|
||||||
|
getFileTypeByExtension(rawPath)?.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') ?? DEFAULT_LANGUAGE;
|
||||||
|
|
||||||
|
const resultObj = tryParseToolResultObject(section.toolResult);
|
||||||
|
const bytesWritten =
|
||||||
|
resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined;
|
||||||
|
const resultMessage = typeof resultObj?.result === 'string' ? resultObj.result : undefined;
|
||||||
|
const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
filePath: rawPath,
|
||||||
|
language,
|
||||||
|
content,
|
||||||
|
bytesWritten,
|
||||||
|
resultMessage,
|
||||||
|
errorMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import type { Snippet, Component } from 'svelte';
|
import type { Snippet, Component } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
|
|
||||||
<div class="my-2 rounded-lg border border-border bg-card p-3">
|
<div class="my-2 rounded-lg border border-border bg-card p-3">
|
||||||
<div class="mb-3 flex items-center gap-2 text-sm">
|
<div class="mb-3 flex items-center gap-2 text-sm">
|
||||||
<IconComponent class="h-4 w-4 shrink-0 text-muted-foreground" />
|
<IconComponent class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||||
<span>
|
<span>
|
||||||
{@render message()}
|
{@render message()}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+58
-196
@@ -1,40 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Wrench, Loader2, Brain } from '@lucide/svelte';
|
|
||||||
import {
|
import {
|
||||||
ChatMessageStatistics,
|
ChatMessageStatistics,
|
||||||
CollapsibleContentBlock,
|
|
||||||
MarkdownContent,
|
MarkdownContent,
|
||||||
SyntaxHighlightedCode,
|
|
||||||
ChatMessageActionCardPermissionRequest,
|
ChatMessageActionCardPermissionRequest,
|
||||||
ChatMessageActionCardContinueRequest
|
ChatMessageActionCardContinueRequest
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
|
|
||||||
import {
|
import { AgenticSectionType, ChatMessageStatsView, ToolPermissionDecision } from '$lib/enums';
|
||||||
AgenticSectionType,
|
|
||||||
ChatMessageStatsView,
|
|
||||||
FileTypeText,
|
|
||||||
ToolPermissionDecision
|
|
||||||
} from '$lib/enums';
|
|
||||||
import type {
|
import type {
|
||||||
ChatMessageAgenticTimings,
|
ChatMessageAgenticTimings,
|
||||||
ChatMessageAgenticTurnStats,
|
ChatMessageAgenticTurnStats,
|
||||||
DatabaseMessage
|
DatabaseMessage
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
import {
|
import { deriveAgenticSections, type AgenticSection } from '$lib/utils';
|
||||||
deriveAgenticSections,
|
|
||||||
formatJsonPretty,
|
|
||||||
parseToolResultWithImages,
|
|
||||||
type AgenticSection,
|
|
||||||
type ToolResultLine
|
|
||||||
} from '$lib/utils';
|
|
||||||
import {
|
import {
|
||||||
agenticPendingPermissionRequest,
|
agenticPendingPermissionRequest,
|
||||||
agenticResolvePermission,
|
agenticResolvePermission,
|
||||||
agenticPendingContinueRequest,
|
agenticPendingContinueRequest,
|
||||||
agenticResolveContinue,
|
agenticResolveContinue,
|
||||||
agenticLastError
|
agenticLastError,
|
||||||
|
agenticExecutingToolCallId
|
||||||
} from '$lib/stores/agentic.svelte';
|
} from '$lib/stores/agentic.svelte';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
|
import ChatMessageReasoningBlock from './ChatMessageReasoningBlock.svelte';
|
||||||
|
import ChatMessageToolCallBlock from './ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: DatabaseMessage;
|
message: DatabaseMessage;
|
||||||
@@ -52,10 +41,10 @@
|
|||||||
|
|
||||||
let expandedStates: Record<number, boolean> = $state({});
|
let expandedStates: Record<number, boolean> = $state({});
|
||||||
|
|
||||||
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
|
|
||||||
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
|
|
||||||
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
|
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
|
||||||
const showMessageStats = $derived(config().showMessageStats as boolean);
|
const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress));
|
||||||
|
const showMessageStats = $derived(Boolean(config().showMessageStats));
|
||||||
|
const showAgenticTurnStats = $derived(showMessageStats && Boolean(config().showAgenticTurnStats));
|
||||||
|
|
||||||
const hasReasoningError = $derived(
|
const hasReasoningError = $derived(
|
||||||
isLastAssistantMessage ? !!agenticLastError(message.convId) : false
|
isLastAssistantMessage ? !!agenticLastError(message.convId) : false
|
||||||
@@ -67,7 +56,6 @@
|
|||||||
isStreaming && isLastAssistantMessage ? agenticPendingPermissionRequest(message.convId) : null
|
isStreaming && isLastAssistantMessage ? agenticPendingPermissionRequest(message.convId) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset dismissed when pendingPermission changes (new request or cleared)
|
|
||||||
let prevPendingRef: typeof pendingPermission = null;
|
let prevPendingRef: typeof pendingPermission = null;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (pendingPermission !== prevPendingRef) {
|
if (pendingPermission !== prevPendingRef) {
|
||||||
@@ -106,33 +94,43 @@
|
|||||||
|
|
||||||
const sections = $derived(deriveAgenticSections(message, toolMessages, [], isStreaming));
|
const sections = $derived(deriveAgenticSections(message, toolMessages, [], isStreaming));
|
||||||
|
|
||||||
// Parse tool results with images
|
const currentlyExecutingToolCallId = $derived(
|
||||||
const sectionsParsed = $derived(
|
isStreaming ? agenticExecutingToolCallId(message.convId) : null
|
||||||
sections.map((section) => ({
|
|
||||||
...section,
|
|
||||||
parsedLines: section.toolResult
|
|
||||||
? parseToolResultWithImages(section.toolResult, section.toolResultExtras || message?.extra)
|
|
||||||
: ([] as ToolResultLine[])
|
|
||||||
}))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group flat sections into agentic turns
|
// Skip sections the user manually collapsed - we never override an explicit false.
|
||||||
// A new turn starts when a non-tool section follows a tool section
|
let lastSeenExecutingToolCallId: string | null = null;
|
||||||
const turnGroups = $derived.by(() => {
|
$effect(() => {
|
||||||
const turns: { sections: (typeof sectionsParsed)[number][]; flatIndices: number[] }[] = [];
|
const current = currentlyExecutingToolCallId;
|
||||||
let currentTurn: (typeof sectionsParsed)[number][] = [];
|
const previous = lastSeenExecutingToolCallId;
|
||||||
|
lastSeenExecutingToolCallId = current;
|
||||||
|
if (!current || current === previous) return;
|
||||||
|
const idx = sections.findIndex((s) => s.toolCallId === current);
|
||||||
|
if (idx >= 0 && expandedStates[idx] === undefined) {
|
||||||
|
expandedStates[idx] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type TurnGroup = {
|
||||||
|
sections: AgenticSection[];
|
||||||
|
flatIndices: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const turnGroups: TurnGroup[] = $derived.by(() => {
|
||||||
|
const groups: TurnGroup[] = [];
|
||||||
|
let currentTurn: AgenticSection[] = [];
|
||||||
let currentIndices: number[] = [];
|
let currentIndices: number[] = [];
|
||||||
let prevWasTool = false;
|
let prevWasTool = false;
|
||||||
|
|
||||||
for (let i = 0; i < sectionsParsed.length; i++) {
|
for (let i = 0; i < sections.length; i++) {
|
||||||
const section = sectionsParsed[i];
|
const section = sections[i];
|
||||||
const isTool =
|
const isTool =
|
||||||
section.type === AgenticSectionType.TOOL_CALL ||
|
section.type === AgenticSectionType.TOOL_CALL ||
|
||||||
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
|
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
|
||||||
section.type === AgenticSectionType.TOOL_CALL_STREAMING;
|
section.type === AgenticSectionType.TOOL_CALL_STREAMING;
|
||||||
|
|
||||||
if (!isTool && prevWasTool && currentTurn.length > 0) {
|
if (!isTool && prevWasTool && currentTurn.length > 0) {
|
||||||
turns.push({ sections: currentTurn, flatIndices: currentIndices });
|
groups.push({ sections: currentTurn, flatIndices: currentIndices });
|
||||||
currentTurn = [];
|
currentTurn = [];
|
||||||
currentIndices = [];
|
currentIndices = [];
|
||||||
}
|
}
|
||||||
@@ -143,10 +141,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentTurn.length > 0) {
|
if (currentTurn.length > 0) {
|
||||||
turns.push({ sections: currentTurn, flatIndices: currentIndices });
|
groups.push({ sections: currentTurn, flatIndices: currentIndices });
|
||||||
}
|
}
|
||||||
|
|
||||||
return turns;
|
return groups;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getDefaultExpanded(section: AgenticSection): boolean {
|
function getDefaultExpanded(section: AgenticSection): boolean {
|
||||||
@@ -154,7 +152,7 @@
|
|||||||
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
|
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
|
||||||
section.type === AgenticSectionType.TOOL_CALL_STREAMING
|
section.type === AgenticSectionType.TOOL_CALL_STREAMING
|
||||||
) {
|
) {
|
||||||
return showToolCallInProgress;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (section.type === AgenticSectionType.REASONING_PENDING) {
|
if (section.type === AgenticSectionType.REASONING_PENDING) {
|
||||||
@@ -189,181 +187,46 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet renderSection(section: (typeof sectionsParsed)[number], index: number)}
|
{#snippet renderSection(section: AgenticSection, index: number)}
|
||||||
{#if section.type === AgenticSectionType.TEXT}
|
{#if section.type === AgenticSectionType.TEXT}
|
||||||
<div class="agentic-text">
|
<div class="agentic-text">
|
||||||
<MarkdownContent content={section.content} attachments={message?.extra} />
|
<MarkdownContent content={section.content} attachments={message?.extra} />
|
||||||
</div>
|
</div>
|
||||||
{:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING}
|
{:else if section.type === AgenticSectionType.REASONING || section.type === AgenticSectionType.REASONING_PENDING}
|
||||||
{@const streamingIcon = isStreaming ? Loader2 : Loader2}
|
<ChatMessageReasoningBlock
|
||||||
{@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
|
{section}
|
||||||
|
|
||||||
<CollapsibleContentBlock
|
|
||||||
open={isExpanded(index, section)}
|
open={isExpanded(index, section)}
|
||||||
class="my-2"
|
|
||||||
icon={streamingIcon}
|
|
||||||
iconClass={streamingIconClass}
|
|
||||||
title={section.toolName || 'Tool call'}
|
|
||||||
subtitle={isStreaming ? '' : 'incomplete'}
|
|
||||||
{isStreaming}
|
{isStreaming}
|
||||||
|
{renderThinkingAsMarkdown}
|
||||||
|
{hasReasoningError}
|
||||||
|
attachments={message?.extra}
|
||||||
onToggle={() => toggleExpanded(index, section)}
|
onToggle={() => toggleExpanded(index, section)}
|
||||||
>
|
/>
|
||||||
<div class="pt-3">
|
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING || section.type === AgenticSectionType.TOOL_CALL_STREAMING}
|
||||||
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
|
<ChatMessageToolCallBlock
|
||||||
<span>Arguments:</span>
|
{section}
|
||||||
|
|
||||||
{#if isStreaming}
|
|
||||||
<Loader2 class="h-3 w-3 animate-spin" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if section.toolArgs}
|
|
||||||
<SyntaxHighlightedCode
|
|
||||||
code={formatJsonPretty(section.toolArgs)}
|
|
||||||
language={FileTypeText.JSON}
|
|
||||||
maxHeight="20rem"
|
|
||||||
class="text-xs"
|
|
||||||
/>
|
|
||||||
{:else if isStreaming}
|
|
||||||
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
|
|
||||||
Receiving arguments...
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
|
|
||||||
>
|
|
||||||
Response was truncated
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContentBlock>
|
|
||||||
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
|
||||||
{@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
|
||||||
{@const toolIcon = isPending ? Loader2 : Wrench}
|
|
||||||
{@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
|
|
||||||
|
|
||||||
<CollapsibleContentBlock
|
|
||||||
open={isExpanded(index, section)}
|
open={isExpanded(index, section)}
|
||||||
class="my-2"
|
|
||||||
icon={toolIcon}
|
|
||||||
iconClass={toolIconClass}
|
|
||||||
title={section.toolName || ''}
|
|
||||||
subtitle={isPending ? 'executing...' : undefined}
|
|
||||||
isStreaming={isPending}
|
|
||||||
onToggle={() => toggleExpanded(index, section)}
|
|
||||||
>
|
|
||||||
{#if section.toolArgs && section.toolArgs !== '{}'}
|
|
||||||
<div class="pt-3">
|
|
||||||
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
|
|
||||||
|
|
||||||
<SyntaxHighlightedCode
|
|
||||||
code={formatJsonPretty(section.toolArgs)}
|
|
||||||
language={FileTypeText.JSON}
|
|
||||||
maxHeight="20rem"
|
|
||||||
class="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="pt-3">
|
|
||||||
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>Result:</span>
|
|
||||||
|
|
||||||
{#if isPending}
|
|
||||||
<Loader2 class="h-3 w-3 animate-spin" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if isPending}
|
|
||||||
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
|
|
||||||
Waiting for result...
|
|
||||||
</div>
|
|
||||||
{:else if section.toolResult}
|
|
||||||
<div class="overflow-auto rounded-lg border border-border bg-muted p-4">
|
|
||||||
{#each section.parsedLines as line, i (i)}
|
|
||||||
<div class="font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
|
||||||
{line.text}
|
|
||||||
</div>
|
|
||||||
{#if line.image}
|
|
||||||
<img
|
|
||||||
src={line.image.base64Url}
|
|
||||||
alt={line.image.name}
|
|
||||||
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">No output</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContentBlock>
|
|
||||||
{:else if section.type === AgenticSectionType.REASONING}
|
|
||||||
{@const reasoningSubtitle = section.wasInterrupted
|
|
||||||
? hasReasoningError
|
|
||||||
? 'Error'
|
|
||||||
: 'Cancelled'
|
|
||||||
: isStreaming
|
|
||||||
? ''
|
|
||||||
: undefined}
|
|
||||||
|
|
||||||
<CollapsibleContentBlock
|
|
||||||
open={isExpanded(index, section)}
|
|
||||||
class="my-2"
|
|
||||||
icon={Brain}
|
|
||||||
title="Reasoning"
|
|
||||||
subtitle={reasoningSubtitle}
|
|
||||||
rawContent={section.content}
|
|
||||||
onToggle={() => toggleExpanded(index, section)}
|
|
||||||
>
|
|
||||||
<div class="pt-3">
|
|
||||||
{#if renderThinkingAsMarkdown}
|
|
||||||
<MarkdownContent content={section.content} attachments={message?.extra} />
|
|
||||||
{:else}
|
|
||||||
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
|
|
||||||
{section.content}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContentBlock>
|
|
||||||
{:else if section.type === AgenticSectionType.REASONING_PENDING}
|
|
||||||
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
|
|
||||||
{@const reasoningSubtitle = isStreaming ? '' : hasReasoningError ? 'Error' : 'Cancelled'}
|
|
||||||
|
|
||||||
<CollapsibleContentBlock
|
|
||||||
open={isExpanded(index, section)}
|
|
||||||
class="my-2"
|
|
||||||
icon={Brain}
|
|
||||||
title={reasoningTitle}
|
|
||||||
subtitle={reasoningSubtitle}
|
|
||||||
rawContent={section.content}
|
|
||||||
{isStreaming}
|
{isStreaming}
|
||||||
|
isExecuting={section.toolCallId !== undefined &&
|
||||||
|
section.toolCallId === currentlyExecutingToolCallId}
|
||||||
|
attachments={message?.extra}
|
||||||
onToggle={() => toggleExpanded(index, section)}
|
onToggle={() => toggleExpanded(index, section)}
|
||||||
>
|
/>
|
||||||
<div class="pt-3">
|
|
||||||
{#if renderThinkingAsMarkdown}
|
|
||||||
<MarkdownContent content={section.content} attachments={message?.extra} />
|
|
||||||
{:else}
|
|
||||||
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
|
|
||||||
{section.content}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContentBlock>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="agentic-content">
|
<div class="agentic-content gap-2">
|
||||||
{#if turnGroups.length > 1}
|
{#if turnGroups.length > 1}
|
||||||
{#each turnGroups as turn, turnIndex (turnIndex)}
|
{#each turnGroups as turn, turnIndex (turnIndex)}
|
||||||
{@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
|
{@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
|
||||||
|
|
||||||
<div class="agentic-turn group/turn grid gap-3 mb-4">
|
<div class="agentic-turn group/turn grid gap-2">
|
||||||
{#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
|
{#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
|
||||||
{@render renderSection(section, turn.flatIndices[sIdx])}
|
{@render renderSection(section, turn.flatIndices[sIdx])}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if turnStats && showMessageStats}
|
{#if turnStats && showAgenticTurnStats}
|
||||||
<div class="turn-stats transition-opacity duration-150">
|
<div class="turn-stats transition-opacity duration-150 mt-1 mb-4">
|
||||||
<ChatMessageStatistics
|
<ChatMessageStatistics
|
||||||
promptTokens={turnStats.llm.prompt_n}
|
promptTokens={turnStats.llm.prompt_n}
|
||||||
promptMs={turnStats.llm.prompt_ms}
|
promptMs={turnStats.llm.prompt_ms}
|
||||||
@@ -380,7 +243,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each sectionsParsed as section, index (index)}
|
{#each sections as section, index (index)}
|
||||||
{@render renderSection(section, index)}
|
{@render renderSection(section, index)}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -404,7 +267,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 48rem;
|
max-width: 48rem;
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.agentic-content > :global(*),
|
.agentic-content > :global(*),
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Lightbulb } from '@lucide/svelte';
|
||||||
|
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
|
||||||
|
import { AgenticSectionType } from '$lib/enums';
|
||||||
|
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
|
||||||
|
import type { DatabaseMessageExtra } from '$lib/types';
|
||||||
|
import type { AgenticSection } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: AgenticSection;
|
||||||
|
open: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
renderThinkingAsMarkdown: boolean;
|
||||||
|
hasReasoningError?: boolean;
|
||||||
|
attachments?: DatabaseMessageExtra[];
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
section,
|
||||||
|
open,
|
||||||
|
isStreaming,
|
||||||
|
renderThinkingAsMarkdown,
|
||||||
|
hasReasoningError = false,
|
||||||
|
attachments,
|
||||||
|
onToggle
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const REASONING_HEADER = 'Reasoning';
|
||||||
|
const REASONING_HEADER_PENDING = 'Reasoning...';
|
||||||
|
const REASONING_SUBTITLE_ERROR = 'Error';
|
||||||
|
const REASONING_SUBTITLE_CANCELLED = 'Cancelled';
|
||||||
|
|
||||||
|
const isPending = $derived(section.type === AgenticSectionType.REASONING_PENDING);
|
||||||
|
const title = $derived(isPending && isStreaming ? REASONING_HEADER_PENDING : REASONING_HEADER);
|
||||||
|
const subtitle = $derived.by(() => {
|
||||||
|
if (isPending && !isStreaming) {
|
||||||
|
return hasReasoningError ? REASONING_SUBTITLE_ERROR : REASONING_SUBTITLE_CANCELLED;
|
||||||
|
}
|
||||||
|
if (section.wasInterrupted) {
|
||||||
|
return hasReasoningError ? REASONING_SUBTITLE_ERROR : REASONING_SUBTITLE_CANCELLED;
|
||||||
|
}
|
||||||
|
return isStreaming ? '' : undefined;
|
||||||
|
});
|
||||||
|
const shimmerTitle = $derived(isPending && isStreaming);
|
||||||
|
|
||||||
|
let scrollEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
const SCROLL_BOTTOM_THRESHOLD_PX = REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX;
|
||||||
|
|
||||||
|
let userScrolledUp = $state(false);
|
||||||
|
let lastScrollTop = 0;
|
||||||
|
let pendingFrame: number | null = null;
|
||||||
|
|
||||||
|
function isAtBottom(): boolean {
|
||||||
|
if (!scrollEl) return false;
|
||||||
|
return (
|
||||||
|
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
|
||||||
|
SCROLL_BOTTOM_THRESHOLD_PX
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottomOnFrame() {
|
||||||
|
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
|
||||||
|
pendingFrame = requestAnimationFrame(() => {
|
||||||
|
pendingFrame = null;
|
||||||
|
// User may scroll between scheduling and paint.
|
||||||
|
if (scrollEl && !userScrolledUp) {
|
||||||
|
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScrollEvent() {
|
||||||
|
if (!scrollEl) return;
|
||||||
|
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
|
||||||
|
if (isScrollingUp && !isAtBottom()) {
|
||||||
|
userScrolledUp = true;
|
||||||
|
} else if (isAtBottom()) {
|
||||||
|
userScrolledUp = false;
|
||||||
|
}
|
||||||
|
lastScrollTop = scrollEl.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void section.content;
|
||||||
|
if (!scrollEl || !isPending || !isStreaming) return;
|
||||||
|
scrollToBottomOnFrame();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Layout shifts that don't change section.content (markdown re-parse,
|
||||||
|
// syntax-highlight settle, image loads).
|
||||||
|
if (!scrollEl || !isPending || !isStreaming) return;
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => scrollToBottomOnFrame());
|
||||||
|
observer.observe(scrollEl, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Pin to bottom at the start of each round.
|
||||||
|
if (!isPending) {
|
||||||
|
userScrolledUp = false;
|
||||||
|
lastScrollTop = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsibleContentBlock
|
||||||
|
{open}
|
||||||
|
class="my-2"
|
||||||
|
icon={Lightbulb}
|
||||||
|
iconClass="h-3.5 w-3.5"
|
||||||
|
{title}
|
||||||
|
{subtitle}
|
||||||
|
{shimmerTitle}
|
||||||
|
{onToggle}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
bind:this={scrollEl}
|
||||||
|
class="reasoning-content"
|
||||||
|
class:is-streaming={isPending}
|
||||||
|
onscroll={handleScrollEvent}
|
||||||
|
>
|
||||||
|
{#if renderThinkingAsMarkdown}
|
||||||
|
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="text-[13px] leading-relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||||
|
>
|
||||||
|
{section.content}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContentBlock>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reasoning-content.is-streaming {
|
||||||
|
max-height: 28rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { ArrowDown } from '@lucide/svelte';
|
import { ArrowDown } from '@lucide/svelte';
|
||||||
import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
|
import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
ariaLabel="Scroll to bottom"
|
ariaLabel="Scroll to bottom"
|
||||||
tooltip="Scroll to bottom"
|
tooltip="Scroll to bottom"
|
||||||
size="lg"
|
size="lg"
|
||||||
iconSize="h-4 w-4"
|
iconSize={ICON_CLASS_DEFAULT}
|
||||||
class="h-9 w-9 rounded-full bg-accent text-accent-foreground absolute bottom-4 shadow-md"
|
class="h-9 w-9 rounded-full bg-accent text-accent-foreground absolute bottom-4 shadow-md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
|
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
|
||||||
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
||||||
import * as Alert from '$lib/components/ui/alert';
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
@@ -15,9 +16,9 @@
|
|||||||
>
|
>
|
||||||
<Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
|
<Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
|
||||||
{#if isLoadingModel}
|
{#if isLoadingModel}
|
||||||
<Loader2 class="h-4 w-4 animate-spin" />
|
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin" />
|
||||||
{:else}
|
{:else}
|
||||||
<AlertTriangle class="h-4 w-4" />
|
<AlertTriangle class={ICON_CLASS_DEFAULT} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Alert.Title class="flex items-center justify-between">
|
<Alert.Title class="flex items-center justify-between">
|
||||||
|
|||||||
@@ -571,6 +571,10 @@ export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessa
|
|||||||
* Handles streaming state with real-time content updates.
|
* Handles streaming state with real-time content updates.
|
||||||
*/
|
*/
|
||||||
export { default as ChatMessageAssistant } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte';
|
export { default as ChatMessageAssistant } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte';
|
||||||
|
export { default as ChatMessageAssistantModel } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte';
|
||||||
|
export { default as ChatMessageAssistantProcessingInfo } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantProcessingInfo.svelte';
|
||||||
|
export { default as ChatMessageAssistantRawOutput } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantRawOutput.svelte';
|
||||||
|
export { default as ChatMessageAssistantStatistics } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline message editing form. Provides textarea for editing message content with
|
* Inline message editing form. Provides textarea for editing message content with
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
|
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
import { cn } from '$lib/components/ui/utils';
|
||||||
import { Card } from '$lib/components/ui/card';
|
|
||||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
|
||||||
import { useThrottle } from '$lib/hooks/use-throttle.svelte';
|
|
||||||
import { formatReasoningPreview } from '$lib/utils';
|
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
@@ -15,11 +11,11 @@
|
|||||||
class?: string;
|
class?: string;
|
||||||
icon?: Component;
|
icon?: Component;
|
||||||
iconClass?: string;
|
iconClass?: string;
|
||||||
title: string;
|
iconUrl?: string | null;
|
||||||
|
title?: string;
|
||||||
|
titleSnippet?: Snippet;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
preview?: string;
|
shimmerTitle?: boolean;
|
||||||
rawContent?: string;
|
|
||||||
isStreaming?: boolean;
|
|
||||||
onToggle?: () => void;
|
onToggle?: () => void;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
@@ -28,45 +24,18 @@
|
|||||||
open = $bindable(false),
|
open = $bindable(false),
|
||||||
class: className = '',
|
class: className = '',
|
||||||
icon: IconComponent,
|
icon: IconComponent,
|
||||||
iconClass = 'h-4 w-4',
|
iconClass = ICON_CLASS_DEFAULT,
|
||||||
title,
|
iconUrl = null,
|
||||||
|
title = '',
|
||||||
|
titleSnippet,
|
||||||
subtitle,
|
subtitle,
|
||||||
preview,
|
shimmerTitle = false,
|
||||||
rawContent,
|
|
||||||
isStreaming = false,
|
|
||||||
onToggle,
|
onToggle,
|
||||||
children
|
children
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let contentContainer: HTMLDivElement | undefined = $state();
|
function hideBrokenIcon(event: Event) {
|
||||||
|
(event.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
|
|
||||||
|
|
||||||
let previewKey = useThrottle(() => rawContent ?? preview ?? '', 500);
|
|
||||||
let displayedPreview = $state('');
|
|
||||||
let displayedOverflow = $state(0);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void previewKey.key;
|
|
||||||
const content = rawContent ?? preview ?? '';
|
|
||||||
const result = formatReasoningPreview(content);
|
|
||||||
displayedPreview = result.preview;
|
|
||||||
displayedOverflow = result.overflow;
|
|
||||||
});
|
|
||||||
|
|
||||||
const autoScroll = createAutoScrollController();
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
autoScroll.setContainer(contentContainer);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Only auto-scroll when open and streaming
|
|
||||||
autoScroll.updateInterval(open && isStreaming);
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleScroll() {
|
|
||||||
autoScroll.handleScroll();
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -76,59 +45,54 @@
|
|||||||
open = value;
|
open = value;
|
||||||
onToggle?.();
|
onToggle?.();
|
||||||
}}
|
}}
|
||||||
class="{className} my-0!"
|
class={cn('group/collapsible', 'my-0!', className)}
|
||||||
>
|
>
|
||||||
<Card class="gap-0 border-muted bg-muted/30 py-0">
|
<Collapsible.Trigger
|
||||||
<Collapsible.Trigger class="flex w-full cursor-pointer items-start justify-between gap-2 p-3">
|
class={cn(
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
'flex w-full cursor-pointer items-start justify-between gap-2 text-left',
|
||||||
<div class="flex items-center gap-2 text-muted-foreground">
|
'py-1.5 pr-1'
|
||||||
{#if IconComponent}
|
)}
|
||||||
<IconComponent class={iconClass} />
|
>
|
||||||
{/if}
|
<div class="flex min-w-0 items-start gap-2 text-muted-foreground">
|
||||||
|
{#if iconUrl}
|
||||||
|
<img
|
||||||
|
src={iconUrl}
|
||||||
|
alt=""
|
||||||
|
class={cn('shrink-0 rounded-sm mt-0.75', iconClass)}
|
||||||
|
onerror={hideBrokenIcon}
|
||||||
|
/>
|
||||||
|
{:else if IconComponent}
|
||||||
|
<IconComponent class={cn('shrink-0 text-muted-foreground/60 mt-0.75', iconClass)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<span class="font-mono text-sm font-medium">{title}</span>
|
<span class={cn('text-sm font-medium', shimmerTitle ? 'shimmer-text' : 'text-foreground/80')}>
|
||||||
|
{#if titleSnippet}
|
||||||
{#if subtitle}
|
{@render titleSnippet()}
|
||||||
<span class="text-xs italic">{subtitle}</span>
|
{:else}
|
||||||
{/if}
|
{title}
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if displayedPreview && !showThoughtInProgress}
|
|
||||||
<div class="flex min-w-0 items-baseline justify-between gap-2">
|
|
||||||
<div class="w-3/4 truncate text-xs text-muted-foreground/80">
|
|
||||||
{displayedPreview}
|
|
||||||
</div>
|
|
||||||
{#if displayedOverflow > 0}
|
|
||||||
<span class="shrink-0 text-xs text-muted-foreground/60"
|
|
||||||
>{displayedOverflow}+ chars</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
<div
|
{#if subtitle}
|
||||||
class={buttonVariants({
|
<span class="text-xs italic text-muted-foreground/70">{subtitle}</span>
|
||||||
variant: 'ghost',
|
{/if}
|
||||||
size: 'sm',
|
</div>
|
||||||
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ChevronsUpDownIcon class="h-4 w-4" />
|
|
||||||
|
|
||||||
<span class="sr-only">Toggle content</span>
|
<ChevronDown
|
||||||
</div>
|
class={cn(
|
||||||
</Collapsible.Trigger>
|
'size-4 shrink-0 text-muted-foreground/60 transition-all duration-150 ease-out opacity-0 group-hover/collapsible:opacity-100 mt-0.75',
|
||||||
|
open && 'rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Collapsible.Content>
|
<span class="sr-only">Toggle content</span>
|
||||||
<div
|
</Collapsible.Trigger>
|
||||||
bind:this={contentContainer}
|
|
||||||
class="overflow-y-auto border-t border-muted px-3 pb-3"
|
<Collapsible.Content>
|
||||||
onscroll={handleScroll}
|
<div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
|
||||||
style="min-height: var(--min-message-height); max-height: var(--max-message-height);"
|
<div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
|
||||||
>
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</Collapsible.Content>
|
</div>
|
||||||
</Card>
|
</Collapsible.Content>
|
||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||||
|
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||||
|
import { cn } from '$lib/components/ui/utils';
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean;
|
||||||
|
class?: string;
|
||||||
|
icon?: Component;
|
||||||
|
iconClass?: string;
|
||||||
|
iconUrl?: string | null;
|
||||||
|
title?: string;
|
||||||
|
titleSnippet?: Snippet;
|
||||||
|
subtitle?: string;
|
||||||
|
shimmerTitle?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
class: className = '',
|
||||||
|
icon: IconComponent,
|
||||||
|
iconClass = ICON_CLASS_DEFAULT,
|
||||||
|
iconUrl = null,
|
||||||
|
title = '',
|
||||||
|
titleSnippet,
|
||||||
|
subtitle,
|
||||||
|
shimmerTitle = false,
|
||||||
|
onToggle,
|
||||||
|
children
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function hideBrokenIcon(event: Event) {
|
||||||
|
(event.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Collapsible.Root
|
||||||
|
{open}
|
||||||
|
onOpenChange={(value) => {
|
||||||
|
open = value;
|
||||||
|
onToggle?.();
|
||||||
|
}}
|
||||||
|
class={cn('group/collapsible', 'overflow-hidden rounded-md', className)}
|
||||||
|
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
|
||||||
|
>
|
||||||
|
<Collapsible.Trigger
|
||||||
|
class={cn(
|
||||||
|
'flex w-full cursor-pointer items-start justify-between gap-2 text-left',
|
||||||
|
'px-3 py-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start gap-2 text-muted-foreground">
|
||||||
|
{#if iconUrl}
|
||||||
|
<img
|
||||||
|
src={iconUrl}
|
||||||
|
alt=""
|
||||||
|
class={cn('shrink-0 rounded-sm mt-0.5', iconClass)}
|
||||||
|
onerror={hideBrokenIcon}
|
||||||
|
/>
|
||||||
|
{:else if IconComponent}
|
||||||
|
<IconComponent class={cn('shrink-0 text-muted-foreground/60 mt-0.5', iconClass)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class={cn('text-sm font-medium', shimmerTitle ? 'shimmer-text' : 'text-foreground/80')}>
|
||||||
|
{#if titleSnippet}
|
||||||
|
{@render titleSnippet()}
|
||||||
|
{:else}
|
||||||
|
{title}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if subtitle}
|
||||||
|
<span class="text-xs italic text-muted-foreground/70">{subtitle}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronDown
|
||||||
|
class={cn(
|
||||||
|
'size-4 shrink-0 text-muted-foreground/60 transition-all duration-150 ease-out opacity-0 group-hover/collapsible:opacity-100 mt-0.5',
|
||||||
|
open && 'rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="sr-only">Toggle content</span>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
|
||||||
|
<Collapsible.Content>
|
||||||
|
<div class="p-3 pt-1">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible.Root>
|
||||||
@@ -19,8 +19,16 @@
|
|||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-content :global(.markdown-block:first-child p:first-child) {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content :global(.markdown-block:last-child p:last-child) {
|
||||||
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-content :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
|
.markdown-content :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
|
||||||
margin-top: 0;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Headers with consistent spacing */
|
/* Headers with consistent spacing */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Download } from '@lucide/svelte';
|
import { Download } from '@lucide/svelte';
|
||||||
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
|
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
|
||||||
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
|
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
title="Zoom out"
|
title="Zoom out"
|
||||||
aria-label="Zoom out"
|
aria-label="Zoom out"
|
||||||
>
|
>
|
||||||
<ZoomOutIcon class="mermaid-preview-btn-icon h-4 w-4" />
|
<ZoomOutIcon class="mermaid-preview-btn-icon {ICON_CLASS_DEFAULT}" />
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
class="mermaid-preview-zoom-label min-w-[3.5rem] px-0.5 text-center text-xs font-medium text-muted-foreground tabular-nums select-none"
|
class="mermaid-preview-zoom-label min-w-[3.5rem] px-0.5 text-center text-xs font-medium text-muted-foreground tabular-nums select-none"
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
title="Zoom in"
|
title="Zoom in"
|
||||||
aria-label="Zoom in"
|
aria-label="Zoom in"
|
||||||
>
|
>
|
||||||
<ZoomInIcon class="mermaid-preview-btn-icon h-4 w-4" />
|
<ZoomInIcon class="mermaid-preview-btn-icon {ICON_CLASS_DEFAULT}" />
|
||||||
</button>
|
</button>
|
||||||
<div class="mermaid-preview-controls-separator mx-1 h-5 w-px bg-border/50"></div>
|
<div class="mermaid-preview-controls-separator mx-1 h-5 w-px bg-border/50"></div>
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@
|
|||||||
title="Reset view"
|
title="Reset view"
|
||||||
aria-label="Reset view"
|
aria-label="Reset view"
|
||||||
>
|
>
|
||||||
<RotateCcwIcon class="mermaid-preview-btn-icon h-4 w-4" />
|
<RotateCcwIcon class="mermaid-preview-btn-icon {ICON_CLASS_DEFAULT}" />
|
||||||
</button>
|
</button>
|
||||||
<div class="mermaid-preview-controls-separator mx-1 h-5 w-px bg-border/50"></div>
|
<div class="mermaid-preview-controls-separator mx-1 h-5 w-px bg-border/50"></div>
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@
|
|||||||
title="Download SVG"
|
title="Download SVG"
|
||||||
aria-label="Download SVG"
|
aria-label="Download SVG"
|
||||||
>
|
>
|
||||||
<Download class="mermaid-preview-btn-icon h-4 w-4" />
|
<Download class="mermaid-preview-btn-icon {ICON_CLASS_DEFAULT}" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import hljs from 'highlight.js';
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
|
|
||||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||||
import { ColorMode } from '$lib/enums';
|
import { ColorMode } from '$lib/enums';
|
||||||
|
import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
|
||||||
|
import { highlightCode } from '$lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -13,6 +14,9 @@
|
|||||||
class?: string;
|
class?: string;
|
||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
|
/** Auto-scrolls to the bottom of new chunks; pauses on user scroll-up
|
||||||
|
* until the user returns to the bottom. */
|
||||||
|
streaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -20,10 +24,17 @@
|
|||||||
language = 'text',
|
language = 'text',
|
||||||
class: className = '',
|
class: className = '',
|
||||||
maxHeight = '60vh',
|
maxHeight = '60vh',
|
||||||
maxWidth = ''
|
maxWidth = '',
|
||||||
|
streaming = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let highlightedHtml = $state('');
|
const highlightedHtml = $derived(highlightCode(code, language));
|
||||||
|
|
||||||
|
let scrollEl = $state<HTMLDivElement>();
|
||||||
|
let userScrolledUp = $state(false);
|
||||||
|
let lastScrollTop = 0;
|
||||||
|
const SCROLL_BOTTOM_THRESHOLD_PX = SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX;
|
||||||
|
let pendingFrame: number | null = null;
|
||||||
|
|
||||||
function loadHighlightTheme(isDark: boolean) {
|
function loadHighlightTheme(isDark: boolean) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
@@ -38,6 +49,36 @@
|
|||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAtBottom(): boolean {
|
||||||
|
if (!scrollEl) return false;
|
||||||
|
return (
|
||||||
|
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
|
||||||
|
SCROLL_BOTTOM_THRESHOLD_PX
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottomOnFrame() {
|
||||||
|
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
|
||||||
|
pendingFrame = requestAnimationFrame(() => {
|
||||||
|
pendingFrame = null;
|
||||||
|
// User may scroll between scheduling and paint.
|
||||||
|
if (scrollEl && !userScrolledUp) {
|
||||||
|
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScrollEvent() {
|
||||||
|
if (!scrollEl) return;
|
||||||
|
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
|
||||||
|
if (isScrollingUp && !isAtBottom()) {
|
||||||
|
userScrolledUp = true;
|
||||||
|
} else if (isAtBottom()) {
|
||||||
|
userScrolledUp = false;
|
||||||
|
}
|
||||||
|
lastScrollTop = scrollEl.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const currentMode = mode.current;
|
const currentMode = mode.current;
|
||||||
const isDark = currentMode === ColorMode.DARK;
|
const isDark = currentMode === ColorMode.DARK;
|
||||||
@@ -45,46 +86,64 @@
|
|||||||
loadHighlightTheme(isDark);
|
loadHighlightTheme(isDark);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pin to bottom at the start of each streaming episode.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!code) {
|
if (streaming) {
|
||||||
highlightedHtml = '';
|
userScrolledUp = false;
|
||||||
return;
|
lastScrollTop = 0;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
$effect(() => {
|
||||||
// Check if the language is supported
|
void code;
|
||||||
const lang = language.toLowerCase();
|
if (!streaming || userScrolledUp) return;
|
||||||
const isSupported = hljs.getLanguage(lang);
|
scrollToBottomOnFrame();
|
||||||
|
});
|
||||||
|
|
||||||
if (isSupported) {
|
// Layout shifts that don't change `code` (highlight.js re-tokenize, line-wrap reflow).
|
||||||
const result = hljs.highlight(code, { language: lang });
|
$effect(() => {
|
||||||
highlightedHtml = result.value;
|
if (!streaming || !scrollEl) return;
|
||||||
} else {
|
|
||||||
// Try auto-detection or fallback to plain text
|
const observer = new MutationObserver(() => scrollToBottomOnFrame());
|
||||||
const result = hljs.highlightAuto(code);
|
observer.observe(scrollEl, {
|
||||||
highlightedHtml = result.value;
|
childList: true,
|
||||||
}
|
subtree: true,
|
||||||
} catch {
|
characterData: true
|
||||||
// Fallback to escaped plain text
|
});
|
||||||
highlightedHtml = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
return () => observer.disconnect();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="code-preview-wrapper min-w-0 max-w-full overflow-x-auto rounded-lg border border-border bg-muted {className}"
|
bind:this={scrollEl}
|
||||||
style="max-height: {maxHeight}; {maxWidth ? `max-width: ${maxWidth};` : ''}"
|
onscroll={handleScrollEvent}
|
||||||
|
class="code-preview-wrapper min-w-0 max-w-full overflow-auto rounded-xl border shadow-[0_1px_2px_0_rgb(0_0_0_/_0.05)] {className}"
|
||||||
|
style="border-color: color-mix(in oklch, var(--border) 30%, transparent); background: var(--code-background); max-height: {maxHeight}; {maxWidth
|
||||||
|
? `max-width: ${maxWidth};`
|
||||||
|
: ''}"
|
||||||
>
|
>
|
||||||
<!-- Needs to be formatted as single line for proper rendering -->
|
<!-- Single line: hljs injection depends on a contiguous source string. -->
|
||||||
<pre class="m-0"><code class="hljs text-sm leading-relaxed">{@html highlightedHtml}</code></pre>
|
<pre class="m-0"><code class="hljs text-sm leading-relaxed">{@html highlightedHtml}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.code-preview-wrapper {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.code-preview-wrapper pre {
|
.code-preview-wrapper pre {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-preview-wrapper code {
|
.code-preview-wrapper code {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .code-preview-wrapper {
|
||||||
|
border-color: color-mix(in oklch, var(--border) 20%, transparent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte
|
|||||||
* ```svelte
|
* ```svelte
|
||||||
* <CollapsibleContentBlock
|
* <CollapsibleContentBlock
|
||||||
* bind:open
|
* bind:open
|
||||||
* icon={BrainIcon}
|
|
||||||
* title="Thinking..."
|
* title="Thinking..."
|
||||||
* isStreaming
|
* isStreaming
|
||||||
* >
|
* >
|
||||||
@@ -78,6 +77,22 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte
|
|||||||
*/
|
*/
|
||||||
export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte';
|
export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **CollapsibleTerminalBlock** - Expandable content card with a terminal-style frame
|
||||||
|
*
|
||||||
|
* Same shape as CollapsibleContentBlock, but with a `code-background`
|
||||||
|
* fill, subtle border, and tightened padding suited for shell command
|
||||||
|
* output and similar dense / monospace content.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <CollapsibleTerminalBlock bind:open title="Run command">
|
||||||
|
* <pre>{output}</pre>
|
||||||
|
* </CollapsibleTerminalBlock>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export { default as CollapsibleTerminalBlock } from './CollapsibleTerminalBlock.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **MermaidPreview** - Interactive Mermaid diagram viewer
|
* **MermaidPreview** - Interactive Mermaid diagram viewer
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { FolderOpen, Plus, Loader2, Braces } from '@lucide/svelte';
|
import { FolderOpen, Plus, Loader2, Braces } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
@@ -289,7 +290,7 @@
|
|||||||
{#if selectedTemplate && !templatePreviewContent}
|
{#if selectedTemplate && !templatePreviewContent}
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<div class="mb-3 flex items-center gap-2">
|
<div class="mb-3 flex items-center gap-2">
|
||||||
<Braces class="h-4 w-4 text-muted-foreground" />
|
<Braces class="{ICON_CLASS_DEFAULT} text-muted-foreground" />
|
||||||
|
|
||||||
<span class="text-sm font-medium">
|
<span class="text-sm font-medium">
|
||||||
{selectedTemplate.title || selectedTemplate.name}
|
{selectedTemplate.title || selectedTemplate.name}
|
||||||
@@ -371,9 +372,9 @@
|
|||||||
{#if hasTemplateResult}
|
{#if hasTemplateResult}
|
||||||
<Button onclick={handleAttachTemplateResource} disabled={isAttaching}>
|
<Button onclick={handleAttachTemplateResource} disabled={isAttaching}>
|
||||||
{#if isAttaching}
|
{#if isAttaching}
|
||||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
|
||||||
{:else}
|
{:else}
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
<Plus class="mr-2 {ICON_CLASS_DEFAULT}" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
Attach Resource
|
Attach Resource
|
||||||
@@ -381,9 +382,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Button onclick={handleAttach} disabled={selectedResources.size === 0 || isAttaching}>
|
<Button onclick={handleAttach} disabled={selectedResources.size === 0 || isAttaching}>
|
||||||
{#if isAttaching}
|
{#if isAttaching}
|
||||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
|
||||||
{:else}
|
{:else}
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
<Plus class="mr-2 {ICON_CLASS_DEFAULT}" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
Attach {selectedResources.size > 0 ? `(${selectedResources.size})` : 'Resource'}
|
Attach {selectedResources.size > 0 ? `(${selectedResources.size})` : 'Resource'}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import { McpServerForm } from '$lib/components/app/mcp';
|
import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { parseHeadersToArray, uuid } from '$lib/utils';
|
import { parseHeadersToArray, uuid, canonicalizeServerUrl } from '$lib/utils';
|
||||||
import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
|
import {
|
||||||
|
BEARER_PREFIX,
|
||||||
|
BOOL_FALSE_STRING,
|
||||||
|
BOOL_TRUE_STRING,
|
||||||
|
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
|
||||||
|
MCP_SERVER_ID_PREFIX,
|
||||||
|
RECOMMENDED_MCP_SERVERS,
|
||||||
|
REDACTED_HEADERS
|
||||||
|
} from '$lib/constants';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -17,6 +26,39 @@
|
|||||||
let newServerUrl = $state('');
|
let newServerUrl = $state('');
|
||||||
let newServerHeaders = $state('');
|
let newServerHeaders = $state('');
|
||||||
let newServerUseProxy = $state(false);
|
let newServerUseProxy = $state(false);
|
||||||
|
|
||||||
|
let newServerWantsAuthorization = $state(false);
|
||||||
|
|
||||||
|
let selectedRecommendationId = $derived.by(() => {
|
||||||
|
const url = newServerUrl.trim();
|
||||||
|
if (!url) return null;
|
||||||
|
const targetCanonical = canonicalizeServerUrl(url);
|
||||||
|
return (
|
||||||
|
RECOMMENDED_MCP_SERVERS.find((rec) => canonicalizeServerUrl(rec.url) === targetCanonical)
|
||||||
|
?.id ?? null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let selectedRecommendation = $derived(
|
||||||
|
selectedRecommendationId
|
||||||
|
? (RECOMMENDED_MCP_SERVERS.find((rec) => rec.id === selectedRecommendationId) ?? null)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
let authRequired = $derived(selectedRecommendation?.needsAuthorization ?? false);
|
||||||
|
|
||||||
|
let bearerTokenFilled = $derived.by(() => {
|
||||||
|
const pairs = parseHeadersToArray(newServerHeaders);
|
||||||
|
const bearerPrefix = BEARER_PREFIX.toLowerCase();
|
||||||
|
const bearer = pairs.find(
|
||||||
|
(p) =>
|
||||||
|
REDACTED_HEADERS.has(p.key.trim().toLowerCase()) &&
|
||||||
|
p.value.trim().toLowerCase().startsWith(bearerPrefix)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bearer) return false;
|
||||||
|
|
||||||
|
return bearer.value.trim().slice(bearerPrefix.length).trim().length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
let newServerUrlError = $derived.by(() => {
|
let newServerUrlError = $derived.by(() => {
|
||||||
if (!newServerUrl.trim()) return 'URL is required';
|
if (!newServerUrl.trim()) return 'URL is required';
|
||||||
try {
|
try {
|
||||||
@@ -30,13 +72,83 @@
|
|||||||
let newServerHeaderPairsValid = $derived(
|
let newServerHeaderPairsValid = $derived(
|
||||||
parseHeadersToArray(newServerHeaders).every((p) => p.key.trim() && p.value.trim())
|
parseHeadersToArray(newServerHeaders).every((p) => p.key.trim() && p.value.trim())
|
||||||
);
|
);
|
||||||
let canSave = $derived(!newServerUrlError && newServerHeaderPairsValid);
|
let canSave = $derived(
|
||||||
|
!newServerUrlError && newServerHeaderPairsValid && (!authRequired || bearerTokenFilled)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Backward-compatible read: older versions stored a JSON array of dismissed ids.
|
||||||
|
function readRecommendationsDismissed(): boolean {
|
||||||
|
if (!browser) return false;
|
||||||
|
const raw = localStorage.getItem(DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw) return false;
|
||||||
|
|
||||||
|
if (raw === BOOL_TRUE_STRING) return true;
|
||||||
|
if (raw === BOOL_FALSE_STRING) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) && parsed.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRecommendationsDismissed(dismissed: boolean) {
|
||||||
|
recommendationsDismissed = dismissed;
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem(
|
||||||
|
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
|
||||||
|
dismissed ? BOOL_TRUE_STRING : BOOL_FALSE_STRING
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let recommendationsDismissed = $state<boolean>(readRecommendationsDismissed());
|
||||||
|
|
||||||
|
// Read-only once a recommendation is picked: switch is disabled, so we keep
|
||||||
|
// the Authorization field in sync with the requirement.
|
||||||
|
$effect(() => {
|
||||||
|
if (authRequired) {
|
||||||
|
newServerWantsAuthorization = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let hasSelection = $derived(selectedRecommendationId !== null);
|
||||||
|
|
||||||
|
let unconfiguredRecommendations = $derived.by(() => {
|
||||||
|
const configuredCanonicals = new Set(
|
||||||
|
mcpStore.getServers().map((s) => canonicalizeServerUrl(s.url))
|
||||||
|
);
|
||||||
|
|
||||||
|
return RECOMMENDED_MCP_SERVERS.filter(
|
||||||
|
(rec) => !configuredCanonicals.has(canonicalizeServerUrl(rec.url))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let recommendationsToShow = $derived(recommendationsDismissed ? [] : unconfiguredRecommendations);
|
||||||
|
|
||||||
|
function handleRecommendationClick(recommendedId: string) {
|
||||||
|
const recommendation = RECOMMENDED_MCP_SERVERS.find((rec) => rec.id === recommendedId);
|
||||||
|
|
||||||
|
if (!recommendation) return;
|
||||||
|
|
||||||
|
newServerUrl = recommendation.url;
|
||||||
|
newServerHeaders = '';
|
||||||
|
newServerWantsAuthorization = recommendation.needsAuthorization ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDismissAll() {
|
||||||
|
writeRecommendationsDismissed(true);
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenChange(value: boolean) {
|
function handleOpenChange(value: boolean) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
newServerUrl = '';
|
newServerUrl = '';
|
||||||
newServerHeaders = '';
|
newServerHeaders = '';
|
||||||
newServerUseProxy = false;
|
newServerUseProxy = false;
|
||||||
|
newServerWantsAuthorization = false;
|
||||||
}
|
}
|
||||||
open = value;
|
open = value;
|
||||||
onOpenChange?.(value);
|
onOpenChange?.(value);
|
||||||
@@ -67,11 +179,33 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
||||||
<Dialog.Content class="sm:max-w-md">
|
<Dialog.Content class="sm:max-w-2xl">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Add New Server</Dialog.Title>
|
<Dialog.Title class="select-none">Add New MCP Server</Dialog.Title>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
|
{#if recommendationsToShow.length > 0}
|
||||||
|
<div class="space-y-3 pt-2">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-medium">Recommended Servers</h3>
|
||||||
|
<Button class="text-muted-foreground" variant="ghost" size="sm" onclick={handleDismissAll}
|
||||||
|
>Dismiss</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{#each recommendationsToShow as recommendation (recommendation.id)}
|
||||||
|
<McpServerCardCompact
|
||||||
|
server={recommendation}
|
||||||
|
onClick={() => handleRecommendationClick(recommendation.id)}
|
||||||
|
selected={selectedRecommendationId === recommendation.id}
|
||||||
|
dimmed={hasSelection && selectedRecommendationId !== recommendation.id}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="contents">
|
<form onsubmit={handleSubmit} class="contents">
|
||||||
<div class="space-y-4 py-4">
|
<div class="space-y-4 py-4">
|
||||||
<McpServerForm
|
<McpServerForm
|
||||||
@@ -83,6 +217,8 @@
|
|||||||
onUseProxyChange={(v) => (newServerUseProxy = v)}
|
onUseProxyChange={(v) => (newServerUseProxy = v)}
|
||||||
urlError={newServerUrl ? newServerUrlError : null}
|
urlError={newServerUrl ? newServerUrlError : null}
|
||||||
id="new-server"
|
id="new-server"
|
||||||
|
bind:wantsAuthorization={newServerWantsAuthorization}
|
||||||
|
required={authRequired}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import { AlertTriangle, ArrowRight } from '@lucide/svelte';
|
import { AlertTriangle, ArrowRight } from '@lucide/svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
>
|
>
|
||||||
<span class="min-w-0 truncate font-mono text-xs">{model}</span>
|
<span class="min-w-0 truncate font-mono text-xs">{model}</span>
|
||||||
<ArrowRight
|
<ArrowRight
|
||||||
class="h-4 w-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
<div class={className}>
|
<div class={className}>
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
{#if sectionLabel}
|
{#if sectionLabel}
|
||||||
<span class="text-xs font-medium">
|
<span class="text-xs font-medium select-none">
|
||||||
{sectionLabel}
|
{sectionLabel}
|
||||||
{#if sectionLabelOptional}
|
{#if sectionLabelOptional}
|
||||||
<span class="text-muted-foreground">(optional)</span>
|
<span class="text-muted-foreground">(optional)</span>
|
||||||
@@ -118,6 +118,7 @@
|
|||||||
{addButtonLabel}
|
{addButtonLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if pairs.length > 0}
|
{#if pairs.length > 0}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each pairs as pair, index (index)}
|
{#each pairs as pair, index (index)}
|
||||||
@@ -159,6 +160,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-xs text-muted-foreground">{emptyMessage}</p>
|
<p class="select-none text-xs text-muted-foreground">{emptyMessage}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Search, X } from '@lucide/svelte';
|
import { Search, X } from '@lucide/svelte';
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
|
|
||||||
<div class="relative {className}">
|
<div class="relative {className}">
|
||||||
<Search
|
<Search
|
||||||
class="absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
|
class="absolute top-1/2 left-3 z-10 {ICON_CLASS_DEFAULT} -translate-y-1/2 transform text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
onclick={handleClear}
|
onclick={handleClear}
|
||||||
aria-label={value ? 'Clear search' : 'Close'}
|
aria-label={value ? 'Clear search' : 'Close'}
|
||||||
>
|
>
|
||||||
<X class="h-4 w-4" />
|
<X class={ICON_CLASS_DEFAULT} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
>
|
>
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
<McpLogo class="h-4 w-4" />
|
<McpLogo class={ICON_CLASS_DEFAULT} />
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
|
|
||||||
<Tooltip.Content>
|
<Tooltip.Content>
|
||||||
@@ -68,7 +69,7 @@
|
|||||||
<img
|
<img
|
||||||
src={favicon.url}
|
src={favicon.url}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-4 w-4"
|
class={ICON_CLASS_DEFAULT}
|
||||||
onerror={(e) => {
|
onerror={(e) => {
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { FileText, Loader2, AlertCircle, Download } from '@lucide/svelte';
|
import { FileText, Loader2, AlertCircle, Download } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
@@ -140,7 +141,7 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center gap-2 rounded bg-muted p-2 text-sm text-muted-foreground">
|
<div class="flex items-center gap-2 rounded bg-muted p-2 text-sm text-muted-foreground">
|
||||||
<FileText class="h-4 w-4" />
|
<FileText class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
<span>Binary content ({blob.mimeType || 'unknown type'})</span>
|
<span>Binary content ({blob.mimeType || 'unknown type'})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { RefreshCw, Loader2 } from '@lucide/svelte';
|
import { RefreshCw, Loader2 } from '@lucide/svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { SearchInput } from '$lib/components/app/forms';
|
import { SearchInput } from '$lib/components/app/forms';
|
||||||
@@ -30,9 +31,9 @@
|
|||||||
title="Refresh resources"
|
title="Refresh resources"
|
||||||
>
|
>
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<Loader2 class="h-4 w-4 animate-spin" />
|
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin" />
|
||||||
{:else}
|
{:else}
|
||||||
<RefreshCw class="h-4 w-4" />
|
<RefreshCw class={ICON_CLASS_DEFAULT} />
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { FolderOpen, ChevronDown, ChevronRight, Loader2, Braces } from '@lucide/svelte';
|
import { FolderOpen, ChevronDown, ChevronRight, Loader2, Braces } from '@lucide/svelte';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
@@ -122,7 +123,7 @@
|
|||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={(checked: boolean | 'indeterminate') =>
|
onCheckedChange={(checked: boolean | 'indeterminate') =>
|
||||||
handleCheckboxChange(resource, checked === true)}
|
handleCheckboxChange(resource, checked === true)}
|
||||||
class="h-4 w-4"
|
class={ICON_CLASS_DEFAULT}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -160,7 +161,7 @@
|
|||||||
<McpServerIdentity
|
<McpServerIdentity
|
||||||
displayName={serverDisplayName}
|
displayName={serverDisplayName}
|
||||||
faviconUrl={serverFaviconUrl}
|
faviconUrl={serverFaviconUrl}
|
||||||
iconClass="h-4 w-4"
|
iconClass={ICON_CLASS_DEFAULT}
|
||||||
showVersion={false}
|
showVersion={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
@@ -134,7 +135,7 @@
|
|||||||
{#if showSkeleton}
|
{#if showSkeleton}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Skeleton class="h-4 w-4 rounded" />
|
<Skeleton class="{ICON_CLASS_DEFAULT} rounded" />
|
||||||
<Skeleton class="h-3 w-24" />
|
<Skeleton class="h-3 w-24" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
@@ -146,7 +147,7 @@
|
|||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Skeleton class="h-4 w-4 rounded" />
|
<Skeleton class="{ICON_CLASS_DEFAULT} rounded" />
|
||||||
<Skeleton class="h-3 w-32" />
|
<Skeleton class="h-3 w-32" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
import type { RecommendedMCPServer } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
server: RecommendedMCPServer;
|
||||||
|
onClick?: () => void;
|
||||||
|
selected?: boolean;
|
||||||
|
dimmed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { server, onClick, selected = false, dimmed = false }: Props = $props();
|
||||||
|
|
||||||
|
let activeIconUrl = $derived.by(() => {
|
||||||
|
const isDark = mode.current === 'dark';
|
||||||
|
|
||||||
|
if (isDark && server.iconUrlDark) return server.iconUrlDark;
|
||||||
|
if (!isDark && server.iconUrlLight) return server.iconUrlLight;
|
||||||
|
|
||||||
|
return server.iconUrl;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root
|
||||||
|
class={`relative gap-3! select-none bg-muted/30 p-4 transition-all ${onClick ? 'cursor-pointer hover:bg-muted/50 hover:opacity-100' : ''} ${selected ? 'bg-muted/30 ring-1 ring-primary/40' : ''} ${dimmed ? 'opacity-50' : ''}`}
|
||||||
|
onclick={onClick}
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
{#if activeIconUrl}
|
||||||
|
<img
|
||||||
|
src={activeIconUrl}
|
||||||
|
alt=""
|
||||||
|
class="h-5 w-5 shrink-0 rounded"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h4 class="min-w-0 flex-1 truncate font-medium">{server.name}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground">{server.description}</p>
|
||||||
|
</Card.Root>
|
||||||
@@ -5,9 +5,14 @@
|
|||||||
import type { KeyValuePair } from '$lib/types';
|
import type { KeyValuePair } from '$lib/types';
|
||||||
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
|
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
|
||||||
import { UrlProtocol } from '$lib/enums';
|
import { UrlProtocol } from '$lib/enums';
|
||||||
import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants';
|
import {
|
||||||
|
AUTHORIZATION_HEADER,
|
||||||
|
BEARER_PREFIX,
|
||||||
|
CLI_FLAGS,
|
||||||
|
MCP_SERVER_URL_PLACEHOLDER,
|
||||||
|
REDACTED_HEADERS
|
||||||
|
} from '$lib/constants';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { CLI_FLAGS } from '$lib/constants';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -18,6 +23,22 @@
|
|||||||
onUseProxyChange?: (useProxy: boolean) => void;
|
onUseProxyChange?: (useProxy: boolean) => void;
|
||||||
urlError?: string | null;
|
urlError?: string | null;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
/**
|
||||||
|
* "Wants Authorization" is the user's *intent* to add a Bearer token
|
||||||
|
* (separate from `hasAuthorization` which reflects what's already in
|
||||||
|
* the headers). Bindable so a parent - e.g. the recommendation cards
|
||||||
|
* on the "Add New Server" dialog - can flip the switch on when the
|
||||||
|
* picked server ships a `needsAuthorization: true` flag.
|
||||||
|
*/
|
||||||
|
wantsAuthorization?: boolean;
|
||||||
|
/**
|
||||||
|
* Marks the "Authorization" field as required. Locks the toggle so the
|
||||||
|
* user can't dismiss it, and visually marks the field with a red
|
||||||
|
* asterisk. The parent is expected to gate its submit affordance on
|
||||||
|
* the bearer token actually being filled. Used by the "Add New Server"
|
||||||
|
* dialog for recommendations whose `needsAuthorization` flag is true.
|
||||||
|
*/
|
||||||
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -28,7 +49,9 @@
|
|||||||
onHeadersChange,
|
onHeadersChange,
|
||||||
onUseProxyChange,
|
onUseProxyChange,
|
||||||
urlError = null,
|
urlError = null,
|
||||||
id = 'server'
|
id = 'server',
|
||||||
|
wantsAuthorization = $bindable(false),
|
||||||
|
required = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let isWebSocket = $derived(
|
let isWebSocket = $derived(
|
||||||
@@ -38,14 +61,11 @@
|
|||||||
|
|
||||||
let headerPairs = $derived<KeyValuePair[]>(parseHeadersToArray(headers));
|
let headerPairs = $derived<KeyValuePair[]>(parseHeadersToArray(headers));
|
||||||
|
|
||||||
const AUTHORIZATION_HEADER = 'Authorization';
|
|
||||||
const BEARER_PREFIX = 'Bearer ';
|
|
||||||
|
|
||||||
// Heuristic: this dedicated UI only owns Authorization headers that already
|
// Heuristic: this dedicated UI only owns Authorization headers that already
|
||||||
// carry a Bearer scheme. Anything else (e.g. Basic, raw tokens) stays in the
|
// carry a Bearer scheme. Anything else (e.g. Basic, raw tokens) stays in the
|
||||||
// KV section so the user can still edit those values verbatim.
|
// KV section so the user can still edit those values verbatim.
|
||||||
const matchesAuthorizationKey = (key: string): boolean =>
|
const matchesAuthorizationKey = (key: string): boolean =>
|
||||||
key.trim().toLowerCase() === AUTHORIZATION_HEADER.toLowerCase();
|
REDACTED_HEADERS.has(key.trim().toLowerCase());
|
||||||
|
|
||||||
const isBearerScheme = (value: string): boolean =>
|
const isBearerScheme = (value: string): boolean =>
|
||||||
value.trim().toLowerCase().startsWith(BEARER_PREFIX.toLowerCase());
|
value.trim().toLowerCase().startsWith(BEARER_PREFIX.toLowerCase());
|
||||||
@@ -55,8 +75,6 @@
|
|||||||
|
|
||||||
let hasAuthorization = $derived(headerPairs.some(ownedByBearerUi));
|
let hasAuthorization = $derived(headerPairs.some(ownedByBearerUi));
|
||||||
|
|
||||||
let wantsAuthorization = $state(false);
|
|
||||||
|
|
||||||
let showAuthorization = $derived(hasAuthorization || wantsAuthorization);
|
let showAuthorization = $derived(hasAuthorization || wantsAuthorization);
|
||||||
|
|
||||||
let urlInput: HTMLInputElement | null = $state(null);
|
let urlInput: HTMLInputElement | null = $state(null);
|
||||||
@@ -119,7 +137,7 @@
|
|||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="server-url-{id}" class="mb-2 block text-xs font-medium">
|
<label for="server-url-{id}" class="mb-2 block text-xs font-medium select-none">
|
||||||
Server URL <span class="text-destructive">*</span>
|
Server URL <span class="text-destructive">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -138,14 +156,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||||
<Switch
|
<Switch
|
||||||
id="use-authorization-{id}"
|
id="use-authorization-{id}"
|
||||||
checked={showAuthorization}
|
checked={showAuthorization}
|
||||||
onCheckedChange={setUseAuthorization}
|
onCheckedChange={setUseAuthorization}
|
||||||
|
disabled={required}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span class="text-xs text-muted-foreground">Authorization</span>
|
<span class="text-xs text-muted-foreground">
|
||||||
|
Authorization{#if required}
|
||||||
|
<span class="text-destructive">*</span>{/if}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{#if showAuthorization}
|
{#if showAuthorization}
|
||||||
|
|||||||
@@ -180,6 +180,17 @@ export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerC
|
|||||||
/** Skeleton loading state for server card during health checks. */
|
/** Skeleton loading state for server card during health checks. */
|
||||||
export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
|
export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **McpServerCardCompact** - Condensed MCP server card
|
||||||
|
*
|
||||||
|
* Static card for picker-style UIs (e.g. recommended MCP servers in the
|
||||||
|
* Add New Server dialog). Shows an optional favicon, the server name, and
|
||||||
|
* a short description. Performs no network requests - safe to render
|
||||||
|
* without contacting any upstream server until the user explicitly adds
|
||||||
|
* the server.
|
||||||
|
*/
|
||||||
|
export { default as McpServerCardCompact } from './McpServerCard/McpServerCardCompact.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **McpServerIdentity** - Server identity display (icon, name, version)
|
* **McpServerIdentity** - Server identity display (icon, name, version)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
disabled={!canScrollLeft}
|
disabled={!canScrollLeft}
|
||||||
aria-label="Scroll left"
|
aria-label="Scroll left"
|
||||||
>
|
>
|
||||||
<ChevronLeft class="h-4 w-4" />
|
<ChevronLeft class={ICON_CLASS_DEFAULT} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -88,6 +89,6 @@
|
|||||||
disabled={!canScrollRight}
|
disabled={!canScrollRight}
|
||||||
aria-label="Scroll right"
|
aria-label="Scroll right"
|
||||||
>
|
>
|
||||||
<ChevronRight class="h-4 w-4" />
|
<ChevronRight class={ICON_CLASS_DEFAULT} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import {
|
import {
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
Heart,
|
Heart,
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
|
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
|
||||||
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{:else if isFailed}
|
{:else if isFailed}
|
||||||
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
|
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { Search } from '@lucide/svelte';
|
import { Search } from '@lucide/svelte';
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet itemIcon(IconComponent: Component)}
|
{#snippet itemIcon(IconComponent: Component)}
|
||||||
<IconComponent class="h-4 w-4" />
|
<IconComponent class={ICON_CLASS_DEFAULT} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if isSearchModeActive}
|
{#if isSearchModeActive}
|
||||||
@@ -183,7 +184,7 @@
|
|||||||
tooltip={item.tooltip}
|
tooltip={item.tooltip}
|
||||||
tooltipSide={TooltipSide.RIGHT}
|
tooltipSide={TooltipSide.RIGHT}
|
||||||
size="lg"
|
size="lg"
|
||||||
iconSize="h-4 w-4"
|
iconSize={ICON_CLASS_DEFAULT}
|
||||||
class="h-9 w-9 rounded-full hover:bg-accent! {isActive
|
class="h-9 w-9 rounded-full hover:bg-accent! {isActive
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: ''}"
|
: ''}"
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import {
|
import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -147,7 +148,7 @@
|
|||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
<div
|
<div
|
||||||
class="stop-button flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
class="stop-button flex {ICON_CLASS_DEFAULT} shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||||
onclick={handleStop}
|
onclick={handleStop}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleStop(e)}
|
onkeydown={(e) => e.key === 'Enter' && handleStop(e)}
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { AlertTriangle, RefreshCw, Key, CheckCircle, XCircle } from '@lucide/svelte';
|
import { AlertTriangle, RefreshCw, Key, CheckCircle, XCircle } from '@lucide/svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -7,7 +8,7 @@
|
|||||||
import Label from '$lib/components/ui/label/label.svelte';
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
|
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
|
||||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||||
import { SETTINGS_KEYS } from '$lib/constants';
|
import { AUTHORIZATION_HEADER, BEARER_PREFIX, SETTINGS_KEYS } from '$lib/constants';
|
||||||
import { ROUTES } from '$lib/constants/routes';
|
import { ROUTES } from '$lib/constants/routes';
|
||||||
import { fade, fly, scale } from 'svelte/transition';
|
import { fade, fly, scale } from 'svelte/transition';
|
||||||
import { KeyboardKey } from '$lib/enums';
|
import { KeyboardKey } from '$lib/enums';
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
const response = await fetch(`${base}/props`, {
|
const response = await fetch(`${base}/props`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${apiKeyInput.trim()}`
|
[AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKeyInput.trim()}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@
|
|||||||
{#if isAccessDeniedError && !showApiKeyInput}
|
{#if isAccessDeniedError && !showApiKeyInput}
|
||||||
<div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4">
|
<div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4">
|
||||||
<Button onclick={handleShowApiKeyInput} variant="outline" class="w-full">
|
<Button onclick={handleShowApiKeyInput} variant="outline" class="w-full">
|
||||||
<Key class="h-4 w-4" />
|
<Key class={ICON_CLASS_DEFAULT} />
|
||||||
Enter API Key
|
Enter API Key
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,21 +172,21 @@
|
|||||||
/>
|
/>
|
||||||
{#if apiKeyState === 'validating'}
|
{#if apiKeyState === 'validating'}
|
||||||
<div class="absolute top-1/2 right-3 -translate-y-1/2">
|
<div class="absolute top-1/2 right-3 -translate-y-1/2">
|
||||||
<RefreshCw class="h-4 w-4 animate-spin text-muted-foreground" />
|
<RefreshCw class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{:else if apiKeyState === 'success'}
|
{:else if apiKeyState === 'success'}
|
||||||
<div
|
<div
|
||||||
class="absolute top-1/2 right-3 -translate-y-1/2"
|
class="absolute top-1/2 right-3 -translate-y-1/2"
|
||||||
in:scale={{ duration: 200, start: 0.8 }}
|
in:scale={{ duration: 200, start: 0.8 }}
|
||||||
>
|
>
|
||||||
<CheckCircle class="h-4 w-4 text-green-500" />
|
<CheckCircle class="{ICON_CLASS_DEFAULT} text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
{:else if apiKeyState === 'error'}
|
{:else if apiKeyState === 'error'}
|
||||||
<div
|
<div
|
||||||
class="absolute top-1/2 right-3 -translate-y-1/2"
|
class="absolute top-1/2 right-3 -translate-y-1/2"
|
||||||
in:scale={{ duration: 200, start: 0.8 }}
|
in:scale={{ duration: 200, start: 0.8 }}
|
||||||
>
|
>
|
||||||
<XCircle class="h-4 w-4 text-destructive" />
|
<XCircle class="{ICON_CLASS_DEFAULT} text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +210,7 @@
|
|||||||
class="flex-1"
|
class="flex-1"
|
||||||
>
|
>
|
||||||
{#if apiKeyState === 'validating'}
|
{#if apiKeyState === 'validating'}
|
||||||
<RefreshCw class="h-4 w-4 animate-spin" />
|
<RefreshCw class="{ICON_CLASS_DEFAULT} animate-spin" />
|
||||||
Validating...
|
Validating...
|
||||||
{:else if apiKeyState === 'success'}
|
{:else if apiKeyState === 'success'}
|
||||||
Success!
|
Success!
|
||||||
@@ -237,11 +238,11 @@
|
|||||||
<div in:fly={{ y: 10, duration: 300, delay: 200 }}>
|
<div in:fly={{ y: 10, duration: 300, delay: 200 }}>
|
||||||
<Button onclick={handleRetryConnection} disabled={isServerLoading} class="w-full">
|
<Button onclick={handleRetryConnection} disabled={isServerLoading} class="w-full">
|
||||||
{#if isServerLoading}
|
{#if isServerLoading}
|
||||||
<RefreshCw class="h-4 w-4 animate-spin" />
|
<RefreshCw class="{ICON_CLASS_DEFAULT} animate-spin" />
|
||||||
|
|
||||||
Connecting...
|
Connecting...
|
||||||
{:else}
|
{:else}
|
||||||
<RefreshCw class="h-4 w-4" />
|
<RefreshCw class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
Retry Connection
|
Retry Connection
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { AlertTriangle, Server } from '@lucide/svelte';
|
import { AlertTriangle, Server } from '@lucide/svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -57,7 +58,7 @@
|
|||||||
|
|
||||||
{#if showActions && error}
|
{#if showActions && error}
|
||||||
<Button variant="outline" size="sm" class="text-destructive">
|
<Button variant="outline" size="sm" class="text-destructive">
|
||||||
<AlertTriangle class="h-4 w-4" />
|
<AlertTriangle class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
{error}
|
{error}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
NUMERIC_FIELDS,
|
NUMERIC_FIELDS,
|
||||||
POSITIVE_INTEGER_FIELDS,
|
POSITIVE_INTEGER_FIELDS,
|
||||||
SETTINGS_CHAT_SECTIONS,
|
SETTINGS_CHAT_SECTIONS,
|
||||||
SETTINGS_SECTION_TITLES,
|
SETTINGS_SECTION_TITLES
|
||||||
type SettingsSection
|
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
|
import type { SettingsSection } from '$lib/types';
|
||||||
import { RouterService } from '$lib/services/router.service';
|
import { RouterService } from '$lib/services/router.service';
|
||||||
import { setMode } from 'mode-watcher';
|
import { setMode } from 'mode-watcher';
|
||||||
import { ColorMode } from '$lib/enums/ui.enums';
|
import { ColorMode } from '$lib/enums/ui.enums';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { RotateCcw, FlaskConical } from '@lucide/svelte';
|
import { RotateCcw, FlaskConical } from '@lucide/svelte';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
@@ -43,161 +44,55 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each fields as field (field.key)}
|
{#each fields as field (field.key)}
|
||||||
<div class="space-y-2">
|
{#if !field.dependsOn || Boolean(localConfig[field.dependsOn])}
|
||||||
{#if field.type === SettingsFieldType.INPUT}
|
<div class={field.dependsOn ? 'space-y-2 pl-6' : 'space-y-2'}>
|
||||||
{@const currentValue = String(localConfig[field.key] ?? '')}
|
{#if field.type === SettingsFieldType.INPUT}
|
||||||
{@const serverDefault = currentModelParams[field.key]}
|
{@const currentValue = String(localConfig[field.key] ?? '')}
|
||||||
{@const isCustomRealTime = (() => {
|
{@const serverDefault = currentModelParams[field.key]}
|
||||||
if (serverDefault == null) return false;
|
{@const isCustomRealTime = (() => {
|
||||||
if (currentValue === '') return false;
|
if (serverDefault == null) return false;
|
||||||
|
if (currentValue === '') return false;
|
||||||
|
|
||||||
const numericInput = parseFloat(currentValue);
|
const numericInput = parseFloat(currentValue);
|
||||||
const normalizedInput = !isNaN(numericInput)
|
const normalizedInput = !isNaN(numericInput)
|
||||||
? Math.round(numericInput * 1000000) / 1000000
|
? Math.round(numericInput * 1000000) / 1000000
|
||||||
: currentValue;
|
: currentValue;
|
||||||
const normalizedDefault =
|
const normalizedDefault =
|
||||||
typeof serverDefault === 'number'
|
typeof serverDefault === 'number'
|
||||||
? Math.round(serverDefault * 1000000) / 1000000
|
? Math.round(serverDefault * 1000000) / 1000000
|
||||||
: serverDefault;
|
: serverDefault;
|
||||||
|
|
||||||
return normalizedInput !== normalizedDefault;
|
return normalizedInput !== normalizedDefault;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
|
<Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
|
||||||
{field.label}
|
{field.label}
|
||||||
|
|
||||||
{#if field.isExperimental}
|
{#if field.isExperimental}
|
||||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{/if}
|
{/if}
|
||||||
</Label>
|
|
||||||
{#if isCustomRealTime}
|
|
||||||
<SettingsChatParameterSourceIndicator />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative w-full">
|
|
||||||
<Input
|
|
||||||
id={field.key}
|
|
||||||
type={field.isPositiveInteger ? 'number' : 'text'}
|
|
||||||
{...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
|
|
||||||
value={currentValue}
|
|
||||||
oninput={(e) => {
|
|
||||||
// Update local config immediately for real-time badge feedback
|
|
||||||
onConfigChange(field.key, e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
placeholder={currentModelParams[field.key] != null
|
|
||||||
? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
|
|
||||||
: ''}
|
|
||||||
class="w-full {isCustomRealTime ? 'pr-8' : ''}"
|
|
||||||
/>
|
|
||||||
{#if isCustomRealTime}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
settingsStore.resetParameterToServerDefault(field.key);
|
|
||||||
onConfigChange(field.key, '');
|
|
||||||
}}
|
|
||||||
class="absolute top-1/2 right-2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
|
|
||||||
aria-label="Reset to default"
|
|
||||||
title="Reset to default"
|
|
||||||
>
|
|
||||||
<RotateCcw class="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
{@html field.help || SETTING_CONFIG_INFO[field.key]}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{:else if field.type === SettingsFieldType.TEXTAREA}
|
|
||||||
{#if field.label}
|
|
||||||
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
|
|
||||||
{field.label}
|
|
||||||
|
|
||||||
{#if field.isExperimental}
|
|
||||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{/if}
|
|
||||||
</Label>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
id={field.key}
|
|
||||||
value={String(localConfig[field.key] ?? '')}
|
|
||||||
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
|
||||||
placeholder=""
|
|
||||||
class="min-h-[10rem] w-full md:max-w-3xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
{field.help || SETTING_CONFIG_INFO[field.key]}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if field.key === SETTINGS_KEYS.SYSTEM_MESSAGE}
|
|
||||||
<div class="mt-3 flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="showSystemMessage"
|
|
||||||
checked={Boolean(localConfig.showSystemMessage ?? true)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onConfigChange(SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, Boolean(checked))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
|
|
||||||
Show system message in conversations
|
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
{#if isCustomRealTime}
|
||||||
{/if}
|
<SettingsChatParameterSourceIndicator />
|
||||||
{:else if field.type === SettingsFieldType.SELECT}
|
|
||||||
{@const selectedOption = field.options?.find(
|
|
||||||
(opt: { value: string; label: string; icon?: Component }) =>
|
|
||||||
opt.value === localConfig[field.key]
|
|
||||||
)}
|
|
||||||
{@const currentValue = localConfig[field.key]}
|
|
||||||
{@const serverDefault = currentModelParams[field.key]}
|
|
||||||
{@const isCustomRealTime = (() => {
|
|
||||||
if (serverDefault == null) return false;
|
|
||||||
if (currentValue === '' || currentValue === undefined) return false;
|
|
||||||
return currentValue !== serverDefault;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
|
|
||||||
{field.label}
|
|
||||||
|
|
||||||
{#if field.isExperimental}
|
|
||||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
{/if}
|
{/if}
|
||||||
</Label>
|
</div>
|
||||||
{#if isCustomRealTime}
|
|
||||||
<SettingsChatParameterSourceIndicator />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select.Root
|
<div class="relative w-full">
|
||||||
type="single"
|
<Input
|
||||||
value={currentValue}
|
id={field.key}
|
||||||
onValueChange={(value) => {
|
type={field.isPositiveInteger ? 'number' : 'text'}
|
||||||
if (field.key === SETTINGS_KEYS.THEME && value && onThemeChange) {
|
{...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
|
||||||
onThemeChange(value);
|
value={currentValue}
|
||||||
} else {
|
oninput={(e) => {
|
||||||
onConfigChange(field.key, value);
|
// Update local config immediately for real-time badge feedback
|
||||||
}
|
onConfigChange(field.key, e.currentTarget.value);
|
||||||
}}
|
}}
|
||||||
>
|
placeholder={currentModelParams[field.key] != null
|
||||||
<div class="relative w-full md:w-auto">
|
? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
|
||||||
<Select.Trigger class="w-full">
|
: ''}
|
||||||
<div class="flex items-center gap-2">
|
class="w-full {isCustomRealTime ? 'pr-8' : ''}"
|
||||||
{#if selectedOption?.icon}
|
/>
|
||||||
{@const IconComponent = selectedOption.icon}
|
|
||||||
<IconComponent class="h-4 w-4" />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{selectedOption?.label || `Select ${field.label.toLowerCase()}`}
|
|
||||||
</div>
|
|
||||||
</Select.Trigger>
|
|
||||||
{#if isCustomRealTime}
|
{#if isCustomRealTime}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -205,7 +100,7 @@
|
|||||||
settingsStore.resetParameterToServerDefault(field.key);
|
settingsStore.resetParameterToServerDefault(field.key);
|
||||||
onConfigChange(field.key, '');
|
onConfigChange(field.key, '');
|
||||||
}}
|
}}
|
||||||
class="absolute top-1/2 right-8 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
|
class="absolute top-1/2 right-2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||||
aria-label="Reset to default"
|
aria-label="Reset to default"
|
||||||
title="Reset to default"
|
title="Reset to default"
|
||||||
>
|
>
|
||||||
@@ -213,55 +108,163 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Select.Content>
|
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
{#if field.options}
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
{#each field.options as option (option.value)}
|
{@html field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
<Select.Item value={option.value} label={option.label}>
|
</p>
|
||||||
<div class="flex items-center gap-2">
|
{/if}
|
||||||
{#if option.icon}
|
{:else if field.type === SettingsFieldType.TEXTAREA}
|
||||||
{@const IconComponent = option.icon}
|
{#if field.label}
|
||||||
<IconComponent class="h-4 w-4" />
|
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
|
||||||
{/if}
|
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
|
||||||
{field.help || SETTING_CONFIG_INFO[field.key]}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{:else if field.type === SettingsFieldType.CHECKBOX}
|
|
||||||
<div class="flex items-start space-x-3">
|
|
||||||
<Checkbox
|
|
||||||
id={field.key}
|
|
||||||
checked={Boolean(localConfig[field.key])}
|
|
||||||
onCheckedChange={(checked) => onConfigChange(field.key, checked)}
|
|
||||||
class="mt-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label
|
|
||||||
for={field.key}
|
|
||||||
class="flex cursor-pointer items-center gap-1.5 pt-1 pb-0.5 text-sm leading-none font-medium"
|
|
||||||
>
|
|
||||||
{field.label}
|
{field.label}
|
||||||
|
|
||||||
{#if field.isExperimental}
|
{#if field.isExperimental}
|
||||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</Label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
<Textarea
|
||||||
<p class="text-xs text-muted-foreground">
|
id={field.key}
|
||||||
{field.help || SETTING_CONFIG_INFO[field.key]}
|
value={String(localConfig[field.key] ?? '')}
|
||||||
</p>
|
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
||||||
|
placeholder=""
|
||||||
|
class="min-h-[10rem] w-full md:max-w-3xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if field.key === SETTINGS_KEYS.SYSTEM_MESSAGE}
|
||||||
|
<div class="mt-3 flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showSystemMessage"
|
||||||
|
checked={Boolean(localConfig.showSystemMessage ?? true)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onConfigChange(SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, Boolean(checked))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
|
||||||
|
Show system message in conversations
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if field.type === SettingsFieldType.SELECT}
|
||||||
|
{@const selectedOption = field.options?.find(
|
||||||
|
(opt: { value: string; label: string; icon?: Component }) =>
|
||||||
|
opt.value === localConfig[field.key]
|
||||||
|
)}
|
||||||
|
{@const currentValue = localConfig[field.key]}
|
||||||
|
{@const serverDefault = currentModelParams[field.key]}
|
||||||
|
{@const isCustomRealTime = (() => {
|
||||||
|
if (serverDefault == null) return false;
|
||||||
|
if (currentValue === '' || currentValue === undefined) return false;
|
||||||
|
return currentValue !== serverDefault;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
{field.label}
|
||||||
|
|
||||||
|
{#if field.isExperimental}
|
||||||
|
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
</Label>
|
||||||
|
{#if isCustomRealTime}
|
||||||
|
<SettingsChatParameterSourceIndicator />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
<Select.Root
|
||||||
</div>
|
type="single"
|
||||||
|
value={currentValue}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (field.key === SETTINGS_KEYS.THEME && value && onThemeChange) {
|
||||||
|
onThemeChange(value);
|
||||||
|
} else {
|
||||||
|
onConfigChange(field.key, value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="relative w-full md:w-auto">
|
||||||
|
<Select.Trigger class="w-full">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if selectedOption?.icon}
|
||||||
|
{@const IconComponent = selectedOption.icon}
|
||||||
|
<IconComponent class={ICON_CLASS_DEFAULT} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{selectedOption?.label || `Select ${field.label.toLowerCase()}`}
|
||||||
|
</div>
|
||||||
|
</Select.Trigger>
|
||||||
|
{#if isCustomRealTime}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
settingsStore.resetParameterToServerDefault(field.key);
|
||||||
|
onConfigChange(field.key, '');
|
||||||
|
}}
|
||||||
|
class="absolute top-1/2 right-8 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||||
|
aria-label="Reset to default"
|
||||||
|
title="Reset to default"
|
||||||
|
>
|
||||||
|
<RotateCcw class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Select.Content>
|
||||||
|
{#if field.options}
|
||||||
|
{#each field.options as option (option.value)}
|
||||||
|
<Select.Item value={option.value} label={option.label}>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if option.icon}
|
||||||
|
{@const IconComponent = option.icon}
|
||||||
|
<IconComponent class={ICON_CLASS_DEFAULT} />
|
||||||
|
{/if}
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{:else if field.type === SettingsFieldType.CHECKBOX}
|
||||||
|
<div class="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id={field.key}
|
||||||
|
checked={Boolean(localConfig[field.key])}
|
||||||
|
onCheckedChange={(checked) => onConfigChange(field.key, checked)}
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label
|
||||||
|
for={field.key}
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 pt-1 pb-0.5 text-sm leading-none font-medium"
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
|
||||||
|
{#if field.isExperimental}
|
||||||
|
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{field.help || SETTING_CONFIG_INFO[field.key]}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
import { Button, type ButtonVariant } from '$lib/components/ui/button';
|
import { Button, type ButtonVariant } from '$lib/components/ui/button';
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
<p class="mb-4 text-sm text-muted-foreground">{description}</p>
|
<p class="mb-4 text-sm text-muted-foreground">{description}</p>
|
||||||
|
|
||||||
<Button class={sectionButtonClass} {onclick} variant={sectionButtonVariant}>
|
<Button class={sectionButtonClass} {onclick} variant={sectionButtonVariant}>
|
||||||
<IconComponent class="mr-2 h-4 w-4" />
|
<IconComponent class="mr-2 {ICON_CLASS_DEFAULT}" />
|
||||||
|
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
|
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
|
||||||
{#if group.source === 'mcp'}
|
{#if group.source === 'mcp'}
|
||||||
<McpServerIdentity
|
<McpServerIdentity
|
||||||
iconClass="h-4 w-4"
|
iconClass={ICON_CLASS_DEFAULT}
|
||||||
iconRounded="rounded-sm"
|
iconRounded="rounded-sm"
|
||||||
showVersion={false}
|
showVersion={false}
|
||||||
displayName={group.label}
|
displayName={group.label}
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isEnabled}
|
checked={isEnabled}
|
||||||
onCheckedChange={() => toolsStore.toggleTool(entry.key)}
|
onCheckedChange={() => toolsStore.toggleTool(entry.key)}
|
||||||
class="h-4 w-4"
|
class={ICON_CLASS_DEFAULT}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@
|
|||||||
permissionsStore.allowTool(permissionKey);
|
permissionsStore.allowTool(permissionKey);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class="h-4 w-4"
|
class={ICON_CLASS_DEFAULT}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Settings } from '@lucide/svelte';
|
import { Settings } from '@lucide/svelte';
|
||||||
import type { SettingsSection, SettingsSectionTitle } from '$lib/constants';
|
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sections: SettingsSection[];
|
sections: SettingsSection[];
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
: 'text-muted-foreground'}"
|
: 'text-muted-foreground'}"
|
||||||
href={getHref(section)}
|
href={getHref(section)}
|
||||||
>
|
>
|
||||||
<section.icon class="h-4 w-4" />
|
<section.icon class={ICON_CLASS_DEFAULT} />
|
||||||
<span class="ml-2">{section.title}</span>
|
<span class="ml-2">{section.title}</span>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
: 'text-muted-foreground'}"
|
: 'text-muted-foreground'}"
|
||||||
onclick={() => onSectionChange?.(section.title)}
|
onclick={() => onSectionChange?.(section.title)}
|
||||||
>
|
>
|
||||||
<section.icon class="h-4 w-4" />
|
<section.icon class={ICON_CLASS_DEFAULT} />
|
||||||
<span class="ml-2">{section.title}</span>
|
<span class="ml-2">{section.title}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||||
import { Settings, ChevronLeft, ChevronRight } from '@lucide/svelte';
|
import { Settings, ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import type { SettingsSection, SettingsSectionTitle } from '$lib/constants';
|
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
|
||||||
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
|
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
onclick={carousel.scrollLeft}
|
onclick={carousel.scrollLeft}
|
||||||
aria-label="Scroll left"
|
aria-label="Scroll left"
|
||||||
>
|
>
|
||||||
<ChevronLeft class="h-4 w-4" />
|
<ChevronLeft class={ICON_CLASS_DEFAULT} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section.icon class="h-4 w-4 flex-shrink-0" />
|
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
|
||||||
<span>{section.title}</span>
|
<span>{section.title}</span>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -85,7 +86,7 @@
|
|||||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section.icon class="h-4 w-4 flex-shrink-0" />
|
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
|
||||||
<span>{section.title}</span>
|
<span>{section.title}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -100,7 +101,7 @@
|
|||||||
onclick={carousel.scrollRight}
|
onclick={carousel.scrollRight}
|
||||||
aria-label="Scroll right"
|
aria-label="Scroll right"
|
||||||
>
|
>
|
||||||
<ChevronRight class="h-4 w-4" />
|
<ChevronRight class={ICON_CLASS_DEFAULT} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,8 +22,6 @@
|
|||||||
|
|
||||||
let { class: className }: Props = $props();
|
let { class: className }: Props = $props();
|
||||||
|
|
||||||
// Every configured server is listed; `enabled` is an on/off state,
|
|
||||||
// not a visibility filter, so a disabled server stays toggleable.
|
|
||||||
let servers = $derived(mcpStore.getServers());
|
let servers = $derived(mcpStore.getServers());
|
||||||
|
|
||||||
let isAddingServer = $state(false);
|
let isAddingServer = $state(false);
|
||||||
@@ -126,6 +124,9 @@
|
|||||||
const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
|
const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
|
||||||
await conversationsStore.toggleMcpServerForChat(server.id);
|
await conversationsStore.toggleMcpServerForChat(server.id);
|
||||||
if (!wasEnabled) {
|
if (!wasEnabled) {
|
||||||
|
// Promote the connection so tools/prompts/resources become
|
||||||
|
// available right away instead of waiting for the next chat-init.
|
||||||
|
await mcpStore.runHealthCheck(server, true);
|
||||||
toolsStore.enableAllToolsForServer(server.id);
|
toolsStore.enableAllToolsForServer(server.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,7 +2,31 @@ import type { AgenticConfig } from '$lib/types/agentic';
|
|||||||
|
|
||||||
export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/;
|
export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/;
|
||||||
|
|
||||||
export const NEWLINE_SEPARATOR = '\n';
|
// JSON detection: trimmed content opens with an object or array literal.
|
||||||
|
export const TOOL_RESULT_JSON_OPEN_REGEX = /^[[{]/;
|
||||||
|
|
||||||
|
// Markdown structural markers used by `looksLikeMarkdown`. Inline / line-level.
|
||||||
|
export const MARKDOWN_CODE_FENCE_REGEX = /^(```|~~~)/m;
|
||||||
|
export const MARKDOWN_ATX_HEADING_REGEX = /^#{1,6}\s+\S/;
|
||||||
|
export const MARKDOWN_BLOCKQUOTE_REGEX = /^>\s+\S/;
|
||||||
|
export const MARKDOWN_LIST_BULLET_REGEX = /^\s*[-*+]\s+\S/;
|
||||||
|
export const MARKDOWN_LIST_NUMBERED_REGEX = /^\s*\d+[.)]\s+\S/;
|
||||||
|
export const MARKDOWN_LINK_REGEX = /\[[^\]\n]+\]\([^)\s]+\)/;
|
||||||
|
export const MARKDOWN_BOLD_REGEX = /\*\*[^*\n]+\*\*|__[^_\n]+__/;
|
||||||
|
export const MARKDOWN_TABLE_SEPARATOR_REGEX = /^\s*\|?[\s:|-]+\|?\s*$/;
|
||||||
|
|
||||||
|
// Search-summary wire format used by file-glob and grep tools:
|
||||||
|
// <matches>
|
||||||
|
// ---
|
||||||
|
// Total matches: N
|
||||||
|
export const SEARCH_SUMMARY_SEPARATOR = '---\n';
|
||||||
|
export const SEARCH_SUMMARY_TOTAL_REGEX = /Total matches:\s*(\d+)/;
|
||||||
|
|
||||||
|
// Separator rendered between stats in the tool-result footer (e.g. between a
|
||||||
|
// result message and the byte/edit count). Plain ASCII spaces bracket a hyphen
|
||||||
|
// so the whole " - " sits on one visual line even when the surrounding text
|
||||||
|
// wraps mid-paragraph.
|
||||||
|
export const RESULT_STAT_SEPARATOR = ' - ';
|
||||||
|
|
||||||
export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
|
export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -1,2 +1,16 @@
|
|||||||
export const AUTO_SCROLL_INTERVAL = 100;
|
export const AUTO_SCROLL_INTERVAL = 100;
|
||||||
|
// Chat main view: tight threshold because scroll-here events come from
|
||||||
|
// discrete assistant-message appends.
|
||||||
export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;
|
export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;
|
||||||
|
// Reasoning block: stickier because reasoning fires many small
|
||||||
|
// incremental DOM writes that easily drift a few pixels off bottom.
|
||||||
|
export const REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX = 64;
|
||||||
|
// Syntax-highlighted code: stickier than the chat main view because line
|
||||||
|
// wrap reflows while the highlight.js pass settles can drift a few pixels
|
||||||
|
// off bottom.
|
||||||
|
export const SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX = 32;
|
||||||
|
// Streaming tool output (e.g. exec_shell_command): shell commands produce
|
||||||
|
// lots of small line writes and the exit-code line appended at the tail
|
||||||
|
// past the last user-visible frame is what triggers DOM drift, so use a
|
||||||
|
// threshold generous enough to capture that tail flush.
|
||||||
|
export const TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX = 64;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Registry of built-in and frontend (browser) tools whose renderer
|
||||||
|
// shows a recognizable icon and friendly label inline in the chat UI.
|
||||||
|
//
|
||||||
|
// To add a new built-in tool, add an entry to BUILTIN_TOOL_UI. To give a
|
||||||
|
// tool a custom title or body renderer, add a dedicated component under
|
||||||
|
// ChatMessageToolCall/ and route it in ChatMessageToolCallBlock.svelte
|
||||||
|
// (see ChatMessageToolCallBlockGetDatetime and
|
||||||
|
// ChatMessageToolCallBlockSearchResults for prior art).
|
||||||
|
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import {
|
||||||
|
Braces,
|
||||||
|
Clock,
|
||||||
|
FilePen,
|
||||||
|
FilePlus,
|
||||||
|
FileSearch,
|
||||||
|
FileText,
|
||||||
|
SearchCode,
|
||||||
|
Terminal
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import { BuiltInTool, ToolSource } from '$lib/enums';
|
||||||
|
|
||||||
|
export interface BuiltinToolUiEntry {
|
||||||
|
icon: Component;
|
||||||
|
label: string;
|
||||||
|
source: ToolSource.BUILTIN | ToolSource.FRONTEND;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUILTIN_TOOL_UI: Readonly<Record<BuiltInTool, BuiltinToolUiEntry>> = {
|
||||||
|
[BuiltInTool.READ_FILE]: { icon: FileText, label: 'Read file', source: ToolSource.BUILTIN },
|
||||||
|
[BuiltInTool.EDIT_FILE]: { icon: FilePen, label: 'Edit file', source: ToolSource.BUILTIN },
|
||||||
|
[BuiltInTool.WRITE_FILE]: { icon: FilePlus, label: 'Write file', source: ToolSource.BUILTIN },
|
||||||
|
[BuiltInTool.FILE_GLOB_SEARCH]: {
|
||||||
|
icon: FileSearch,
|
||||||
|
label: 'Search files',
|
||||||
|
source: ToolSource.BUILTIN
|
||||||
|
},
|
||||||
|
[BuiltInTool.GREP_SEARCH]: {
|
||||||
|
icon: SearchCode,
|
||||||
|
label: 'Search in files',
|
||||||
|
source: ToolSource.BUILTIN
|
||||||
|
},
|
||||||
|
[BuiltInTool.GET_DATETIME]: { icon: Clock, label: 'Current time', source: ToolSource.BUILTIN },
|
||||||
|
[BuiltInTool.EXEC_SHELL_COMMAND]: {
|
||||||
|
icon: Terminal,
|
||||||
|
label: 'Run command',
|
||||||
|
source: ToolSource.BUILTIN
|
||||||
|
},
|
||||||
|
[BuiltInTool.RUN_JAVASCRIPT]: {
|
||||||
|
icon: Braces,
|
||||||
|
label: 'Run JavaScript',
|
||||||
|
source: ToolSource.FRONTEND
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getBuiltinToolUi(toolName: string | undefined): BuiltinToolUiEntry | null {
|
||||||
|
if (!toolName) return null;
|
||||||
|
return (BUILTIN_TOOL_UI as Record<string, BuiltinToolUiEntry>)[toolName] ?? null;
|
||||||
|
}
|
||||||
@@ -6,3 +6,19 @@ export const AMPERSAND_REGEX = /&/g;
|
|||||||
export const LT_REGEX = /</g;
|
export const LT_REGEX = /</g;
|
||||||
export const GT_REGEX = />/g;
|
export const GT_REGEX = />/g;
|
||||||
export const FENCE_PATTERN = /^```|\n```/g;
|
export const FENCE_PATTERN = /^```|\n```/g;
|
||||||
|
|
||||||
|
// Whitespace-only empty lines (between start of string and first non-empty line).
|
||||||
|
// Used by trimCodePadding to drop leading/trailing phantom blank rows from LLM
|
||||||
|
// payload wrappers without touching internal blank lines.
|
||||||
|
export const TRIM_LEADING_PADDING_REGEX = /^(?:[ \t]*\n)+/;
|
||||||
|
export const TRIM_TRAILING_PADDING_REGEX = /(?:\n[ \t]*)+$/;
|
||||||
|
|
||||||
|
// Matches either Unix or Windows path separators so `String.split(REGEX)` can
|
||||||
|
// recover the trailing file-name segment from either `/foo/bar.txt` or
|
||||||
|
// `C:\foo\bar.txt`. Used wherever a parameter accepts a user-supplied path.
|
||||||
|
export const FILE_PATH_SEPARATOR_REGEX = /[\\/]/;
|
||||||
|
|
||||||
|
// Matches the `text:` prefix that file-type identifiers use to denote a
|
||||||
|
// plain-text language (e.g. `text:typescript`). Used by tool-call renderers
|
||||||
|
// to recover the underlying highlight.js language.
|
||||||
|
export const TEXT_LANGUAGE_PREFIX_REGEX = /^text:/;
|
||||||
|
|||||||
@@ -18,3 +18,9 @@ export const PANEL_CLASSES = `
|
|||||||
|
|
||||||
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
|
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
|
||||||
export const DIALOG_SUBMENU_CONTENT = 'w-60';
|
export const DIALOG_SUBMENU_CONTENT = 'w-60';
|
||||||
|
|
||||||
|
/** Default Tailwind size class for inline icon components (lucide, etc.). */
|
||||||
|
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
|
||||||
|
|
||||||
|
/** Icon size + spinning animation; used for live-streaming tool indicators. */
|
||||||
|
export const ICON_CLASS_SPIN = 'h-4 w-4 animate-spin';
|
||||||
|
|||||||
@@ -6,30 +6,3 @@ export const MEDIUM_DURATION_THRESHOLD = 10;
|
|||||||
|
|
||||||
/** Default display value when no performance time is available */
|
/** Default display value when no performance time is available */
|
||||||
export const DEFAULT_PERFORMANCE_TIME = '0s';
|
export const DEFAULT_PERFORMANCE_TIME = '0s';
|
||||||
|
|
||||||
/** Max length before reasoning preview is truncated */
|
|
||||||
export const MAX_PREVIEW_LENGTH = 120;
|
|
||||||
|
|
||||||
export const STRIP_MARKDOWN_CAPTURE_PATTERNS: [RegExp, string][] = [
|
|
||||||
[/^```(.*)/gm, '$1'],
|
|
||||||
[/(.*)```$/gm, '$1'],
|
|
||||||
[/`([^`]*)`/g, '$1'],
|
|
||||||
[/\*\*(.*?)\*\*/g, '$1'],
|
|
||||||
[/__(.*?)__/g, '$1'],
|
|
||||||
[/\*(.*?)\*/g, '$1'],
|
|
||||||
[/_(.*?)_/g, '$1']
|
|
||||||
];
|
|
||||||
|
|
||||||
/* eslint-disable no-misleading-character-class */
|
|
||||||
export const STRIP_MARKDOWN_INLINE_REGEX = new RegExp(
|
|
||||||
[
|
|
||||||
'<[^>]*>',
|
|
||||||
'^>\\s*',
|
|
||||||
'^#{1,6}\\s+',
|
|
||||||
'^[\\s]*[-*+]\\s+',
|
|
||||||
'^[\\s]*\\d+[.)]\\s+',
|
|
||||||
'[\\u{1F600}-\\u{1F64F}\\u{1F300}-\\u{1F5FF}\\u{1F680}-\\u{1F6FF}\\u{1F1E0}-\\u{1F1FF}\\u{2600}-\\u{26FF}\\u{2700}-\\u{27BF}\\u{FE00}-\\u{FE0F}\\u{1F900}-\\u{1F9FF}\\u{1FA00}-\\u{1FA6F}\\u{1FA70}-\\u{1FAFF}\\u{200D}\\u{20E3}\\u{231A}-\\u{231B}\\u{23E9}-\\u{23F3}\\u{23F8}-\\u{23FA}\\u{25AA}-\\u{25AB}\\u{25B6}\\u{25C0}\\u{25FB}-\\u{25FE}\\u{2934}-\\u{2935}\\u{2B05}-\\u{2B07}\\u{2B1B}-\\u{2B1C}\\u{2B50}\\u{2B55}\\u{3030}\\u{303D}\\u{3297}\\u{3299}]'
|
|
||||||
].join('|'),
|
|
||||||
'gmu'
|
|
||||||
);
|
|
||||||
/* eslint-enable no-misleading-character-class */
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ export * from './attachment-labels';
|
|||||||
export * from './database';
|
export * from './database';
|
||||||
export * from './reasoning-effort';
|
export * from './reasoning-effort';
|
||||||
export * from './reasoning-effort-tokens';
|
export * from './reasoning-effort-tokens';
|
||||||
|
export * from './recommended-mcp-servers';
|
||||||
export * from './storage';
|
export * from './storage';
|
||||||
export * from './attachment-menu';
|
export * from './attachment-menu';
|
||||||
export * from './auto-scroll';
|
export * from './auto-scroll';
|
||||||
export * from './binary-detection';
|
export * from './binary-detection';
|
||||||
|
export * from './built-in-tools';
|
||||||
export * from './cache';
|
export * from './cache';
|
||||||
export * from './chat-form';
|
export * from './chat-form';
|
||||||
export * from './cli-flags';
|
export * from './cli-flags';
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export const IMAGE_NOT_ERROR_BOUND_SELECTOR = 'img:not([data-error-bound])';
|
|||||||
export const DATA_ERROR_BOUND_ATTR = 'errorBound';
|
export const DATA_ERROR_BOUND_ATTR = 'errorBound';
|
||||||
export const DATA_ERROR_HANDLED_ATTR = 'errorHandled';
|
export const DATA_ERROR_HANDLED_ATTR = 'errorHandled';
|
||||||
export const BOOL_TRUE_STRING = 'true';
|
export const BOOL_TRUE_STRING = 'true';
|
||||||
|
export const BOOL_FALSE_STRING = 'false';
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export const MCP_PARTIAL_REDACT_HEADERS = new Map<string, number>([
|
|||||||
['mcp-session-id', MCP_SESSION_ID_VISIBLE_CHARS]
|
['mcp-session-id', MCP_SESSION_ID_VISIBLE_CHARS]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** Bearer scheme prefix used for Authorization headers (RFC 6750) */
|
||||||
|
export const BEARER_PREFIX = 'Bearer ';
|
||||||
|
|
||||||
|
/** Canonical casing for the Authorization header (RFC 7235) */
|
||||||
|
export const AUTHORIZATION_HEADER = 'Authorization';
|
||||||
|
|
||||||
/** Header names whose values should be redacted in diagnostic logs */
|
/** Header names whose values should be redacted in diagnostic logs */
|
||||||
export const REDACTED_HEADERS = new Set([
|
export const REDACTED_HEADERS = new Set([
|
||||||
'authorization',
|
'authorization',
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { RecommendedMCPServer } from '$lib/types';
|
||||||
|
|
||||||
|
// Suggested MCP servers shown as opt-in cards in the "Add New Server" dialog.
|
||||||
|
// Rendering these cards never reaches the upstream domain - favicons come
|
||||||
|
// from local bundles in static/recommended-mcp/ and the URL is only used
|
||||||
|
// after the user clicks Add.
|
||||||
|
export const RECOMMENDED_MCP_SERVERS: RecommendedMCPServer[] = [
|
||||||
|
{
|
||||||
|
id: 'exa',
|
||||||
|
name: 'Exa',
|
||||||
|
description: 'Search the web and fetch full page content as clean markdown.',
|
||||||
|
url: 'https://mcp.exa.ai/mcp',
|
||||||
|
iconUrl: '/recommended-mcp/exa.ico'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'huggingface',
|
||||||
|
name: 'Hugging Face',
|
||||||
|
description: 'Search and browse AI models, datasets, spaces, and docs on the Hugging Face Hub.',
|
||||||
|
url: 'https://huggingface.co/mcp',
|
||||||
|
iconUrl: '/recommended-mcp/huggingface.ico'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'github',
|
||||||
|
name: 'GitHub',
|
||||||
|
description: 'Search repositories, issues, pull requests and interact with code on GitHub.',
|
||||||
|
url: 'https://api.githubcopilot.com/mcp',
|
||||||
|
iconUrlLight: '/recommended-mcp/github-light.png',
|
||||||
|
iconUrlDark: '/recommended-mcp/github-dark.png',
|
||||||
|
needsAuthorization: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'context7',
|
||||||
|
name: 'Context7',
|
||||||
|
description: 'Browse up-to-date documentation and code examples for libraries and frameworks.',
|
||||||
|
url: 'https://mcp.context7.com/mcp',
|
||||||
|
iconUrl: '/recommended-mcp/context7.png'
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { JsonSchemaType, ToolCallType } from '$lib/enums';
|
import { BuiltInTool, JsonSchemaType, ToolCallType } from '$lib/enums';
|
||||||
import type { OpenAIToolDefinition } from '$lib/types';
|
import type { OpenAIToolDefinition } from '$lib/types';
|
||||||
|
|
||||||
export const SANDBOX_TOOL_NAME = 'run_javascript';
|
export const SANDBOX_TOOL_NAME = BuiltInTool.RUN_JAVASCRIPT;
|
||||||
|
|
||||||
export const SANDBOX_TIMEOUT_MS_DEFAULT = 10000;
|
export const SANDBOX_TIMEOUT_MS_DEFAULT = 10000;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const SETTINGS_KEYS = {
|
|||||||
MAX_IMAGE_RESOLUTION: 'maxImageMPixels',
|
MAX_IMAGE_RESOLUTION: 'maxImageMPixels',
|
||||||
// Display
|
// Display
|
||||||
SHOW_MESSAGE_STATS: 'showMessageStats',
|
SHOW_MESSAGE_STATS: 'showMessageStats',
|
||||||
|
SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
|
||||||
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
|
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
|
||||||
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
|
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
|
||||||
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
|
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
|||||||
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
|
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
|
||||||
label: 'Show message generation statistics',
|
label: 'Show message generation statistics',
|
||||||
help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
|
help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
|
||||||
defaultValue: true,
|
defaultValue: false,
|
||||||
type: SettingsFieldType.CHECKBOX,
|
type: SettingsFieldType.CHECKBOX,
|
||||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||||
sync: {
|
sync: {
|
||||||
@@ -231,6 +231,15 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
|||||||
paramType: SyncableParameterType.BOOLEAN
|
paramType: SyncableParameterType.BOOLEAN
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: SETTINGS_KEYS.SHOW_AGENTIC_TURN_STATS,
|
||||||
|
label: 'Show statistics for individual agentic turns',
|
||||||
|
help: 'Display per-turn statistics (tokens, duration) under each turn in agentic responses. Shown only when "Show message generation statistics" is enabled.',
|
||||||
|
defaultValue: false,
|
||||||
|
type: SettingsFieldType.CHECKBOX,
|
||||||
|
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||||
|
dependsOn: SETTINGS_KEYS.SHOW_MESSAGE_STATS
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
|
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
|
||||||
label: 'Show thought in progress',
|
label: 'Show thought in progress',
|
||||||
@@ -789,9 +798,6 @@ export const SETTING_CONFIG_INFO: Record<string, string> = Object.fromEntries(
|
|||||||
/** Theme select options. */
|
/** Theme select options. */
|
||||||
export const SETTINGS_COLOR_MODES_CONFIG = COLOR_MODE_OPTIONS;
|
export const SETTINGS_COLOR_MODES_CONFIG = COLOR_MODE_OPTIONS;
|
||||||
|
|
||||||
export type { SettingsSectionTitle } from '$lib/types';
|
|
||||||
export type { SettingsSection } from '$lib/types';
|
|
||||||
|
|
||||||
/** Sidebar sections + field configs (as consumed by UI). */
|
/** Sidebar sections + field configs (as consumed by UI). */
|
||||||
export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
|
export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
|
||||||
...Object.values(SETTINGS_REGISTRY).map((section) => ({
|
...Object.values(SETTINGS_REGISTRY).map((section) => ({
|
||||||
@@ -804,6 +810,7 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
|
|||||||
type: s.type,
|
type: s.type,
|
||||||
isExperimental: s.isExperimental,
|
isExperimental: s.isExperimental,
|
||||||
isPositiveInteger: s.isPositiveInteger,
|
isPositiveInteger: s.isPositiveInteger,
|
||||||
|
dependsOn: s.dependsOn,
|
||||||
help: s.help,
|
help: s.help,
|
||||||
options: s.options
|
options: s.options
|
||||||
}))
|
}))
|
||||||
@@ -832,5 +839,3 @@ export const SYNCABLE_PARAMETERS: SyncableParameter[] = getAllSettings()
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const SETTINGS_FALLBACK_EXIT_ROUTE = ROUTES.START;
|
export const SETTINGS_FALLBACK_EXIT_ROUTE = ROUTES.START;
|
||||||
|
|
||||||
export { SETTINGS_KEYS } from './settings-keys';
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabled
|
|||||||
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
|
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
|
||||||
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
|
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
|
||||||
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
|
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
|
||||||
|
export const DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.dismissedRecommendedMcpServers`;
|
||||||
|
|
||||||
/** Key prefix for per-conversation resumable stream state, conversationId is appended */
|
/** Key prefix for per-conversation resumable stream state, conversationId is appended */
|
||||||
export const STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX = `${STORAGE_APP_NAME}.streamResume.`;
|
export const STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX = `${STORAGE_APP_NAME}.streamResume.`;
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
|||||||
export const ICON_STRIP_TRANSITION_DURATION = 150;
|
export const ICON_STRIP_TRANSITION_DURATION = 150;
|
||||||
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
|
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
|
||||||
|
|
||||||
|
/** Max height for tool-result code blocks (json / source / diff / streaming code). */
|
||||||
|
export const MAX_HEIGHT_CODE_BLOCK = '22rem';
|
||||||
|
|
||||||
export interface DesktopIconStripItem {
|
export interface DesktopIconStripItem {
|
||||||
icon: Component;
|
icon: Component;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
|
|||||||
@@ -184,3 +184,6 @@ function buildSuffixSet(suffixes: Record<string, readonly string[]>): Set<string
|
|||||||
|
|
||||||
export const TWO_PART_PUBLIC_SUFFIXES = buildSuffixSet(ccTLD_PREFIXES);
|
export const TWO_PART_PUBLIC_SUFFIXES = buildSuffixSet(ccTLD_PREFIXES);
|
||||||
export const WILDCARD_PUBLIC_SUFFIXES = buildSuffixSet(WILDCARD_BASES);
|
export const WILDCARD_PUBLIC_SUFFIXES = buildSuffixSet(WILDCARD_BASES);
|
||||||
|
|
||||||
|
// Matches one or more trailing "/" characters at the end of a URL/path.
|
||||||
|
export const TRAILING_SLASHES_REGEX = /\/+$/;
|
||||||
|
|||||||
@@ -25,3 +25,21 @@ export enum ContinueIntentKind {
|
|||||||
RERUN_TURN = 'rerun_turn',
|
RERUN_TURN = 'rerun_turn',
|
||||||
NEXT_TURN = 'next_turn'
|
NEXT_TURN = 'next_turn'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderer tier for a tool-result blob shown in the default tool-call block.
|
||||||
|
*/
|
||||||
|
export enum ToolResultKind {
|
||||||
|
JSON = 'json',
|
||||||
|
MARKDOWN = 'markdown',
|
||||||
|
TEXT = 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line classification for the unified-diff renderer of `edit_file` results.
|
||||||
|
*/
|
||||||
|
export enum DiffLineKind {
|
||||||
|
CONTEXT = 'context',
|
||||||
|
ADD = 'add',
|
||||||
|
REMOVE = 'remove'
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ export {
|
|||||||
AttachmentItemVisibleWhen
|
AttachmentItemVisibleWhen
|
||||||
} from './attachment.enums';
|
} from './attachment.enums';
|
||||||
|
|
||||||
export { AgenticSectionType, ContinueIntentKind, ToolCallType } from './agentic.enums';
|
export {
|
||||||
|
AgenticSectionType,
|
||||||
|
ContinueIntentKind,
|
||||||
|
DiffLineKind,
|
||||||
|
ToolResultKind,
|
||||||
|
ToolCallType
|
||||||
|
} from './agentic.enums';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ChatMessageStatsView,
|
ChatMessageStatsView,
|
||||||
@@ -64,6 +70,6 @@ export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol }
|
|||||||
|
|
||||||
export { KeyboardKey } from './keyboard.enums';
|
export { KeyboardKey } from './keyboard.enums';
|
||||||
|
|
||||||
export { ToolSource, ToolPermissionDecision, ToolResponseField } from './tools.enums';
|
export { BuiltInTool, ToolSource, ToolPermissionDecision, ToolResponseField } from './tools.enums';
|
||||||
|
|
||||||
export { SplashOrientation } from './splash.enums';
|
export { SplashOrientation } from './splash.enums';
|
||||||
|
|||||||
@@ -16,3 +16,22 @@ export enum ToolResponseField {
|
|||||||
PLAIN_TEXT = 'plain_text_response',
|
PLAIN_TEXT = 'plain_text_response',
|
||||||
ERROR = 'error'
|
ERROR = 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire-format identifiers for built-in and frontend tools. The string
|
||||||
|
* value matches what the model emits in tool call names, so comparing
|
||||||
|
* against `BuiltInTool.READ_FILE` is equivalent to comparing against the
|
||||||
|
* raw `'read_file'` literal - the enum just keeps the two in lock-step
|
||||||
|
* and gives TypeScript a single source of truth for autocomplete / rename
|
||||||
|
* support.
|
||||||
|
*/
|
||||||
|
export enum BuiltInTool {
|
||||||
|
READ_FILE = 'read_file',
|
||||||
|
EDIT_FILE = 'edit_file',
|
||||||
|
WRITE_FILE = 'write_file',
|
||||||
|
GET_DATETIME = 'get_datetime',
|
||||||
|
FILE_GLOB_SEARCH = 'file_glob_search',
|
||||||
|
GREP_SEARCH = 'grep_search',
|
||||||
|
EXEC_SHELL_COMMAND = 'exec_shell_command',
|
||||||
|
RUN_JAVASCRIPT = 'run_javascript'
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Creates a reactive throttle key that increments when `getValue()` changes
|
|
||||||
* and the throttle window has elapsed since the last increment.
|
|
||||||
*
|
|
||||||
* Useful for throttling animations that should not fire on every rapid update.
|
|
||||||
*
|
|
||||||
* @param getValue - A reactive getter for the value to watch
|
|
||||||
* @param ms - Throttle window in milliseconds
|
|
||||||
* @returns A reactive number that increments when the throttled value changes
|
|
||||||
*/
|
|
||||||
export function useThrottle(getValue: () => string | undefined, ms: number) {
|
|
||||||
let key = $state(0);
|
|
||||||
let throttleEnd = $state(0);
|
|
||||||
let lastValue: string | undefined = getValue();
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const value = getValue();
|
|
||||||
if (value === lastValue) return;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now >= throttleEnd) {
|
|
||||||
lastValue = value;
|
|
||||||
key++;
|
|
||||||
throttleEnd = now + ms;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
get key() {
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
NEW_TO_DEPRECATED_MAP
|
NEW_TO_DEPRECATED_MAP
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants/agentic';
|
import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants/agentic';
|
||||||
import { SETTINGS_KEYS } from '$lib/constants/settings-registry';
|
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
|
||||||
import { MessageRole } from '$lib/enums';
|
import { MessageRole } from '$lib/enums';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user