Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified tools/server/public/index.html.gz
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import { getDeletionInfo } from '$lib/stores/chat.svelte';
import { copyToClipboard } from '$lib/utils/copy';
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
Expand Down Expand Up @@ -53,6 +54,28 @@
return null;
});

let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
if (message.role === 'assistant') {
const trimmedToolCalls = message.toolCalls?.trim();

if (!trimmedToolCalls) {
return null;
}

try {
const parsed = JSON.parse(trimmedToolCalls);

if (Array.isArray(parsed)) {
return parsed as ApiChatCompletionToolCall[];
}
} catch {
// Harmony-only path: fall back to the raw string so issues surface visibly.
}

return trimmedToolCalls;
}
return null;
});
function handleCancelEdit() {
isEditing = false;
editedContent = message.content;
Expand Down Expand Up @@ -168,5 +191,6 @@
{showDeleteDialog}
{siblingInfo}
{thinkingContent}
{toolCallContent}
/>
{/if}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<script lang="ts">
import { ChatMessageThinkingBlock, MarkdownContent } from '$lib/components/app';
import {
ChatMessageThinkingBlock,
ChatMessageToolCallBlock,
MarkdownContent
} from '$lib/components/app';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading } from '$lib/stores/chat.svelte';
import { fade } from 'svelte/transition';
Expand All @@ -11,6 +15,7 @@
import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte';
import { copyToClipboard } from '$lib/utils/copy';
import type { ApiChatCompletionToolCall } from '$lib/types/api';

interface Props {
class?: string;
Expand Down Expand Up @@ -41,6 +46,7 @@
siblingInfo?: ChatMessageSiblingInfo | null;
textareaElement?: HTMLTextAreaElement;
thinkingContent: string | null;
toolCallContent: ApiChatCompletionToolCall[] | string | null;
}

let {
Expand All @@ -66,7 +72,8 @@
shouldBranchAfterEdit = false,
siblingInfo = null,
textareaElement = $bindable(),
thinkingContent
thinkingContent,
toolCallContent
}: Props = $props();

const processingState = useProcessingState();
Expand All @@ -85,6 +92,10 @@
/>
{/if}

{#if toolCallContent && config().showToolCalls}
<ChatMessageToolCallBlock {toolCallContent} />
{/if}

{#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
<div class="mt-6 w-full max-w-[48rem]" in:fade>
<div class="processing-container">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script lang="ts">
import { Wrench } from '@lucide/svelte';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card';
import ChatMessageToolCallItem from './ChatMessageToolCallItem.svelte';
import type { ApiChatCompletionToolCall } from '$lib/types/api';

interface Props {
class?: string;
toolCallContent: ApiChatCompletionToolCall[] | string | null;
}

let { class: className = '', toolCallContent }: Props = $props();
let fallbackExpanded = $state(false);

const toolCalls = $derived.by(() => (Array.isArray(toolCallContent) ? toolCallContent : null));
const fallbackContent = $derived.by(() =>
typeof toolCallContent === 'string' ? toolCallContent : null
);
</script>

{#if toolCalls && toolCalls.length > 0}
<div class="mb-6 flex flex-col gap-3 {className}">
{#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
<ChatMessageToolCallItem {toolCall} {index} />
{/each}
</div>
{:else if fallbackContent}
<Collapsible.Root bind:open={fallbackExpanded} class="mb-6 {className}">
<Card class="gap-0 border-muted bg-muted/30 py-0">
<Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
<div class="flex items-center gap-2 text-muted-foreground">
<Wrench class="h-4 w-4" />

<span class="text-sm font-medium">Tool calls</span>
</div>

<div
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
})}
>
<ChevronsUpDownIcon class="h-4 w-4" />

<span class="sr-only">Toggle tool call content</span>
</div>
</Collapsible.Trigger>

<Collapsible.Content>
<div class="border-t border-muted px-3 pb-3">
<div class="pt-3">
<pre class="tool-call-content">{fallbackContent}</pre>
</div>
</div>
</Collapsible.Content>
</Card>
</Collapsible.Root>
{/if}

<style>
.tool-call-content {
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.25rem;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<script lang="ts">
import { Wrench } from '@lucide/svelte';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card';
import type { ApiChatCompletionToolCall } from '$lib/types/api';

interface Props {
class?: string;
index: number;
toolCall: ApiChatCompletionToolCall;
}

let { class: className = '', index, toolCall }: Props = $props();

let isExpanded = $state(false);

const headerLabel = $derived.by(() => {
const callNumber = index + 1;
const functionName = toolCall.function?.name?.trim();

return functionName ? `Tool call #${callNumber} · ${functionName}` : `Tool call #${callNumber}`;
});

const formattedPayload = $derived.by(() => {
const payload: Record<string, unknown> = {};

if (toolCall.id) {
payload.id = toolCall.id;
}

if (toolCall.type) {
payload.type = toolCall.type;
}

if (toolCall.function) {
const fnPayload: Record<string, unknown> = {};
const { name, arguments: args } = toolCall.function;

if (name) {
fnPayload.name = name;
}

const trimmedArguments = args?.trim();
if (trimmedArguments) {
try {
fnPayload.arguments = JSON.parse(trimmedArguments);
} catch {
fnPayload.arguments = trimmedArguments;
}
}

if (Object.keys(fnPayload).length > 0) {
payload.function = fnPayload;
}
}

return JSON.stringify(payload, null, 2);
});
</script>

<Collapsible.Root bind:open={isExpanded} class="mb-3 last:mb-0 {className}">
<Card class="gap-0 border-muted bg-muted/30 py-0">
<Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
<div class="flex items-center gap-2 text-muted-foreground">
<Wrench class="h-4 w-4" />

<span class="text-sm font-medium">{headerLabel}</span>
</div>

<div
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
})}
>
<ChevronsUpDownIcon class="h-4 w-4" />

