|
<script lang="ts"> |
|
import { toast } from 'svelte-sonner'; |
|
|
|
import { goto } from '$app/navigation'; |
|
import { onMount, tick, getContext } from 'svelte'; |
|
|
|
import { |
|
OLLAMA_API_BASE_URL, |
|
OPENAI_API_BASE_URL, |
|
WEBUI_API_BASE_URL, |
|
WEBUI_BASE_URL |
|
} from '$lib/constants'; |
|
import { WEBUI_NAME, config, user, models, settings } from '$lib/stores'; |
|
|
|
import { generateOpenAIChatCompletion } from '$lib/apis/openai'; |
|
|
|
import { splitStream } from '$lib/utils'; |
|
import Collapsible from '../common/Collapsible.svelte'; |
|
|
|
import Messages from '$lib/components/playground/Chat/Messages.svelte'; |
|
import ChevronUp from '../icons/ChevronUp.svelte'; |
|
import ChevronDown from '../icons/ChevronDown.svelte'; |
|
import Pencil from '../icons/Pencil.svelte'; |
|
import Cog6 from '../icons/Cog6.svelte'; |
|
import Sidebar from '../common/Sidebar.svelte'; |
|
import ArrowRight from '../icons/ArrowRight.svelte'; |
|
|
|
const i18n = getContext('i18n'); |
|
|
|
let loaded = false; |
|
|
|
let selectedModelId = ''; |
|
let loading = false; |
|
let stopResponseFlag = false; |
|
|
|
let messagesContainerElement: HTMLDivElement; |
|
|
|
let showSystem = false; |
|
let showSettings = false; |
|
|
|
let system = ''; |
|
|
|
let role = 'user'; |
|
let message = ''; |
|
|
|
let messages = []; |
|
|
|
const scrollToBottom = () => { |
|
const element = messagesContainerElement; |
|
|
|
if (element) { |
|
element.scrollTop = element?.scrollHeight; |
|
} |
|
}; |
|
|
|
const stopResponse = () => { |
|
stopResponseFlag = true; |
|
console.log('stopResponse'); |
|
}; |
|
|
|
const chatCompletionHandler = async () => { |
|
const model = $models.find((model) => model.id === selectedModelId); |
|
|
|
const [res, controller] = await generateOpenAIChatCompletion( |
|
localStorage.token, |
|
{ |
|
model: model.id, |
|
stream: true, |
|
messages: [ |
|
system |
|
? { |
|
role: 'system', |
|
content: system |
|
} |
|
: undefined, |
|
...messages |
|
].filter((message) => message) |
|
}, |
|
`${WEBUI_BASE_URL}/api` |
|
); |
|
|
|
let responseMessage; |
|
if (messages.at(-1)?.role === 'assistant') { |
|
responseMessage = messages.at(-1); |
|
} else { |
|
responseMessage = { |
|
role: 'assistant', |
|
content: '' |
|
}; |
|
messages.push(responseMessage); |
|
messages = messages; |
|
} |
|
|
|
await tick(); |
|
const textareaElement = document.getElementById(`assistant-${messages.length - 1}-textarea`); |
|
|
|
if (res && res.ok) { |
|
const reader = res.body |
|
.pipeThrough(new TextDecoderStream()) |
|
.pipeThrough(splitStream('\n')) |
|
.getReader(); |
|
|
|
while (true) { |
|
const { value, done } = await reader.read(); |
|
if (done || stopResponseFlag) { |
|
if (stopResponseFlag) { |
|
controller.abort('User: Stop Response'); |
|
} |
|
break; |
|
} |
|
|
|
try { |
|
let lines = value.split('\n'); |
|
|
|
for (const line of lines) { |
|
if (line !== '') { |
|
console.log(line); |
|
if (line === 'data: [DONE]') { |
|
// responseMessage.done = true; |
|
messages = messages; |
|
} else { |
|
let data = JSON.parse(line.replace(/^data: /, '')); |
|
console.log(data); |
|
|
|
if (responseMessage.content == '' && data.choices[0].delta.content == '\n') { |
|
continue; |
|
} else { |
|
textareaElement.style.height = textareaElement.scrollHeight + 'px'; |
|
|
|
responseMessage.content += data.choices[0].delta.content ?? ''; |
|
messages = messages; |
|
|
|
textareaElement.style.height = textareaElement.scrollHeight + 'px'; |
|
|
|
await tick(); |
|
} |
|
} |
|
} |
|
} |
|
} catch (error) { |
|
console.log(error); |
|
} |
|
|
|
scrollToBottom(); |
|
} |
|
} |
|
}; |
|
|
|
const addHandler = async () => { |
|
if (message) { |
|
messages.push({ |
|
role: role, |
|
content: message |
|
}); |
|
messages = messages; |
|
message = ''; |
|
await tick(); |
|
scrollToBottom(); |
|
} |
|
}; |
|
|
|
const submitHandler = async () => { |
|
if (selectedModelId) { |
|
await addHandler(); |
|
|
|
loading = true; |
|
await chatCompletionHandler(); |
|
|
|
loading = false; |
|
stopResponseFlag = false; |
|
} |
|
}; |
|
|
|
onMount(async () => { |
|
if ($user?.role !== 'admin') { |
|
await goto('/'); |
|
} |
|
|
|
if ($settings?.models) { |
|
selectedModelId = $settings?.models[0]; |
|
} else if ($config?.default_models) { |
|
selectedModelId = $config?.default_models.split(',')[0]; |
|
} else { |
|
selectedModelId = ''; |
|
} |
|
loaded = true; |
|
}); |
|
</script> |
|
|
|
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full"> |
|
<div class="mx-auto w-full md:px-0 h-full relative"> |
|
<Sidebar bind:show={showSettings} className=" bg-white dark:bg-gray-900" width="300px"> |
|
<div class="flex flex-col px-5 py-3 text-sm"> |
|
<div class="flex justify-between items-center mb-2"> |
|
<div class=" font-medium text-base">Settings</div> |
|
|
|
<div class=" translate-x-1.5"> |
|
<button |
|
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg" |
|
on:click={() => { |
|
showSettings = !showSettings; |
|
}} |
|
> |
|
<ArrowRight className="size-3" strokeWidth="2.5" /> |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<div class="mt-1"> |
|
<div> |
|
<div class=" text-xs font-medium mb-1">Model</div> |
|
|
|
<div class="w-full"> |
|
<select |
|
class="w-full bg-transparent border border-gray-50 dark:border-gray-850 rounded-lg py-1 px-2 -mx-0.5 text-sm outline-none" |
|
bind:value={selectedModelId} |
|
> |
|
{#each $models as model} |
|
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option> |
|
{/each} |
|
</select> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</Sidebar> |
|
|
|
<div class=" flex flex-col h-full px-4 py-1"> |
|
<div class="flex w-full items-start gap-1.5"> |
|
<Collapsible |
|
className="w-full flex-1" |
|
bind:open={showSystem} |
|
buttonClassName="w-full rounded-lg text-sm border border-gray-50 dark:border-gray-850 w-full py-1 px-1.5" |
|
grow={true} |
|
> |
|
<div class="flex gap-2 justify-between items-center"> |
|
<div class=" flex-shrink-0 font-medium ml-1.5"> |
|
{$i18n.t('System Instructions')} |
|
</div> |
|
|
|
{#if !showSystem} |
|
<div class=" flex-1 text-gray-500 line-clamp-1"> |
|
{system} |
|
</div> |
|
{/if} |
|
|
|
<div class="flex-shrink-0"> |
|
<button class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"> |
|
{#if showSystem} |
|
<ChevronUp className="size-3.5" /> |
|
{:else} |
|
<Pencil className="size-3.5" /> |
|
{/if} |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<div slot="content"> |
|
<div class="pt-1 px-1.5"> |
|
<textarea |
|
id="system-textarea" |
|
class="w-full h-full bg-transparent resize-none outline-none text-sm" |
|
bind:value={system} |
|
placeholder={$i18n.t("You're a helpful assistant.")} |
|
rows="4" |
|
/> |
|
</div> |
|
</div> |
|
</Collapsible> |
|
|
|
<div class="translate-y-1"> |
|
<button |
|
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg" |
|
on:click={() => { |
|
showSettings = !showSettings; |
|
}} |
|
> |
|
<Cog6 /> |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<div |
|
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0" |
|
id="messages-container" |
|
bind:this={messagesContainerElement} |
|
> |
|
<div class=" h-full w-full flex flex-col"> |
|
<div class="flex-1 p-1"> |
|
<Messages bind:messages /> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div class="pb-3"> |
|
<div class="text-xs font-medium text-gray-500 px-2 py-1"> |
|
{selectedModelId} |
|
</div> |
|
<div class="border border-gray-50 dark:border-gray-850 w-full px-3 py-2.5 rounded-xl"> |
|
<div class="py-0.5"> |
|
|
|
|
|
<textarea |
|
bind:value={message} |
|
class=" w-full h-full bg-transparent resize-none outline-none text-sm" |
|
placeholder={$i18n.t(`Enter {{role}} message here`, { |
|
role: role === 'user' ? $i18n.t('a user') : $i18n.t('an assistant') |
|
})} |
|
on:input={(e) => { |
|
e.target.style.height = ''; |
|
e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px'; |
|
}} |
|
on:focus={(e) => { |
|
e.target.style.height = ''; |
|
e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px'; |
|
}} |
|
rows="2" |
|
/> |
|
</div> |
|
|
|
<div class="flex justify-between"> |
|
<div> |
|
<button |
|
class="px-3.5 py-1.5 text-sm font-medium bg-gray-50 hover:bg-gray-100 text-gray-900 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition rounded-lg" |
|
on:click={() => { |
|
role = role === 'user' ? 'assistant' : 'user'; |
|
}} |
|
> |
|
{#if role === 'user'} |
|
{$i18n.t('User')} |
|
{:else} |
|
{$i18n.t('Assistant')} |
|
{/if} |
|
</button> |
|
</div> |
|
|
|
<div> |
|
{#if !loading} |
|
<button |
|
disabled={message === ''} |
|
class="px-3.5 py-1.5 text-sm font-medium disabled:bg-gray-50 dark:disabled:hover:bg-gray-850 disabled:cursor-not-allowed bg-gray-50 hover:bg-gray-100 text-gray-900 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition rounded-lg" |
|
on:click={() => { |
|
addHandler(); |
|
role = role === 'user' ? 'assistant' : 'user'; |
|
}} |
|
> |
|
{$i18n.t('Add')} |
|
</button> |
|
|
|
<button |
|
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-lg" |
|
on:click={() => { |
|
submitHandler(); |
|
}} |
|
> |
|
{$i18n.t('Run')} |
|
</button> |
|
{:else} |
|
<button |
|
class="px-3 py-1.5 text-sm font-medium bg-gray-300 text-black transition rounded-lg" |
|
on:click={() => { |
|
stopResponse(); |
|
}} |
|
> |
|
{$i18n.t('Cancel')} |
|
</button> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
|