Skip to content

Commit

Permalink
feat: new tool UI (#1630)
Browse files Browse the repository at this point in the history
* feat: new tool UI

* wip community tools support

* fix: community tools support

* fix: line heights

* fix: MIME types

* fix: remove debug logs

* fix: make sure file upload is always last

* fix: always enable document parser when a pdf is present in the conversation

* fix: use correct icon for document upload

* fix: make sure community tools use their custom icons correctly

* feat: add button to browse community tools

* tweak buttons

* add some tooltips

* fix: bug in file upload tooltips

* fix: lint

---------

Co-authored-by: Victor Mustar <victor.mustar@gmail.com>
  • Loading branch information
nsarrazin and gary149 authored Dec 24, 2024
1 parent c6f34ec commit 3cd714f
Show file tree
Hide file tree
Showing 14 changed files with 394 additions and 78 deletions.
3 changes: 2 additions & 1 deletion chart/env/prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,8 @@ envVars:
],
"outputComponent": "textbox",
"outputComponentIdx": 0,
"showOutput": false
"showOutput": false,
"isHidden": true
},
{
"_id": "000000000000000000000003",
Expand Down
31 changes: 29 additions & 2 deletions src/lib/components/HoverTooltip.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
<script lang="ts">
export let label = "";
export let position: "top" | "bottom" | "left" | "right" = "bottom";
export let TooltipClassNames = "";
const positionClasses = {
top: "bottom-full mb-2",
bottom: "top-full mt-2",
left: "right-full mr-2 top-1/2 -translate-y-1/2",
right: "left-full ml-2 top-1/2 -translate-y-1/2",
};
</script>

<div class="group/tooltip md:relative">
<div class="group/tooltip inline-block md:relative">
<slot />

<div
class="invisible absolute z-10 w-64 whitespace-normal rounded-md bg-black p-2 text-center text-white group-hover/tooltip:visible group-active/tooltip:visible max-sm:left-1/2 max-sm:-translate-x-1/2"
class="
invisible
absolute
z-10
w-64
whitespace-normal
rounded-md
bg-black
p-2
text-center
text-white
group-hover/tooltip:visible
group-active/tooltip:visible
max-sm:left-1/2
max-sm:-translate-x-1/2
{positionClasses[position]}
{TooltipClassNames}
"
>
{label}
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/lib/components/ToolLogo.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
export let color: string;
export let icon: string;
export let size: "sm" | "md" | "lg" = "md";
export let size: "xs" | "sm" | "md" | "lg" = "md";
$: gradientColor = (() => {
switch (color) {
Expand Down Expand Up @@ -72,6 +72,8 @@
$: sizeClass = (() => {
switch (size) {
case "xs":
return "size-4";
case "sm":
return "size-8";
case "md":
Expand Down
265 changes: 239 additions & 26 deletions src/lib/components/chat/ChatInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,51 @@
import { browser } from "$app/environment";
import { createEventDispatcher, onMount } from "svelte";
import HoverTooltip from "$lib/components/HoverTooltip.svelte";
import IconInternet from "$lib/components/icons/IconInternet.svelte";
import IconImageGen from "$lib/components/icons/IconImageGen.svelte";
import IconPaperclip from "$lib/components/icons/IconPaperclip.svelte";
import { useSettingsStore } from "$lib/stores/settings";
import { webSearchParameters } from "$lib/stores/webSearchParameters";
import {
documentParserToolId,
fetchUrlToolId,
imageGenToolId,
webSearchToolId,
} from "$lib/utils/toolIds";
import type { Assistant } from "$lib/types/Assistant";
import { page } from "$app/stores";
import type { ToolFront } from "$lib/types/Tool";
import ToolLogo from "../ToolLogo.svelte";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import IconAdd from "~icons/carbon/add";
export let files: File[] = [];
export let mimeTypes: string[] = [];
export let value = "";
export let minRows = 1;
export let maxRows: null | number = null;
export let placeholder = "";
export let loading = false;
export let disabled = false;
export let assistant: Assistant | undefined = undefined;
export let modelHasTools = false;
export let modelIsMultimodal = false;
const onFileChange = async (e: Event) => {
if (!e.target) return;
const target = e.target as HTMLInputElement;
files = [...files, ...(target.files ?? [])];
if (files.some((file) => file.type.startsWith("application/"))) {
await settings.instantSet({
tools: [...($settings.tools ?? []), documentParserToolId],
});
}
};
let textareaElement: HTMLTextAreaElement;
let isCompositionOn = false;
Expand All @@ -28,8 +67,14 @@
return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
}
$: minHeight = `${1 + minRows * 1.5}em`;
$: maxHeight = maxRows ? `${1 + maxRows * 1.5}em` : `auto`;
function adjustTextareaHeight() {
if (!textareaElement) return;
textareaElement.style.height = "auto";
const newHeight = Math.min(textareaElement.scrollHeight, parseInt("96em"));
textareaElement.style.height = `${newHeight}px`;
if (!textareaElement.parentElement) return;
textareaElement.parentElement.style.height = `${newHeight}px`;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey && !isCompositionOn) {
Expand All @@ -48,41 +93,209 @@
}
}
const settings = useSettingsStore();
// tool section
$: webSearchIsOn = modelHasTools
? ($settings.tools?.includes(webSearchToolId) ?? false) ||
($settings.tools?.includes(fetchUrlToolId) ?? false)
: $webSearchParameters.useSearch;
$: imageGenIsOn = $settings.tools?.includes(imageGenToolId) ?? false;
$: documentParserIsOn =
modelHasTools && files.length > 0 && files.some((file) => file.type.startsWith("application/"));
onMount(() => {
if (!isVirtualKeyboard()) {
textareaElement.focus();
}
adjustTextareaHeight();
});
$: extraTools = $page.data.tools
.filter((t: ToolFront) => $settings.tools?.includes(t._id))
.filter(
(t: ToolFront) =>
![documentParserToolId, imageGenToolId, webSearchToolId, fetchUrlToolId].includes(t._id)
) satisfies ToolFront[];
</script>

<div class="relative min-w-0 flex-1" on:paste>
<pre
class="scrollbar-custom invisible overflow-x-hidden overflow-y-scroll whitespace-pre-wrap break-words p-3"
aria-hidden="true"
style="min-height: {minHeight}; max-height: {maxHeight}">{(value || " ") + "\n"}</pre>

<textarea
enterkeyhint={!isVirtualKeyboard() ? "enter" : "send"}
tabindex="0"
rows="1"
class="scrollbar-custom absolute top-0 m-0 h-full w-full resize-none scroll-p-3 overflow-x-hidden overflow-y-scroll border-0 bg-transparent p-3 outline-none focus:ring-0 focus-visible:ring-0 max-sm:p-2.5 max-sm:text-[16px]"
class:text-gray-400={disabled}
bind:value
bind:this={textareaElement}
{disabled}
on:keydown={handleKeydown}
on:compositionstart={() => (isCompositionOn = true)}
on:compositionend={() => (isCompositionOn = false)}
on:beforeinput
{placeholder}
/>
<div class="min-h-full flex-1" on:paste>
<div class="relative w-full min-w-0">
<textarea
enterkeyhint={!isVirtualKeyboard() ? "enter" : "send"}
tabindex="0"
rows="1"
class="scrollbar-custom max-h-[96em] w-full resize-none scroll-p-3 overflow-y-auto overflow-x-hidden border-0 bg-transparent px-3 py-2.5 outline-none focus:ring-0 focus-visible:ring-0 max-sm:p-2.5 max-sm:text-[16px]"
class:text-gray-400={disabled}
bind:value
bind:this={textareaElement}
{disabled}
on:keydown={handleKeydown}
on:compositionstart={() => (isCompositionOn = true)}
on:compositionend={() => (isCompositionOn = false)}
on:input={adjustTextareaHeight}
on:beforeinput
{placeholder}
/>
</div>
{#if !assistant}
<div
class="-ml-0.5 flex flex-wrap items-center justify-start gap-2.5 px-3 pb-2.5 text-gray-500 dark:text-gray-400"
>
<HoverTooltip
label="Search the web"
position="top"
TooltipClassNames="text-xs !text-left !w-auto whitespace-nowrap !py-1 !mb-0 max-sm:hidden {webSearchIsOn
? 'hidden'
: ''}"
>
<button
class="base-tool"
class:active-tool={webSearchIsOn}
disabled={loading}
on:click|preventDefault={async () => {
if (modelHasTools) {
if (webSearchIsOn) {
await settings.instantSet({
tools: ($settings.tools ?? []).filter(
(t) => t !== webSearchToolId && t !== fetchUrlToolId
),
});
} else {
await settings.instantSet({
tools: [...($settings.tools ?? []), webSearchToolId, fetchUrlToolId],
});
}
} else {
$webSearchParameters.useSearch = !webSearchIsOn;
}
}}
>
<IconInternet classNames="text-xl" />
{#if webSearchIsOn}
Search
{/if}
</button>
</HoverTooltip>
{#if modelHasTools}
<HoverTooltip
label="Generate images"
position="top"
TooltipClassNames="text-xs !text-left !w-auto whitespace-nowrap !py-1 !mb-0 max-sm:hidden {imageGenIsOn
? 'hidden'
: ''}"
>
<button
class="base-tool"
class:active-tool={imageGenIsOn}
disabled={loading}
on:click|preventDefault={async () => {
if (modelHasTools) {
if (imageGenIsOn) {
await settings.instantSet({
tools: ($settings.tools ?? []).filter((t) => t !== imageGenToolId),
});
} else {
await settings.instantSet({
tools: [...($settings.tools ?? []), imageGenToolId],
});
}
}
}}
>
<IconImageGen classNames="text-xl" />
{#if imageGenIsOn}
Image Gen
{/if}
</button>
</HoverTooltip>
{/if}
{#if modelHasTools}
{#each extraTools as tool}
<button
class="active-tool base-tool"
disabled={loading}
on:click|preventDefault={async () => {
goto(`${base}/tools/${tool._id}`);
}}
>
<ToolLogo icon={tool.icon} color={tool.color} size="xs" />
{tool.displayName}
</button>
{/each}
{/if}
{#if modelIsMultimodal || modelHasTools}
{@const mimeTypesString = mimeTypes
.map((m) => {
// if the mime type ends in *, grab the first part so image/* becomes image
if (m.endsWith("*")) {
return m.split("/")[0];
}
// otherwise, return the second part for example application/pdf becomes pdf
return m.split("/")[1];
})
.join(", ")}
<form class="flex items-center">
<HoverTooltip
label={`Upload ${mimeTypesString} files`}
position="top"
TooltipClassNames="text-xs !text-left !w-auto whitespace-nowrap !py-1 !mb-0 max-sm:hidden"
>
<button
class="base-tool relative"
class:active-tool={documentParserIsOn}
disabled={loading}
>
<input
class="absolute w-full cursor-pointer opacity-0"
aria-label="Upload file"
type="file"
on:change={onFileChange}
accept={mimeTypes.join(",")}
/>
<IconPaperclip classNames="text-xl" />
{#if documentParserIsOn}
Document Parser
{/if}
</button>
</HoverTooltip>
</form>
{/if}
{#if modelHasTools}
<HoverTooltip
label="Browse more tools"
position="right"
TooltipClassNames="text-xs !text-left !w-auto whitespace-nowrap !py-1 max-sm:hidden"
>
<a
class="base-tool flex !size-[20px] items-center justify-center rounded-full bg-white/10"
href={`${base}/tools`}
title="Browse more tools"
>
<IconAdd class="text-sm" />
</a>
</HoverTooltip>
{/if}
</div>
{/if}
<slot />
</div>

<style>
<style lang="postcss">
pre,
textarea {
font-family: inherit;
box-sizing: border-box;
line-height: 1.5;
}
.base-tool {
@apply flex h-[1.6rem] items-center gap-[.2rem] whitespace-nowrap text-xs outline-none transition-all hover:text-purple-600 focus:outline-none active:outline-none dark:hover:text-gray-300;
}
.active-tool {
@apply rounded-full bg-purple-500/15 pl-1 pr-2 text-purple-600 hover:text-purple-600 dark:bg-purple-600/40 dark:text-purple-300;
}
</style>
Loading

0 comments on commit 3cd714f

Please sign in to comment.