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-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 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 user’ s 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) {
responseMessage.content = '< status title = "Edited" done = "true" / > ';
}
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;
responseMessage.content = '< status title = "Editing" done = "false" / > ';
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;
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 }
onSubmit={ submitHandler }
2025-07-08 08:50:06 +00:00
{ onStop }
2025-07-07 15:26:12 +00:00
>
2025-07-08 08:35:35 +00:00
< div slot = "menu" class = "flex items-center justify-between gap-2 w-full pr-2" >
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
class=" bg-transparent rounded-lg py-1 px-2 -mx-0.5 text-sm outline-hidden w-20"
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 >