<span class="sr-only">Toggle tool call payload</span>
</div>
</Collapsible.Trigger>

<Collapsible.Content>
<div class="border-t border-muted px-3 pb-3">
<div class="pt-3">
<pre class="tool-call-content">{formattedPayload}</pre>
</div>
</div>
</Collapsible.Content>
</Card>
</Collapsible.Root>

<style>
.tool-call-content {
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.25rem;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@
label: 'Show raw LLM output',
type: 'checkbox'
},
{
key: 'showToolCalls',
label: 'Show tool call chunks',
type: 'checkbox'
},
{
key: 'custom',
label: 'Custom JSON',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
updateConversationName
} from '$lib/stores/chat.svelte';
import ChatSidebarActions from './ChatSidebarActions.svelte';
import ModelSelector from './ModelSelector.svelte';

const sidebar = Sidebar.useSidebar();

Expand Down Expand Up @@ -110,6 +111,8 @@
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
</a>

<ModelSelector />

<ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
</Sidebar.Header>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Loader2 } from '@lucide/svelte';
import * as Select from '$lib/components/ui/select';
import {
fetchModels,
modelOptions,
modelsError,
modelsLoading,
modelsUpdating,
selectModel,
selectedModelId
} from '$lib/stores/models.svelte';
import type { ModelOption } from '$lib/stores/models.svelte';

let options = $derived(modelOptions());
let loading = $derived(modelsLoading());
let updating = $derived(modelsUpdating());
let error = $derived(modelsError());
let activeId = $derived(selectedModelId());

let isMounted = $state(false);

onMount(async () => {
try {
await fetchModels();
} catch (error) {
console.error('Unable to load models:', error);
} finally {
isMounted = true;
}
});

async function handleSelect(value: string | undefined) {
if (!value) return;

const option = options.find((item) => item.id === value);
if (!option) {
console.error('Model is no longer available');
return;
}

try {
await selectModel(option.id);
} catch (error) {
console.error('Failed to switch model:', error);
}
}

function getDisplayOption(): ModelOption | undefined {
if (activeId) {
return options.find((option) => option.id === activeId);
}

return options[0];
}
</script>

{#if loading && options.length === 0 && !isMounted}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
Loading models…
</div>
{:else if options.length === 0}
<p class="text-xs text-muted-foreground">No models available.</p>
{:else}
{@const selectedOption = getDisplayOption()}

<Select.Root
type="single"
value={selectedOption?.id ?? ''}
onValueChange={handleSelect}
disabled={loading || updating}
>
<Select.Trigger class="h-9 w-full justify-between">
<span class="truncate text-sm font-medium">{selectedOption?.name || 'Select model'}</span>

{#if updating}
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
{/if}
</Select.Trigger>

<Select.Content class="z-[100000]">
{#each options as option (option.id)}
<Select.Item value={option.id} label={option.name}>
<span class="text-sm font-medium">{option.name}</span>

{#if option.description}
<span class="text-xs text-muted-foreground">{option.description}</span>
{/if}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}

{#if error}
<p class="mt-2 text-xs text-destructive">{error}</p>
{/if}
Loading