feat: edit notes via chat

This commit is contained in:
Timothy Jaeryang Baek 2025-07-08 12:31:31 +04:00
parent 45085d04eb
commit 9a064d9623
4 changed files with 181 additions and 60 deletions

View file

@ -84,6 +84,23 @@
{:else}
{token.text}
{/if}
{:else if token.text && token.text.includes('<status')}
{@const match = token.text.match(/<status title="([^"]+)" done="(true|false)" ?\/?>/)}
{@const statusTitle = match && match[1]}
{@const statusDone = match && match[2] === 'true'}
{#if statusTitle}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{statusDone === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 line-clamp-1 text-wrap"
>
{statusTitle}
</div>
</div>
{:else}
{token.text}
{/if}
{:else if token.text.includes(`<file type="html"`)}
{@const match = token.text.match(/<file type="html" id="([^"]+)"/)}
{@const fileId = match && match[1]}

View file

@ -13,13 +13,8 @@
import { marked } from 'marked';
import { toast } from 'svelte-sonner';
import { config, models, settings, showSidebar } from '$lib/stores';
import { goto } from '$app/navigation';
import { compressImage, copyToClipboard, splitStream } from '$lib/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { uploadFile } from '$lib/apis/files';
import dayjs from '$lib/dayjs';
import calendar from 'dayjs/plugin/calendar';
import duration from 'dayjs/plugin/duration';
@ -29,6 +24,21 @@
dayjs.extend(duration);
dayjs.extend(relativeTime);
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import { compressImage, copyToClipboard, splitStream } from '$lib/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { uploadFile } from '$lib/apis/files';
import { chatCompletion } from '$lib/apis/openai';
import { config, models, settings, showSidebar } from '$lib/stores';
import NotePanel from '$lib/components/notes/NotePanel.svelte';
import MenuLines from '../icons/MenuLines.svelte';
import ChatBubbleOval from '../icons/ChatBubbleOval.svelte';
import Settings from './NoteEditor/Settings.svelte';
import Chat from './NoteEditor/Chat.svelte';
async function loadLocale(locales) {
for (const locale of locales) {
try {
@ -69,7 +79,6 @@
import Sidebar from '../common/Sidebar.svelte';
import ArrowRight from '../icons/ArrowRight.svelte';
import Cog6 from '../icons/Cog6.svelte';
import { chatCompletion } from '$lib/apis/openai';
export let id: null | string = null;
@ -682,14 +691,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
dropzoneElement?.removeEventListener('dragleave', onDragLeave);
}
});
import NotePanel from '$lib/components/notes/NotePanel.svelte';
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import XMark from '../icons/XMark.svelte';
import MenuLines from '../icons/MenuLines.svelte';
import ChatBubbleOval from '../icons/ChatBubbleOval.svelte';
import Settings from './NoteEditor/Settings.svelte';
import Chat from './NoteEditor/Chat.svelte';
</script>
<FilesOverlay show={dragged} />
@ -1046,8 +1047,10 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
bind:show={showPanel}
bind:selectedModelId
bind:messages
bind:note
bind:enhancing
bind:streaming
{files}
{note}
onInsert={insertHandler}
/>
{:else if selectedPanel === 'settings'}

View file

@ -2,6 +2,7 @@
export let show = false;
export let selectedModelId = '';
import { marked } from 'marked';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
@ -23,14 +24,21 @@
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 enhancing = false;
export let streaming = false;
export let note = null;
export let files = [];
export let messages = [];
export let onInsert = (content) => {};
export let scrollToBottomHandler = () => {};
let loaded = false;
@ -40,13 +48,43 @@
let messagesContainerElement: HTMLDivElement;
let system = '';
let editorEnabled = false;
let chatInputElement = null;
const scrollToBottom = () => {
const element = messagesContainerElement;
const DEFAULT_DOCUMENT_EDITOR_PROMPT = `You are an expert document editor.
if (element) {
element.scrollTop = element?.scrollHeight;
## 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;
const scrollToBottom = () => {
if (messagesContainerElement) {
if (scrolledToBottom) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
}
}
};
const onScroll = () => {
if (messagesContainerElement) {
scrolledToBottom =
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
messagesContainerElement.clientHeight + 10;
}
};
@ -83,37 +121,43 @@
await tick();
scrollToBottom();
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
])
);
const [res, controller] = await chatCompletion(
localStorage.token,
{
model: model.id,
stream: true,
messages: [
system
? {
role: 'system',
content: system
}
: undefined,
...messages
].filter((message) => message),
files: [
...(note?.data?.content?.md
? [
{
id: `note:${note?.id ?? 'note'}`,
name: note?.name ?? 'Note',
file: {
data: {
content: note?.data?.content?.md
}
},
context: 'full'
}
]
: []), // Include the note content as a file
...files
]
messages: chatMessages
// ...(files && files.length > 0 ? { files } : {}) // TODO: Decide whether to use native file handling or not
},
`${WEBUI_BASE_URL}/api`
);
@ -121,6 +165,8 @@
await tick();
scrollToBottom();
let messageContent = '';
if (res && res.ok) {
const reader = res.body
.pipeThrough(new TextDecoderStream())
@ -133,6 +179,11 @@
if (stopResponseFlag) {
controller.abort('User: Stop Response');
}
if (editorEnabled) {
enhancing = false;
streaming = false;
}
break;
}
@ -143,17 +194,41 @@
if (line !== '') {
console.log(line);
if (line === 'data: [DONE]') {
// responseMessage.done = true;
if (editorEnabled) {
responseMessage.content = '<status title="Edited" done="true" />';
}
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') {
let deltaContent = data.choices[0]?.delta?.content ?? '';
if (responseMessage.content == '' && deltaContent == '\n') {
continue;
} else {
responseMessage.content += data.choices[0].delta.content ?? '';
messages = messages;
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;
}
await tick();
}
@ -205,7 +280,10 @@
} else {
selectedModelId = '';
}
loaded = true;
scrollToBottom();
});
</script>
@ -240,7 +318,7 @@
</div>
</div>
<div class="flex flex-col items-center mb-2 flex-1">
<div class="flex flex-col items-center mb-2 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">
@ -248,6 +326,7 @@
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
id="messages-container"
bind:this={messagesContainerElement}
on:scroll={onScroll}
>
<div class=" h-full w-full flex flex-col">
<div class="flex-1 p-1">
@ -264,15 +343,37 @@
onSubmit={submitHandler}
onStop={stopHandler}
>
<div slot="menu">
<select
class=" bg-transparent rounded-lg py-1 px-2 -mx-0.5 text-sm outline-hidden w-50"
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 slot="menu" class="flex items-center justify-between gap-2 w-full">
<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>
</div>
</MessageInput>
</div>

View file

@ -95,7 +95,7 @@
}}
/>
{:else}
<div class=" markdown-prose-sm">
<div class=" markdown-prose-sm text-sm">
<Markdown id={`note-message-${idx}`} content={message.content} />
</div>
{/if}