This commit is contained in:
Timothy Jaeryang Baek
2026-07-15 21:43:47 -04:00
parent 4d11553a6b
commit 423cafd4e7
21 changed files with 803 additions and 1158 deletions
+117 -335
View File
@@ -23,10 +23,10 @@
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import { compressImage, copyToClipboard, splitStream, convertHeicToJpeg } from '$lib/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { compressImage, copyToClipboard, convertHeicToJpeg } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
import { getFileById, uploadFile } from '$lib/apis/files';
import { chatCompletion, generateOpenAIChatCompletion } from '$lib/apis/openai';
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import {
config,
@@ -42,8 +42,7 @@
import { downloadPdf } from './utils';
import Controls from './NoteEditor/Controls.svelte';
import Chat from './NoteEditor/Chat.svelte';
import Chat from '$lib/components/chat/Chat.svelte';
import NotePanel from '$lib/components/notes/NotePanel.svelte';
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
@@ -65,6 +64,7 @@
import {
deleteNoteById,
getNoteById,
getNoteChatById,
updateNoteById,
updateNoteAccessGrants,
toggleNotePinnedStatusById,
@@ -76,28 +76,18 @@
import Mic from '../icons/Mic.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ChatBubbleOval from '../icons/ChatBubbleOval.svelte';
import Calendar from '../icons/Calendar.svelte';
import Users from '../icons/Users.svelte';
import AccessButton from '../common/AccessButton.svelte';
import Image from '../common/Image.svelte';
import FileItem from '../common/FileItem.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import RecordMenu from './RecordMenu.svelte';
import NoteMenu from './Notes/NoteMenu.svelte';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import Sparkles from '../icons/Sparkles.svelte';
import SparklesSolid from '../icons/SparklesSolid.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Bars3BottomLeft from '../icons/Bars3BottomLeft.svelte';
import ArrowUturnLeft from '../icons/ArrowUturnLeft.svelte';
import ArrowUturnRight from '../icons/ArrowUturnRight.svelte';
import Sidebar from '../icons/Sidebar.svelte';
import ArrowRight from '../icons/ArrowRight.svelte';
import Cog6 from '../icons/Cog6.svelte';
import AiMenu from './AIMenu.svelte';
import AdjustmentsHorizontalOutline from '../icons/AdjustmentsHorizontalOutline.svelte';
export let id: null | string = null;
@@ -131,7 +121,6 @@
);
let files = [];
let messages = [];
let wordCount = 0;
let charCount = 0;
@@ -142,8 +131,9 @@
let recording = false;
let displayMediaRecord = false;
let showPanel = false;
let selectedPanel = 'chat';
let showNoteChat = false;
let noteChatId = null;
let noteChatLoading = false;
let selectedContent = null;
@@ -157,11 +147,6 @@
let dragged = false;
let loading = false;
let editing = false;
let streaming = false;
let stopResponseFlag = false;
let inputElement = null;
// Computed HTML for editor: fall back to markdown if HTML is missing
@@ -176,8 +161,6 @@
return null;
});
messages = [];
if (res) {
note = res;
if (!Array.isArray(note?.access_grants)) {
@@ -249,11 +232,6 @@
return false;
}
const onEdited = async () => {
if (!editor) return;
editor.commands.setContent(note.data.content.html);
};
const generateTitleHandler = async () => {
const content = note.data.content.md;
const DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = `### Task:
@@ -328,34 +306,6 @@ ${content}
changeDebounceHandler();
};
async function enhanceNoteHandler() {
if (selectedModelId === '') {
toast.error($i18n.t('Please select a model.'));
return;
}
const model = $models
.filter((model) => model.id === selectedModelId && !(model?.info?.meta?.hidden ?? false))
.find((model) => model.id === selectedModelId);
if (!model) {
selectedModelId = '';
return;
}
editing = true;
await enhanceCompletionHandler(model);
editing = false;
onEdited();
versionIdx = null;
}
const stopResponseHandler = async () => {
stopResponseFlag = true;
console.log('stopResponse', stopResponseFlag);
};
function setContentByVersion(versionIdx) {
if (!note.data.versions?.length) return;
let idx = versionIdx;
@@ -433,13 +383,6 @@ ${content}
files = [...files, fileItem];
// open the settings panel if it is not open
selectedPanel = 'settings';
if (!showPanel) {
showPanel = true;
}
try {
// If the file is an audio file, provide the language for STT.
let metadata = null;
@@ -597,6 +540,51 @@ ${content}
});
};
const noteChatFileKey = (file) =>
`${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`;
const getNoteChatFiles = () => {
if (!note) return [];
const seen = new Set();
return (note?.data?.files ?? files ?? []).filter((file) => {
const key = noteChatFileKey(file);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const openNoteChat = async () => {
console.info('[note-chat] open requested', {
noteId: note?.id,
alreadyLoading: noteChatLoading,
currentChatId: noteChatId,
sidebarOpen: showNoteChat
});
if (!note?.id || noteChatLoading) return;
noteChatLoading = true;
const chat = await getNoteChatById(localStorage.token, note.id).catch((error) => {
console.error('[note-chat] open failed', { noteId: note?.id, error });
toast.error(`${error}`);
return null;
});
noteChatLoading = false;
if (chat?.id) {
console.info('[note-chat] open resolved', {
noteId: note.id,
chatId: chat.id,
title: chat.title,
hasChatPayload: !!chat.chat
});
noteChatId = chat.id;
showNoteChat = true;
} else {
console.warn('[note-chat] open returned no chat id', { noteId: note.id, chat });
}
};
const downloadHandler = async (type) => {
console.log('downloadHandler', type);
if (type === 'txt') {
@@ -629,113 +617,6 @@ ${content}
}
};
const scrollToBottom = () => {
const element = document.getElementById('note-content-container');
if (element) {
element.scrollTop = element?.scrollHeight;
}
};
const enhanceCompletionHandler = async (model) => {
stopResponseFlag = false;
let enhancedContent = {
json: null,
html: '',
md: ''
};
const systemPrompt = `Enhance existing notes using additional context provided from audio transcription or uploaded file content in the content's primary language. Your task is to make the notes more useful and comprehensive by incorporating relevant information from the provided context.
Input will be provided within <notes> and <context> XML tags, providing a structure for the existing notes and context respectively.
# Output Format
Provide the enhanced notes in markdown format. Use markdown syntax for headings, lists, task lists ([ ]) where tasks or checklists are strongly implied, and emphasis to improve clarity and presentation. Ensure that all integrated content from the context is accurately reflected. Return only the markdown formatted note.
`;
const [res, controller] = await chatCompletion(
localStorage.token,
{
model: model.id,
stream: true,
messages: [
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content:
`<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>`
: '')
}
]
},
`${WEBUI_BASE_URL}/api`
);
await tick();
streaming = true;
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');
}
editing = false;
streaming = false;
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
console.log(line);
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
if (data.choices && data.choices.length > 0) {
const choice = data.choices[0];
if (choice.delta && choice.delta.content) {
enhancedContent.md += choice.delta.content;
enhancedContent.html = marked.parse(enhancedContent.md);
note.data.content.md = enhancedContent.md;
note.data.content.html = enhancedContent.html;
note.data.content.json = null;
scrollToBottom();
}
}
}
}
}
} catch (error) {
console.log(error);
}
}
}
streaming = false;
};
const onDragOver = (e) => {
e.preventDefault();
@@ -800,6 +681,18 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
note.data.files = files;
}
if (_note.data?.content) {
note.data.content = {
...note.data.content,
..._note.data.content
};
if (editor) {
editor.commands.setContent(
_note.data.content.html || marked.parse(_note.data.content.md ?? '')
);
}
}
if (_note.title && _note.title) {
note.title = _note.title;
}
@@ -1025,44 +918,19 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div>
</div>
{/if}
<Tooltip placement="top" content={$i18n.t('Chat')} className="cursor-pointer">
<button
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
on:click={() => {
if (showPanel && selectedPanel === 'chat') {
showPanel = false;
} else {
if (!showPanel) {
showPanel = true;
}
selectedPanel = 'chat';
}
}}
>
<ChatBubbleOval />
</button>
</Tooltip>
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
<button
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
on:click={() => {
if (showPanel && selectedPanel === 'settings') {
showPanel = false;
} else {
if (!showPanel) {
showPanel = true;
}
selectedPanel = 'settings';
}
}}
>
<AdjustmentsHorizontalOutline />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Controls')} placement="top">
<button
type="button"
class="p-1 bg-transparent hover:bg-white/5 transition rounded-lg"
aria-label={$i18n.t('Controls')}
on:click={openNoteChat}
>
<AdjustmentsHorizontalOutline className="size-4" />
</button>
</Tooltip>
{#if note?.write_access}
<RecordMenu
onRecord={async () => {
@@ -1150,6 +1018,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
onDelete={() => {
showDeleteConfirm = true;
}}
onChat={openNoteChat}
isPinned={$pinnedNotes.some((n) => n.id === note.id)}
onPin={async () => {
await toggleNotePinnedStatusById(localStorage.token, note.id);
@@ -1239,14 +1108,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
class=" flex-1 w-full h-full overflow-auto px-2.5 relative"
id="note-content-container"
>
{#if editing}
<div
class="w-full h-full fixed top-0 left-0 {streaming
? ''
: ' backdrop-blur-xs bg-white/10 dark:bg-gray-900/10'} flex items-center justify-center z-10 cursor-not-allowed"
></div>
{/if}
<RichTextInput
bind:this={inputElement}
bind:editor
@@ -1264,7 +1125,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
image={true}
{files}
placeholder={$i18n.t('Write something...')}
editable={versionIdx === null && !editing && note?.write_access}
editable={versionIdx === null && note?.write_access}
onSelectionUpdate={({ editor }) => {
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
@@ -1349,126 +1210,47 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div>
{/if}
</div>
<div class="absolute z-50 bottom-0 right-0 p-3.5 flex select-none">
<div class="flex flex-col gap-2 justify-end">
{#if recording}
<div class="flex-1 w-full">
<VoiceRecording
bind:recording
className="p-1 w-full max-w-full"
transcribe={false}
displayMedia={displayMediaRecord}
echoCancellation={false}
noiseSuppression={false}
onCancel={() => {
recording = false;
displayMediaRecord = false;
}}
onConfirm={(data) => {
if (data?.file) {
uploadFileHandler(data?.file);
}
recording = false;
displayMediaRecord = false;
}}
/>
</div>
{:else}
<div
class="cursor-pointer flex gap-0.5 rounded-full border border-gray-50 dark:border-gray-850/30 dark:bg-gray-850 transition shadow-xl"
>
<Tooltip content={$i18n.t('AI')} placement="top">
{#if editing}
<button
class="p-2 flex justify-center items-center hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full transition shrink-0"
on:click={() => {
stopResponseHandler();
}}
type="button"
>
<Spinner className="size-5" />
</button>
{:else}
<AiMenu
onEdit={() => {
enhanceNoteHandler();
}}
onChat={() => {
showPanel = true;
selectedPanel = 'chat';
}}
>
<div
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
>
<SparklesSolid />
</div>
</AiMenu>
{/if}
</Tooltip>
</div>
{/if}
</div>
</div>
</Pane>
<NotePanel bind:show={showPanel}>
{#if selectedPanel === 'chat'}
<Chat
bind:show={showPanel}
bind:selectedModelId
bind:messages
bind:note
bind:editing
bind:streaming
bind:stopResponseFlag
{editor}
{inputElement}
{selectedContent}
{files}
onInsert={insertHandler}
onStop={stopResponseHandler}
{onEdited}
insertNoteHandler={() => {
insertNoteVersion(note);
}}
scrollToBottomHandler={scrollToBottom}
/>
{:else if selectedPanel === 'settings'}
<Controls
bind:show={showPanel}
bind:selectedModelId
bind:files
onUpdate={(updatedFiles) => {
files = updatedFiles;
note.data.files = files.length > 0 ? files : null;
if (editor) {
editor.storage.files = files;
const fileIds = new Set(files.map((file) => file.id));
const ranges = [];
editor.state.doc.descendants((node, pos) => {
const src = node.attrs.src;
if (
node.type.name === 'image' &&
src?.startsWith('data://') &&
!fileIds.has(src.slice('data://'.length))
) {
ranges.push([pos, pos + node.nodeSize]);
{#if recording}
<div class="absolute z-50 bottom-0 right-0 p-3.5 flex select-none">
<div class="flex-1 w-full">
<VoiceRecording
bind:recording
className="p-1 w-full max-w-full"
transcribe={false}
displayMedia={displayMediaRecord}
echoCancellation={false}
noiseSuppression={false}
onCancel={() => {
recording = false;
displayMediaRecord = false;
}}
onConfirm={(data) => {
if (data?.file) {
uploadFileHandler(data?.file);
}
});
if (ranges.length > 0) {
let transaction = editor.state.tr;
ranges.reverse().forEach(([from, to]) => {
transaction = transaction.delete(from, to);
});
editor.view.dispatch(transaction);
}
}
changeDebounceHandler();
recording = false;
displayMediaRecord = false;
}}
/>
</div>
</div>
{/if}
</Pane>
<NotePanel bind:show={showNoteChat}>
{#if noteChatLoading}
<div class="flex h-full items-center justify-center">
<Spinner className="size-5" />
</div>
{:else if noteChatId}
<Chat
embedded={true}
chatIdProp={noteChatId}
initialFiles={getNoteChatFiles()}
selectedText={selectedContent?.text ?? ''}
onInsertToNote={insertHandler}
onCloseEmbedded={() => {
showNoteChat = false;
}}
/>
{/if}
@@ -1,445 +0,0 @@
<script lang="ts">
export let show = false;
export let selectedModelId = '';
import { marked } from 'marked';
// 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>`;
}
}
});
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { onMount, tick, getContext } from 'svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, config, user, models, settings } from '$lib/stores';
import { chatCompletion } 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';
import Pencil from '$lib/components/icons/Pencil.svelte';
import PencilSquare from '$lib/components/icons/PencilSquare.svelte';
const i18n = getContext('i18n');
export let editor = null;
export let editing = false;
export let streaming = false;
export let stopResponseFlag = false;
export let note = null;
export let selectedContent = null;
export let files = [];
export let messages = [];
export let onInsert = (content) => {};
export let onStop = () => {};
export let onEdited = () => {};
export let insertNoteHandler = () => {};
export let scrollToBottomHandler = () => {};
let loaded = false;
let loading = false;
let messagesContainerElement: HTMLDivElement;
let system = '';
let editEnabled = false;
let chatInputElement = null;
const DEFAULT_DOCUMENT_EDITOR_PROMPT = `You are an expert document editor.
## Task
Based on the user's instruction, update and enhance the existing notes or selection by incorporating relevant and accurate information from the provided context in the content's primary language. 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.
- Current note selection: Enclosed within <selection></selection> XML tags.
- Editing instruction: Provided in the user message.
## Output Instructions
- If a selection is provided, edit **only** the content within <selection></selection>. Leave unselected parts unchanged.
- If no selection is provided, edit the entire notes.
- 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, lists, task lists ([ ]) where tasks or checklists are strongly implied, 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;
const scrollToBottom = () => {
if (messagesContainerElement) {
if (scrolledToBottom) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
}
};
const onScroll = () => {
if (messagesContainerElement) {
scrolledToBottom =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 10;
}
};
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;
}
let responseMessage;
if (messages.at(-1)?.role === 'assistant') {
responseMessage = messages.at(-1);
} else {
responseMessage = {
role: 'assistant',
content: '',
done: false
};
messages.push(responseMessage);
messages = messages;
}
await tick();
scrollToBottom();
stopResponseFlag = false;
let enhancedContent = {
json: null,
html: '',
md: ''
};
system = '';
if (editEnabled) {
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>`
: '') +
(selectedContent ? `\n<selection>${selectedContent?.text}</selection>` : '');
// Filter out empty assistant placeholder messages to avoid sending
// an empty trailing assistant message as "response prefill", which is
// incompatible with enable_thinking in llama.cpp and similar backends.
const filteredMessages = messages.filter((m) => !(m.role === 'assistant' && m.content === ''));
const chatMessages = JSON.parse(
JSON.stringify([
{
role: 'system',
content: `${system}`
},
...filteredMessages
])
);
const [res, controller] = await chatCompletion(
localStorage.token,
{
model: model.id,
stream: true,
messages: chatMessages.map((m) => ({
role: m.role,
content: m.content
}))
// ...(files && files.length > 0 ? { files } : {}) // TODO: Decide whether to use native file handling or not
},
`${WEBUI_BASE_URL}/api`
);
await tick();
scrollToBottom();
let messageContent = '';
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');
}
if (editEnabled) {
editing = false;
streaming = false;
onEdited();
}
break;
}
try {
let lines = value.split('\n');
for (const line of lines) {
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
if (editEnabled) {
responseMessage.content = `<status title="${$i18n.t('Edited')}" done="true" />`;
if (selectedContent && selectedContent?.text && editor) {
editor.commands.insertContentAt(
{
from: selectedContent.from,
to: selectedContent.to
},
enhancedContent.html || enhancedContent.md || ''
);
selectedContent = null;
}
}
responseMessage.done = true;
messages = messages;
} else {
let data = JSON.parse(line.replace(/^data: /, ''));
console.log(data);
let deltaContent = data.choices[0]?.delta?.content ?? '';
if (responseMessage.content == '' && deltaContent == '\n') {
continue;
} else {
if (editEnabled) {
editing = true;
streaming = true;
enhancedContent.md += deltaContent;
enhancedContent.html = marked.parse(enhancedContent.md);
if (!selectedContent || !selectedContent?.text) {
note.data.content.md = enhancedContent.md;
note.data.content.html = enhancedContent.html;
note.data.content.json = null;
}
scrollToBottomHandler();
responseMessage.content = `<status title="${$i18n.t('Editing')}" done="false" />`;
messages = messages;
} else {
messageContent += deltaContent;
responseMessage.content = messageContent;
messages = messages;
}
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();
messages = messages.map((message) => {
message.done = true;
return message;
});
loading = false;
stopResponseFlag = false;
}
};
onMount(async () => {
editEnabled = localStorage.getItem('noteEditEnabled') === 'true';
loaded = true;
await tick();
scrollToBottom();
});
</script>
<div class="flex items-center mb-1.5 pt-1.5">
<div class="flex items-center mr-1">
<button
class="p-0.5 bg-transparent transition rounded-lg"
on:click={() => {
show = !show;
}}
>
<XMark className="size-5" strokeWidth="2.5" />
</button>
</div>
<div class=" font-normal text-base flex items-center gap-1">
<div>
{$i18n.t('Chat')}
</div>
<div>
<Tooltip
content={$i18n.t(
'This feature is experimental and may be modified or discontinued without notice.'
)}
position="top"
className="inline-block"
>
<span
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
>{$i18n.t('Experimental')}</span
>
</Tooltip>
</div>
</div>
</div>
<div class="flex flex-col items-center flex-1 @container">
<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
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 scrollbar-hidden"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={onScroll}
>
<div class=" h-full w-full flex flex-col">
<div class="flex-1 p-1">
<Messages bind:messages {onInsert} />
</div>
</div>
</div>
<div class=" pb-[1rem]">
{#if selectedContent}
<div class="text-xs rounded-xl px-2.5 py-3 w-full markdown-prose-xs">
<blockquote>
<div class=" line-clamp-3">
{selectedContent?.text}
</div>
</blockquote>
</div>
{/if}
<MessageInput
bind:chatInputElement
acceptFiles={false}
inputLoading={loading}
showFormattingToolbar={false}
onSubmit={submitHandler}
{onStop}
>
<div slot="menu" class="flex items-center justify-between gap-2 w-full pr-1">
<div>
<Tooltip content={$i18n.t('Edit')} placement="top">
<button
on:click|preventDefault={() => {
editEnabled = !editEnabled;
localStorage.setItem('noteEditEnabled', editEnabled ? 'true' : 'false');
}}
disabled={streaming || loading}
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 {editEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-200/5'
: 'bg-transparent text-gray-600 dark:text-gray-300 '} disabled:opacity-50 disabled:pointer-events-none"
>
<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-full text-right pr-5"
bind:value={selectedModelId}
>
{#each $models.filter((model) => !(model?.info?.meta?.hidden ?? false)) as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
>{model.name}</option
>
{/each}
</select>
</Tooltip>
</div>
</MessageInput>
</div>
</div>
</div>
</div>
</div>
@@ -1,103 +0,0 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowUpLeft from '$lib/components/icons/ArrowUpLeft.svelte';
export let message;
export let idx;
export let onDelete;
export let onEdit;
export let onInsert;
let textAreaElement: HTMLTextAreaElement;
</script>
<div class="flex flex-col gap-1 group">
<div class="flex items-center justify-between pt-1">
<div class="py-1 text-sm font-normal uppercase min-w-[6rem] text-left rounded-lg transition">
{$i18n.t(message.role)}
</div>
<div class="flex items-center gap-2">
<Tooltip placement="top" content={$i18n.t('Insert')}>
<button
class=" text-transparent group-hover:text-gray-500 dark:hover:text-gray-300 transition"
on:click={() => {
onInsert();
}}
>
<ArrowUpLeft className="size-3.5" strokeWidth="2" />
</button>
</Tooltip>
<Tooltip placement="top" content={$i18n.t('Edit')}>
<button
class=" text-transparent group-hover:text-gray-500 dark:hover:text-gray-300 transition"
on:click={() => {
onEdit();
}}
>
<Pencil className="size-3.5" strokeWidth="2" />
</button>
</Tooltip>
<Tooltip placement="top" content={$i18n.t('Delete')}>
<button
class=" text-transparent group-hover:text-gray-500 dark:hover:text-gray-300 transition"
on:click={() => {
onDelete();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</button>
</Tooltip>
</div>
</div>
<div class="flex-1">
<!-- $i18n.t('a user') -->
<!-- $i18n.t('an assistant') -->
{#if !(message?.done ?? true) && message?.content === ''}
<div class="">
<Skeleton size="sm" />
</div>
{:else if message?.edit === true}
<Textarea
className="w-full bg-transparent outline-hidden text-sm resize-none overflow-hidden"
placeholder={$i18n.t(`Enter {{role}} message here`, {
role: message.role === 'user' ? $i18n.t('a user') : $i18n.t('an assistant')
})}
bind:value={message.content}
onBlur={() => {
message.edit = false;
}}
/>
{:else}
<div class=" markdown-prose-sm text-sm">
<Markdown id={`note-message-${idx}`} content={message.content} />
</div>
{/if}
</div>
</div>
@@ -1,32 +0,0 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import Message from './Message.svelte';
const i18n = getContext('i18n');
export let messages = [];
export let onInsert = (content: string) => {};
</script>
<div class="space-y-3 pb-12">
{#each messages as message, idx}
<Message
{message}
{idx}
onInsert={() => {
onInsert(message?.content ?? '');
}}
onEdit={() => {
messages = messages.map((msg, messageIdx) => {
if (messageIdx === idx) {
return { ...msg, edit: true };
}
return msg;
});
}}
onDelete={() => {
messages = messages.filter((message, messageIdx) => messageIdx !== idx);
}}
/>
{/each}
</div>
@@ -1,103 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import XMark from '$lib/components/icons/XMark.svelte';
import { models } from '$lib/stores';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import FileItem from '$lib/components/common/FileItem.svelte';
import Image from '$lib/components/common/Image.svelte';
export let show = false;
export let selectedModelId = '';
export let files = [];
export let onUpdate = (files: any[]) => {
// Default no-op function
};
</script>
<div class="flex items-center mb-1.5 pt-1.5">
<div class=" mr-1 flex items-center">
<button
class="p-0.5 bg-transparent transition rounded-lg"
on:click={() => {
show = !show;
}}
>
<XMark className="size-5" strokeWidth="2.5" />
</button>
</div>
<div class=" font-normal text-base flex items-center gap-1">
<div>
{$i18n.t('Controls')}
</div>
</div>
</div>
<div class="mt-1 px-1.5">
<div class="pb-10">
{#if files.length > 0}
<div class=" text-xs font-normal mb-2">{$i18n.t('Files')}</div>
<div class="flex flex-col gap-1">
{#each files.filter((file) => file.type !== 'image') as file, fileIdx}
<FileItem
className="w-full"
item={file}
small={true}
edit={true}
dismissible={true}
url={file.url}
name={file.name}
type={file.type}
size={file?.size}
loading={file.status === 'uploading'}
on:dismiss={() => {
// Remove the file from the files array
files = files.filter((item) => item.id !== file.id);
files = files;
onUpdate(files);
}}
on:click={() => {
console.log(file);
}}
/>
{/each}
<div class="flex items-center flex-wrap gap-2 mt-1.5">
{#each files.filter((file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) as file, fileIdx}
<Image
src={file.url}
imageClassName=" size-14 rounded-xl object-cover"
dismissible={true}
onDismiss={() => {
files = files.filter((item) => item.id !== file.id);
files = files;
onUpdate(files);
}}
/>
{/each}
</div>
</div>
<hr class="my-2 border-gray-50 dark:border-gray-700/10" />
{/if}
<div class=" text-xs font-normal mb-1">{$i18n.t('Model')}</div>
<div class="w-full">
<select class="w-full bg-transparent text-sm outline-hidden" bind:value={selectedModelId}>
<option value="" class="bg-gray-50 dark:bg-gray-700" disabled>
{$i18n.t('Select a model')}
</option>
{#each $models.filter((model) => !(model?.info?.meta?.hidden ?? false)) as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
</div>
</div>
+3 -5
View File
@@ -3,7 +3,6 @@
import { Pane, PaneResizer } from 'paneforge';
import Drawer from '../common/Drawer.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
export let show = false;
export let pane = null;
@@ -35,14 +34,13 @@
const container = document.getElementById(containerId);
// initialize the minSize based on the container width
minSize = Math.floor((400 / container.clientWidth) * 100);
minSize = Math.floor((350 / container.clientWidth) * 100);
// Create a new ResizeObserver instance
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.contentRect.width;
// calculate the percentage of 200px
const percentage = (400 / width) * 100;
const percentage = (350 / width) * 100;
// set the minSize to the percentage, must be an integer
minSize = Math.floor(percentage);
@@ -99,7 +97,7 @@
{#if show}
<div class="flex max-h-full min-h-full">
<div
class="w-full pt-2 bg-white dark:shadow-lg dark:bg-gray-850 z-40 pointer-events-auto overflow-y-auto scrollbar-hidden flex flex-col px-2"
class="w-full bg-white dark:bg-gray-900 z-40 pointer-events-auto overflow-hidden scrollbar-hidden flex flex-col"
>
<slot />
</div>
@@ -11,6 +11,7 @@
import Link from '$lib/components/icons/Link.svelte';
import Pin from '$lib/components/icons/Pin.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
const i18n = getContext('i18n');
@@ -21,6 +22,7 @@
export let onDelete = () => {};
export let onPin = null;
export let isPinned = false;
export let onChat = null;
export let onCopyLink = null;
export let onCopyToClipboard = null;
@@ -40,6 +42,19 @@
<div slot="content">
<DropdownMenu className="min-w-[180px]">
{#if onChat}
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[13px] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
onChat();
show = false;
}}
>
<ChatBubbleOval className="size-3.5" strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Chat with note')}</div>
</button>
{/if}
<DropdownSub contentClass="select-none z-50">
<button
slot="trigger"