open-webui/src/lib/components/notes/NoteEditor/Chat.svelte

416 lines
11 KiB
Svelte
Raw Normal View History

2025-07-07 15:26:12 +00:00
<script lang="ts">
export let show = false;
export let selectedModelId = '';
2025-07-08 08:31:31 +00:00
import { marked } from 'marked';
2025-07-08 23:43:41 +00:00
// Configure marked with extensions
marked.use({
breaks: true,
gfm: true,
renderer: {
list(body, ordered, start) {
const isTaskList = body.includes('data-checked=');
if (isTaskList) {
return `<ul data-type="taskList">${body}</ul>`;
}
const type = ordered ? 'ol' : 'ul';
const startatt = ordered && start !== 1 ? ` start="${start}"` : '';
return `<${type}${startatt}>${body}</${type}>`;
},
listitem(text, task, checked) {
if (task) {
const checkedAttr = checked ? 'true' : 'false';
return `<li data-type="taskItem" data-checked="${checkedAttr}">${text}</li>`;
}
return `<li>${text}</li>`;
}
}
});
2025-07-07 15:26:12 +00:00
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 { chatCompletion, generateOpenAIChatCompletion } from '$lib/apis/openai';
import { splitStream } from '$lib/utils';
import Messages from '$lib/components/notes/NoteEditor/Chat/Messages.svelte';
import MessageInput from '$lib/components/channel/MessageInput.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
2025-07-08 08:31:31 +00:00
import Pencil from '$lib/components/icons/Pencil.svelte';
import PencilSquare from '$lib/components/icons/PencilSquare.svelte';
2025-07-07 15:26:12 +00:00
const i18n = getContext('i18n');
2025-07-08 08:31:31 +00:00
export let enhancing = false;
export let streaming = false;
2025-07-08 08:50:06 +00:00
export let stopResponseFlag = false;
2025-07-08 08:31:31 +00:00
2025-07-07 16:19:17 +00:00
export let note = null;
2025-07-08 08:31:31 +00:00
2025-07-07 16:19:17 +00:00
export let files = [];
2025-07-07 15:33:54 +00:00
export let messages = [];
2025-07-07 17:22:07 +00:00
export let onInsert = (content) => {};
2025-07-08 08:50:06 +00:00
export let onStop = () => {};
2025-07-08 12:43:26 +00:00
export let insertNoteHandler = () => {};
2025-07-08 08:31:31 +00:00
export let scrollToBottomHandler = () => {};
2025-07-07 17:22:07 +00:00
2025-07-07 15:26:12 +00:00
let loaded = false;
let loading = false;
let messagesContainerElement: HTMLDivElement;
let system = '';
2025-07-08 08:31:31 +00:00
let editorEnabled = false;
2025-07-07 15:26:12 +00:00
let chatInputElement = null;
2025-07-08 08:31:31 +00:00
const DEFAULT_DOCUMENT_EDITOR_PROMPT = `You are an expert document editor.
## Task
Based on the user's instruction, update and enhance the existing notes by incorporating relevant and accurate information from the provided context. Ensure all edits strictly follow the users intent.
## Input Structure
- Existing notes: Enclosed within <notes></notes> XML tags.
- Additional context: Enclosed within <context></context> XML tags.
- Editing instruction: Provided in the user message.
## Output Instructions
- Deliver a single, rewritten version of the notes in markdown format.
- Integrate information from the context only if it directly supports the user's instruction.
- Use clear, organized markdown elements: headings, bullet points, numbered lists, bold and italic text as appropriate.
- Focus on improving clarity, completeness, and usefulness of the notes.
- Return only the final, fully-edited markdown notes—do not include explanations, reasoning, or XML tags.
`;
let scrolledToBottom = true;
2025-07-07 15:26:12 +00:00
const scrollToBottom = () => {
2025-07-08 08:31:31 +00:00
if (messagesContainerElement) {
if (scrolledToBottom) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
}
};
2025-07-07 15:26:12 +00:00
2025-07-08 08:31:31 +00:00
const onScroll = () => {
if (messagesContainerElement) {
scrolledToBottom =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 10;
2025-07-07 15:26:12 +00:00
}
};
const chatCompletionHandler = async () => {
if (selectedModelId === '') {
toast.error($i18n.t('Please select a model.'));
return;
}
const model = $models.find((model) => model.id === selectedModelId);
if (!model) {
selectedModelId = '';
return;
}
2025-07-07 15:47:32 +00:00
let responseMessage;
if (messages.at(-1)?.role === 'assistant') {
responseMessage = messages.at(-1);
} else {
responseMessage = {
role: 'assistant',
content: '',
done: false
};
messages.push(responseMessage);
messages = messages;
}
2025-07-07 15:58:10 +00:00
await tick();
scrollToBottom();
2025-07-08 08:31:31 +00:00
let enhancedContent = {
json: null,
html: '',
md: ''
};
system = '';
if (editorEnabled) {
system = `${DEFAULT_DOCUMENT_EDITOR_PROMPT}\n\n`;
} else {
system = `You are a helpful assistant. Please answer the user's questions based on the context provided.\n\n`;
}
system +=
`<notes>${note?.data?.content?.md ?? ''}</notes>` +
(files && files.length > 0
? `\n<context>${files.map((file) => `${file.name}: ${file?.file?.data?.content ?? 'Could not extract content'}\n`).join('')}</context>`
: '');
const chatMessages = JSON.parse(
JSON.stringify([
{
role: 'system',
content: `${system}`
},
...messages
])
);
2025-07-07 15:26:12 +00:00
const [res, controller] = await chatCompletion(
localStorage.token,
{
model: model.id,
stream: true,
2025-07-08 08:31:31 +00:00
messages: chatMessages
// ...(files && files.length > 0 ? { files } : {}) // TODO: Decide whether to use native file handling or not
2025-07-07 15:26:12 +00:00
},
`${WEBUI_BASE_URL}/api`
);
await tick();
2025-07-07 15:58:10 +00:00
scrollToBottom();
2025-07-07 15:26:12 +00:00
2025-07-08 08:31:31 +00:00
let messageContent = '';
2025-07-07 15:26:12 +00:00
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');
}
2025-07-08 08:31:31 +00:00
if (editorEnabled) {
enhancing = false;
streaming = false;
}
2025-07-07 15:26:12 +00:00
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
2025-07-08 08:31:31 +00:00
if (editorEnabled) {
2025-07-08 09:29:04 +00:00
responseMessage.content = `<status title="${$i18n.t('Edited')}" done="true" />`;
2025-07-08 08:31:31 +00:00
}
responseMessage.done = true;
2025-07-07 15:26:12 +00:00
messages = messages;
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
2025-07-08 08:31:31 +00:00
let deltaContent = data.choices[0]?.delta?.content ?? '';
if (responseMessage.content == '' && deltaContent == '\n') {
2025-07-07 15:26:12 +00:00
continue;
} else {
2025-07-08 08:31:31 +00:00
if (editorEnabled) {
enhancing = true;
streaming = true;
enhancedContent.md += deltaContent;
enhancedContent.html = marked.parse(enhancedContent.md);
note.data.content.md = enhancedContent.md;
note.data.content.html = enhancedContent.html;
note.data.content.json = null;
2025-07-08 09:29:04 +00:00
responseMessage.content = `<status title="${$i18n.t('Editing')}" done="false" />`;
2025-07-08 08:31:31 +00:00
scrollToBottomHandler();
messages = messages;
} else {
messageContent += deltaContent;
responseMessage.content = messageContent;
messages = messages;
}
2025-07-07 15:26:12 +00:00
await tick();
}
}
}
}
} catch (error) {
console.log(error);
}
scrollToBottom();
}
}
};
const submitHandler = async (e) => {
const { content, data } = e;
if (selectedModelId && content) {
messages.push({
role: 'user',
content: content
});
messages = messages;
await tick();
scrollToBottom();
loading = true;
2025-07-08 12:43:26 +00:00
if (editorEnabled) {
insertNoteHandler();
}
2025-07-07 15:26:12 +00:00
await chatCompletionHandler();
2025-07-07 15:47:32 +00:00
messages = messages.map((message) => {
message.done = true;
return message;
2025-07-07 15:42:08 +00:00
});
2025-07-07 15:26:12 +00:00
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 = '';
}
2025-07-08 08:31:31 +00:00
2025-07-07 15:26:12 +00:00
loaded = true;
2025-07-08 08:31:31 +00:00
2025-07-08 08:54:11 +00:00
await tick();
2025-07-08 08:31:31 +00:00
scrollToBottom();
2025-07-07 15:26:12 +00:00
});
</script>
2025-07-07 15:32:41 +00:00
<div class="flex items-center mb-2 pt-1">
<div class=" -translate-x-1.5 flex items-center">
2025-07-07 15:26:12 +00:00
<button
2025-07-07 15:32:41 +00:00
class="p-0.5 bg-transparent transition rounded-lg"
2025-07-07 15:26:12 +00:00
on:click={() => {
show = !show;
}}
>
<XMark className="size-5" strokeWidth="2.5" />
</button>
</div>
<div class=" font-medium text-base flex items-center gap-1">
<div>
{$i18n.t('Chat')}
</div>
<div>
<Tooltip
2025-07-07 15:30:05 +00:00
content={$i18n.t(
'This feature is experimental and may be modified or discontinued without notice.'
)}
2025-07-07 15:26:12 +00:00
position="top"
className="inline-block"
>
<span class="text-gray-500 text-sm">({$i18n.t('Experimental')})</span>
</Tooltip>
</div>
</div>
</div>
2025-07-08 08:31:31 +00:00
<div class="flex flex-col items-center mb-2 flex-1 @container">
2025-07-07 15:26:12 +00:00
<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">
<div class=" flex flex-col h-full">
<div
2025-07-08 08:54:11 +00:00
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 scrollbar-hidden"
2025-07-07 15:26:12 +00:00
id="messages-container"
bind:this={messagesContainerElement}
2025-07-08 08:31:31 +00:00
on:scroll={onScroll}
2025-07-07 15:26:12 +00:00
>
<div class=" h-full w-full flex flex-col">
<div class="flex-1 p-1">
2025-07-07 17:22:07 +00:00
<Messages bind:messages {onInsert} />
2025-07-07 15:26:12 +00:00
</div>
</div>
</div>
<div class=" pb-2">
<MessageInput
bind:chatInputElement
acceptFiles={false}
inputLoading={loading}
2025-07-08 23:15:20 +00:00
showFormattingButtons={false}
2025-07-07 15:26:12 +00:00
onSubmit={submitHandler}
2025-07-08 08:50:06 +00:00
{onStop}
2025-07-07 15:26:12 +00:00
>
2025-07-08 12:46:14 +00:00
<div slot="menu" class="flex items-center justify-between gap-2 w-full pr-1">
2025-07-08 08:31:31 +00:00
<div>
<Tooltip content={$i18n.t('Edit')} placement="top">
<button
on:click|preventDefault={() => (editorEnabled = !editorEnabled)}
type="button"
class="px-2 @xl:px-2.5 py-2 flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {editorEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
: 'bg-transparent text-gray-600 dark:text-gray-300 '}"
>
<PencilSquare className="size-4" strokeWidth="1.75" />
<span
class="block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
>{$i18n.t('Edit')}</span
>
</button>
</Tooltip>
</div>
<Tooltip content={selectedModelId}>
<select
2025-07-08 12:46:14 +00:00
class=" bg-transparent rounded-lg py-1 px-2 -mx-0.5 text-sm outline-hidden w-full text-right pr-5"
2025-07-08 08:31:31 +00:00
bind:value={selectedModelId}
>
{#each $models as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
>{model.name}</option
>
{/each}
</select>
</Tooltip>
2025-07-07 15:26:12 +00:00
</div>
</MessageInput>
</div>
</div>
</div>
</div>
</div>