mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 09:31:21 +00:00
refac
This commit is contained in:
parent
9d1b4ef362
commit
91e8deee74
10 changed files with 606 additions and 78 deletions
|
|
@ -108,10 +108,12 @@ async def mentions(
|
|||
return result
|
||||
|
||||
|
||||
async def list_comments(db, user, resource, resource_type, resource_id, parent_id, cursor, limit):
|
||||
async def list_comments(db, user, resource, resource_type, resource_id, parent_id, cursor, limit, *, anchored=False):
|
||||
query = select(Comment).where(
|
||||
Comment.resource_type == resource_type, Comment.resource_id == resource_id, Comment.parent_id == parent_id
|
||||
)
|
||||
if anchored:
|
||||
query = query.where(Comment.meta['anchor'].as_string().is_not(None), Comment.deleted_at.is_(None))
|
||||
if cursor:
|
||||
try:
|
||||
timestamp, id = cursor.split(':', 1)
|
||||
|
|
@ -138,11 +140,12 @@ async def list_resource_comments(
|
|||
resource_id: str,
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
anchored: bool = False,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
resource = await comment_resource(resource_type, resource_id, user, db, request=request)
|
||||
result = await list_comments(db, user, resource, resource_type, resource_id, None, cursor, limit)
|
||||
result = await list_comments(db, user, resource, resource_type, resource_id, None, cursor, limit, anchored=anchored)
|
||||
result['total'] = await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Comment)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export type CommentResource = {
|
||||
resource_type:
|
||||
| 'model'
|
||||
| 'knowledge'
|
||||
| 'prompt'
|
||||
| 'skill'
|
||||
| 'tool'
|
||||
| 'note'
|
||||
| 'chat';
|
||||
resource_type: 'model' | 'knowledge' | 'prompt' | 'skill' | 'tool' | 'note' | 'chat';
|
||||
resource_id: string;
|
||||
};
|
||||
export type CommentAnchor = {
|
||||
|
|
@ -71,6 +64,7 @@ export async function commentsRequest<T>(
|
|||
response.status,
|
||||
typeof data.detail === 'string' ? data.detail : 'Unable to load comments'
|
||||
);
|
||||
if (method !== 'GET') window.dispatchEvent(new Event('comments:updated'));
|
||||
return data;
|
||||
}
|
||||
export const resourceQuery = (resource: CommentResource) =>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
import {
|
||||
commentSurface,
|
||||
contentHash,
|
||||
commentSelectionDraft,
|
||||
selectedComment
|
||||
} from '$lib/components/comments/anchors';
|
||||
import {
|
||||
openComments,
|
||||
config,
|
||||
user,
|
||||
artifactCode,
|
||||
|
|
@ -333,7 +333,8 @@
|
|||
<div
|
||||
class="markdown-prose"
|
||||
use:commentSurface={{
|
||||
resource_type: 'chat', resource_id: chatId,
|
||||
resource_type: 'chat',
|
||||
resource_id: chatId,
|
||||
target_type: 'chat_message',
|
||||
target_id: messageId,
|
||||
surface_id: 'content',
|
||||
|
|
@ -388,7 +389,8 @@
|
|||
<div
|
||||
class="whitespace-pre-wrap text-[0.9375rem]"
|
||||
use:commentSurface={{
|
||||
resource_type: 'chat', resource_id: chatId,
|
||||
resource_type: 'chat',
|
||||
resource_id: chatId,
|
||||
target_type: 'chat_message',
|
||||
target_id: messageId,
|
||||
surface_id: 'content',
|
||||
|
|
@ -411,11 +413,7 @@
|
|||
onComment={commentSelection
|
||||
? () => {
|
||||
if (!commentSelection) return;
|
||||
const { options, anchor } = commentSelection;
|
||||
openComments(
|
||||
{ resource_type: options.resource_type, resource_id: options.resource_id },
|
||||
{ target_type: options.target_type, target_id: options.target_id, anchor }
|
||||
);
|
||||
commentSelectionDraft.set(commentSelection);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
closeFloatingButtons();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
export let draftKey: string;
|
||||
export let contextLabel = '';
|
||||
export let quote = '';
|
||||
export let scrollable = true;
|
||||
export let embedded = false;
|
||||
export let onClearContext: (() => void) | null = null;
|
||||
export let initialContent = '';
|
||||
export let submitLabel = 'Comment';
|
||||
|
|
@ -88,35 +90,41 @@
|
|||
: $settings?.chatDirection === 'LTR'
|
||||
? 'ltr'
|
||||
: 'auto'}
|
||||
class="relative rounded-2xl border border-gray-100/50 dark:border-gray-850/50 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition bg-white/5 dark:bg-gray-500/5 ps-3 pb-2.5 {contextLabel ||
|
||||
quote
|
||||
? 'pt-1'
|
||||
: 'pt-2.5'} {($settings?.highContrastMode ?? false)
|
||||
class="relative {embedded
|
||||
? ''
|
||||
: `rounded-2xl border border-gray-100/50 dark:border-gray-850/50 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition bg-white/5 dark:bg-gray-500/5 ps-3 pb-2.5 ${contextLabel || quote ? 'pt-1' : 'pt-2.5'}`} {($settings?.highContrastMode ??
|
||||
false)
|
||||
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500'
|
||||
: ''}"
|
||||
>
|
||||
{#if contextLabel || quote}
|
||||
<div class="pb-2 pe-3 text-left w-full flex flex-col">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span class="truncate">{contextLabel}</span>{#if onClearContext}<button
|
||||
type="button"
|
||||
class="flex h-6 shrink-0 items-center justify-center p-0 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
aria-label={$i18n.t('Remove context')}
|
||||
on:click={onClearContext}><XMark className="size-3.5" /></button
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="pb-2 {embedded ? '' : 'pe-3'} text-left w-full flex flex-col">
|
||||
{#if contextLabel || onClearContext}
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span class="truncate">{contextLabel}</span>{#if onClearContext}<button
|
||||
type="button"
|
||||
class="flex h-6 shrink-0 items-center justify-center p-0 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
aria-label={$i18n.t('Remove context')}
|
||||
on:click={onClearContext}><XMark className="size-3.5" /></button
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if quote}<div
|
||||
class="mb-1 mt-1 max-h-20 overflow-auto border-l-2 border-gray-200 dark:border-gray-700 ps-2 text-xs text-gray-500 whitespace-pre-wrap"
|
||||
class="{embedded ? '' : 'mb-1 mt-1'} {scrollable
|
||||
? 'max-h-20 overflow-auto'
|
||||
: ''} border-l-2 border-gray-200 dark:border-gray-700 ps-2 text-xs text-gray-500 whitespace-pre-wrap break-words"
|
||||
>
|
||||
{quote}
|
||||
</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class={submitLabel === 'Save' ? 'pe-16' : 'pe-12'}>
|
||||
<div class={embedded ? 'min-h-6 py-0.5 pe-9' : submitLabel === 'Save' ? 'pe-16' : 'pe-12'}>
|
||||
<div
|
||||
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full resize-none h-fit max-h-96 overflow-auto"
|
||||
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full resize-none h-fit {scrollable
|
||||
? 'max-h-96 overflow-auto'
|
||||
: ''}"
|
||||
>
|
||||
<RichTextInput
|
||||
id={`comment-input-${tempId}`}
|
||||
|
|
@ -154,7 +162,7 @@
|
|||
</div>
|
||||
</div>
|
||||
{#if error}<p role="alert" class="py-1 text-xs text-red-500">{error}</p>{/if}
|
||||
<div class="absolute bottom-2 end-2.5 flex items-center">
|
||||
<div class="absolute {embedded ? 'bottom-0 end-0' : 'bottom-2 end-2.5'} flex items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
488
src/lib/components/comments/CommentHighlights.svelte
Normal file
488
src/lib/components/comments/CommentHighlights.svelte
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
import tippy, { type Instance } from 'tippy.js';
|
||||
import dayjs from '$lib/dayjs';
|
||||
import { socket, user, commentsView, openComments } from '$lib/stores';
|
||||
import { commentsRequest, resourceQuery, type ResourceComment } from '$lib/apis/comments';
|
||||
import { isSavedChatId } from '$lib/utils/chatId';
|
||||
import {
|
||||
anchorRange,
|
||||
getCommentSurfaces,
|
||||
surfaceRevision,
|
||||
commentSelectionDraft,
|
||||
type CommentSurface
|
||||
} from './anchors';
|
||||
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
|
||||
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import CommentComposer from './CommentComposer.svelte';
|
||||
import { scrollToComment } from '$lib/utils';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
type LocatedComment = { comment: ResourceComment; range: Range; node: HTMLElement };
|
||||
const i18n = getContext<any>('i18n');
|
||||
let mounted = false;
|
||||
let records: Record<string, ResourceComment[]> = {};
|
||||
let located: LocatedComment[] = [];
|
||||
let hovered: LocatedComment[] = [];
|
||||
let preview: HTMLDivElement;
|
||||
let popup: Instance;
|
||||
let referenceRect: DOMRect;
|
||||
let refreshTimer: ReturnType<typeof setTimeout>;
|
||||
let connection: typeof $socket = null;
|
||||
let composer: CommentComposer;
|
||||
let previousDraft: typeof $commentSelectionDraft = null;
|
||||
let postedComment: ResourceComment | null = null;
|
||||
const requests = new Map<string, AbortController>();
|
||||
const focusable = new Map<HTMLElement, { tabindex: string | null; description: string | null }>();
|
||||
|
||||
$: surfaces =
|
||||
($surfaceRevision,
|
||||
[...getCommentSurfaces()].filter(
|
||||
([, options]) =>
|
||||
options.resource_type === 'chat' &&
|
||||
options.enabled !== false &&
|
||||
isSavedChatId(options.resource_id)
|
||||
));
|
||||
$: resourceIds = [...new Set(surfaces.map(([, options]) => options.resource_id))];
|
||||
$: if (mounted) syncResources(resourceIds);
|
||||
$: if (mounted) rebuild(surfaces, records);
|
||||
$: draftRange = $commentSelectionDraft ? selectionRange($commentSelectionDraft, surfaces) : null;
|
||||
$: if (mounted) paint('comment-draft', draftRange ? [draftRange] : []);
|
||||
$: if (mounted && $commentSelectionDraft !== previousDraft) {
|
||||
previousDraft = $commentSelectionDraft;
|
||||
if ($commentSelectionDraft) void startDraft($commentSelectionDraft);
|
||||
else hide();
|
||||
}
|
||||
$: if (mounted)
|
||||
paint(
|
||||
'comment-active',
|
||||
(hovered.length
|
||||
? hovered
|
||||
: located.filter(({ comment }) => comment.id === $commentsView?.comment_id)
|
||||
).map(({ range }) => range)
|
||||
);
|
||||
|
||||
const paint = (name: string, ranges: Range[]) => {
|
||||
const highlights = (window.CSS as any)?.highlights;
|
||||
const HighlightClass = (window as any).Highlight;
|
||||
if (!highlights || !HighlightClass) return;
|
||||
if (ranges.length) highlights.set(name, new HighlightClass(...ranges));
|
||||
else highlights.delete(name);
|
||||
};
|
||||
const selectionRange = (
|
||||
draft: NonNullable<typeof $commentSelectionDraft>,
|
||||
entries: typeof surfaces
|
||||
) => {
|
||||
const entry = entries.find(
|
||||
([, surface]) =>
|
||||
surface.resource_id === draft.options.resource_id &&
|
||||
surface.target_id === draft.options.target_id &&
|
||||
surface.surface_id === draft.anchor.surface_id
|
||||
);
|
||||
return entry ? anchorRange(entry[0], draft.anchor) : null;
|
||||
};
|
||||
const startDraft = async (draft: NonNullable<typeof $commentSelectionDraft>) => {
|
||||
hovered = [];
|
||||
postedComment = null;
|
||||
referenceRect = draft.rect;
|
||||
await tick();
|
||||
if (!mounted || $commentSelectionDraft !== draft) return;
|
||||
popup.show();
|
||||
await popup.popperInstance?.update();
|
||||
composer?.focus();
|
||||
};
|
||||
const dismissDraft = () => {
|
||||
commentSelectionDraft.set(null);
|
||||
hide();
|
||||
};
|
||||
const submitted = async () => {
|
||||
const created = postedComment;
|
||||
if (!mounted || !created) return;
|
||||
commentSelectionDraft.set(null);
|
||||
await tick();
|
||||
const item = located.find(({ comment }) => comment.id === created.id);
|
||||
if (item) void reveal([item], item.range.getBoundingClientRect());
|
||||
};
|
||||
const load = async (id: string) => {
|
||||
requests.get(id)?.abort();
|
||||
const controller = new AbortController();
|
||||
requests.set(id, controller);
|
||||
let items: ResourceComment[] = [];
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
do {
|
||||
const page = await commentsRequest<{
|
||||
items: ResourceComment[];
|
||||
next_cursor: string | null;
|
||||
}>(
|
||||
`/?${resourceQuery({ resource_type: 'chat', resource_id: id })}&anchored=true&limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
|
||||
'GET',
|
||||
undefined,
|
||||
controller.signal
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
items = [
|
||||
...items,
|
||||
...page.items.filter((comment) => !comment.deleted_at && comment.meta?.anchor)
|
||||
];
|
||||
cursor = page.next_cursor;
|
||||
} while (cursor);
|
||||
if (!controller.signal.aborted) records = { ...records, [id]: items };
|
||||
} catch {
|
||||
if (!controller.signal.aborted) {
|
||||
records = { ...records, [id]: [] };
|
||||
hide();
|
||||
}
|
||||
}
|
||||
};
|
||||
const syncResources = (ids: string[]) => {
|
||||
for (const [id, request] of requests) {
|
||||
if (ids.includes(id)) continue;
|
||||
request.abort();
|
||||
requests.delete(id);
|
||||
delete records[id];
|
||||
}
|
||||
for (const id of ids) if (!requests.has(id)) void load(id);
|
||||
};
|
||||
const refresh = () => {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => {
|
||||
for (const id of resourceIds) void load(id);
|
||||
}, 150);
|
||||
};
|
||||
const changed = (resource: { resource_type: string; resource_id: string }) => {
|
||||
if (resource.resource_type === 'chat' && resourceIds.includes(resource.resource_id)) refresh();
|
||||
};
|
||||
$: if (mounted && connection !== $socket) {
|
||||
connection?.off('comments:changed', changed);
|
||||
connection?.off('connect', refresh);
|
||||
connection = $socket;
|
||||
connection?.on('comments:changed', changed);
|
||||
connection?.on('connect', refresh);
|
||||
}
|
||||
const restoreFocus = () => {
|
||||
for (const [node, previous] of focusable) {
|
||||
for (const [attribute, value] of [
|
||||
['tabindex', previous.tabindex],
|
||||
['aria-description', previous.description]
|
||||
]) {
|
||||
if (value === null) node.removeAttribute(attribute!);
|
||||
else node.setAttribute(attribute!, value!);
|
||||
}
|
||||
}
|
||||
focusable.clear();
|
||||
};
|
||||
const rebuild = (entries: [HTMLElement, CommentSurface][], comments: typeof records) => {
|
||||
restoreFocus();
|
||||
const next: LocatedComment[] = [];
|
||||
for (const [node, surface] of entries) {
|
||||
for (const comment of comments[surface.resource_id] ?? []) {
|
||||
const anchor = comment.meta?.anchor;
|
||||
if (
|
||||
!anchor ||
|
||||
comment.target_id !== surface.target_id ||
|
||||
anchor.surface_id !== surface.surface_id
|
||||
)
|
||||
continue;
|
||||
const range = anchorRange(node, anchor);
|
||||
if (range) next.push({ comment, range, node });
|
||||
}
|
||||
}
|
||||
located = next;
|
||||
paint(
|
||||
'comment-anchors',
|
||||
next.map(({ range }) => range)
|
||||
);
|
||||
for (const node of new Set(next.map(({ node }) => node))) {
|
||||
focusable.set(node, {
|
||||
tabindex: node.getAttribute('tabindex'),
|
||||
description: node.getAttribute('aria-description')
|
||||
});
|
||||
if (!node.hasAttribute('tabindex')) node.tabIndex = 0;
|
||||
node.setAttribute('aria-description', $i18n.t('Comments'));
|
||||
}
|
||||
if (hovered.length) {
|
||||
hovered = next.filter(({ comment }) =>
|
||||
hovered.some((item) => item.comment.id === comment.id)
|
||||
);
|
||||
if (!hovered.length) hide();
|
||||
}
|
||||
};
|
||||
const hide = () => {
|
||||
popup?.hide();
|
||||
hovered = [];
|
||||
};
|
||||
const leave = () => {
|
||||
if (!$commentSelectionDraft) hide();
|
||||
};
|
||||
const reveal = async (items: LocatedComment[], rect: DOMRect) => {
|
||||
referenceRect = rect;
|
||||
hovered = items;
|
||||
await tick();
|
||||
if (!mounted || !hovered.length) return;
|
||||
popup.show();
|
||||
void popup.popperInstance?.update();
|
||||
};
|
||||
const atPoint = (event: MouseEvent) => {
|
||||
if (!(event.target instanceof Node)) return [];
|
||||
return located.filter(
|
||||
({ node, range }) =>
|
||||
node.contains(event.target as Node) &&
|
||||
[...range.getClientRects()].some(
|
||||
(rect) =>
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom
|
||||
)
|
||||
);
|
||||
};
|
||||
const move = (event: MouseEvent) => {
|
||||
if ($commentSelectionDraft) return;
|
||||
if (popup?.popper.contains(event.target as Node)) return;
|
||||
if (event.buttons || !window.getSelection()?.isCollapsed) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
const items = atPoint(event);
|
||||
if (!items.length) {
|
||||
leave();
|
||||
return;
|
||||
}
|
||||
if (items.length === hovered.length && items.every((item, index) => item === hovered[index])) {
|
||||
return;
|
||||
}
|
||||
const rect = [...items[0].range.getClientRects()].find(
|
||||
(rect) => event.clientY >= rect.top && event.clientY <= rect.bottom
|
||||
)!;
|
||||
void reveal(items, rect);
|
||||
};
|
||||
const open = async (comment: ResourceComment) => {
|
||||
hide();
|
||||
openComments(
|
||||
{ resource_type: comment.resource_type, resource_id: comment.resource_id },
|
||||
{ comment_id: comment.id }
|
||||
);
|
||||
await tick();
|
||||
scrollToComment(comment.id);
|
||||
};
|
||||
const click = (event: MouseEvent) => {
|
||||
if ($commentSelectionDraft) return;
|
||||
if (
|
||||
!window.getSelection()?.isCollapsed ||
|
||||
(event.target as Element)?.closest('a, button, input, textarea, [contenteditable="true"]')
|
||||
)
|
||||
return;
|
||||
const items = atPoint(event);
|
||||
if (items.length === 1) open(items[0].comment);
|
||||
};
|
||||
const focus = (event: FocusEvent) => {
|
||||
if ($commentSelectionDraft) return;
|
||||
const items = located.filter(({ node }) => node === event.target);
|
||||
if (items.length) void reveal(items, items[0].range.getBoundingClientRect());
|
||||
else if (!popup?.popper.contains(event.target as Node) && !popup?.popper.matches(':hover'))
|
||||
hide();
|
||||
};
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if ($commentSelectionDraft) {
|
||||
if (event.key === 'Escape' && !document.getElementById('suggestions-container')) {
|
||||
event.stopPropagation();
|
||||
dismissDraft();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && hovered.length) {
|
||||
event.stopPropagation();
|
||||
hide();
|
||||
}
|
||||
if (event.key === 'Enter' && focusable.has(event.target as HTMLElement)) {
|
||||
const item = located.find(({ node }) => node === event.target);
|
||||
if (item) {
|
||||
event.preventDefault();
|
||||
open(item.comment);
|
||||
}
|
||||
}
|
||||
};
|
||||
const scroll = (event: Event) => {
|
||||
if ($commentSelectionDraft) {
|
||||
void popup?.popperInstance?.update();
|
||||
return;
|
||||
}
|
||||
if (!preview?.contains(event.target as Node)) hide();
|
||||
};
|
||||
onMount(() => {
|
||||
referenceRect = new DOMRect();
|
||||
popup = tippy(document.body, {
|
||||
content: preview,
|
||||
trigger: 'manual',
|
||||
interactive: true,
|
||||
placement: 'bottom-start',
|
||||
getReferenceClientRect: () => draftRange?.getBoundingClientRect() ?? referenceRect,
|
||||
appendTo: () => document.body,
|
||||
arrow: false,
|
||||
maxWidth: 'min(320px, calc(100vw - 24px))',
|
||||
theme: 'comment-preview',
|
||||
duration: 0,
|
||||
hideOnClick: false,
|
||||
offset: [0, 0]
|
||||
});
|
||||
mounted = true;
|
||||
document.addEventListener('mousemove', move);
|
||||
document.addEventListener('mouseleave', leave);
|
||||
window.addEventListener('blur', leave);
|
||||
document.addEventListener('click', click);
|
||||
document.addEventListener('focusin', focus);
|
||||
document.addEventListener('keydown', keydown);
|
||||
document.addEventListener('scroll', scroll, true);
|
||||
window.addEventListener('resize', hide);
|
||||
window.addEventListener('focus', refresh);
|
||||
window.addEventListener('comments:updated', refresh);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearTimeout(refreshTimer);
|
||||
for (const request of requests.values()) request.abort();
|
||||
connection?.off('comments:changed', changed);
|
||||
connection?.off('connect', refresh);
|
||||
document.removeEventListener('mousemove', move);
|
||||
document.removeEventListener('mouseleave', leave);
|
||||
window.removeEventListener('blur', leave);
|
||||
document.removeEventListener('click', click);
|
||||
document.removeEventListener('focusin', focus);
|
||||
document.removeEventListener('keydown', keydown);
|
||||
document.removeEventListener('scroll', scroll, true);
|
||||
window.removeEventListener('resize', hide);
|
||||
window.removeEventListener('focus', refresh);
|
||||
window.removeEventListener('comments:updated', refresh);
|
||||
restoreFocus();
|
||||
paint('comment-anchors', []);
|
||||
paint('comment-active', []);
|
||||
paint('comment-draft', []);
|
||||
popup.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="hidden">
|
||||
<div
|
||||
bind:this={preview}
|
||||
class="rounded-2xl border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 text-gray-800 dark:text-gray-200 shadow-lg"
|
||||
>
|
||||
{#if $commentSelectionDraft}
|
||||
{@const draft = $commentSelectionDraft}
|
||||
<div class="w-80 max-w-[calc(100vw-24px)] px-3 py-2">
|
||||
<div class="mb-2.5 flex min-w-0 items-center gap-2">
|
||||
<ProfileImage
|
||||
src={$user?.id ? `${WEBUI_API_BASE_URL}/users/${$user.id}/profile/image` : undefined}
|
||||
className="-ml-1 size-6 shrink-0 rounded-full!"
|
||||
/>
|
||||
<span class="min-w-0 truncate text-[0.8125rem] text-gray-900 dark:text-gray-100"
|
||||
>{$user?.name ?? $i18n.t('Unknown User')}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Close')}
|
||||
class="ms-auto shrink-0 p-0 text-gray-400 hover:text-gray-900 dark:text-gray-500 dark:hover:text-gray-100"
|
||||
on:click={dismissDraft}><XMark className="size-3.5" /></button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="my-1 border-l-2 border-gray-200 dark:border-gray-700 ps-2 text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-words"
|
||||
>
|
||||
{draft.anchor.quote}
|
||||
</div>
|
||||
{#key `${draft.options.resource_id}:${draft.options.target_id}:${draft.anchor.surface_id}:${draft.anchor.start}:${draft.anchor.end}`}
|
||||
<CommentComposer
|
||||
bind:this={composer}
|
||||
resource={draft.options}
|
||||
scrollable={false}
|
||||
embedded={true}
|
||||
draftKey={`${$user?.id}:${resourceQuery(draft.options)}:${draft.options.target_id}:${draft.anchor.surface_id}:${draft.anchor.source}:${draft.anchor.start}:${draft.anchor.end}`}
|
||||
onCancel={dismissDraft}
|
||||
onSubmitted={submitted}
|
||||
onSubmit={async (content, temp_id) => {
|
||||
const created = await commentsRequest<ResourceComment>('/', 'POST', {
|
||||
resource_type: draft.options.resource_type,
|
||||
resource_id: draft.options.resource_id,
|
||||
target_type: draft.options.target_type,
|
||||
target_id: draft.options.target_id,
|
||||
anchor: draft.anchor,
|
||||
content,
|
||||
temp_id
|
||||
});
|
||||
if (!mounted || $commentSelectionDraft !== draft) return;
|
||||
postedComment = created;
|
||||
records = {
|
||||
...records,
|
||||
[created.resource_id]: [
|
||||
...(records[created.resource_id] ?? []).filter((item) => item.id !== created.id),
|
||||
created
|
||||
]
|
||||
};
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
{#each hovered as { comment } (comment.id)}
|
||||
<div class="px-3 py-2">
|
||||
<div class="mb-2.5 flex min-w-0 items-center gap-2">
|
||||
<ProfileImage
|
||||
src={comment.user?.id
|
||||
? `${WEBUI_API_BASE_URL}/users/${comment.user.id}/profile/image`
|
||||
: undefined}
|
||||
className="-ml-1 size-6 shrink-0 rounded-full!"
|
||||
/>
|
||||
<span class="min-w-0 truncate text-[0.8125rem] text-gray-900 dark:text-gray-100"
|
||||
>{comment.user?.name ?? $i18n.t('Deleted user')}</span
|
||||
>
|
||||
<span class="ms-auto shrink-0 text-xs text-gray-400 dark:text-gray-500"
|
||||
>{dayjs(comment.created_at / 1e6).fromNow()}</span
|
||||
>
|
||||
</div>
|
||||
{#if comment.meta?.anchor}
|
||||
<div
|
||||
class="my-1 border-l-2 border-gray-200 dark:border-gray-700 ps-2 text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-words"
|
||||
>
|
||||
{comment.meta.anchor.quote}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1 text-[0.8125rem] text-gray-600 dark:text-gray-300 break-words">
|
||||
<Markdown
|
||||
id={`comment-preview-${comment.id}`}
|
||||
content={comment.content}
|
||||
done={true}
|
||||
allowEmbeds={false}
|
||||
allowHtml={false}
|
||||
compactPreview={true}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 p-0 text-xs text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
on:click={() => open(comment)}>{$i18n.t('View comment')}</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(::highlight(comment-anchors)) {
|
||||
background-color: #fbbf2433;
|
||||
}
|
||||
:global(::highlight(comment-active)) {
|
||||
background-color: #fbbf2466;
|
||||
}
|
||||
:global(::highlight(comment-draft)) {
|
||||
background-color: #fbbf2466;
|
||||
}
|
||||
:global(.tippy-box[data-theme~='comment-preview']) {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
:global(.tippy-box[data-theme~='comment-preview'] > .tippy-content) {
|
||||
/* Keep the gap to the passage inside the popup's hover area. */
|
||||
padding: 6px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { commentsRequest, type ResourceComment } from '$lib/apis/comments';
|
||||
import CommentItem from './CommentItem.svelte';
|
||||
import CommentReplyComposer from './CommentReplyComposer.svelte';
|
||||
import { groupReplies } from '$lib/utils';
|
||||
import { groupReplies, scrollToComment } from '$lib/utils';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
export let parent: ResourceComment;
|
||||
export let replyTo: ResourceComment | null;
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
if (linkedCommentId && revealedId !== linkedCommentId) {
|
||||
revealedId = linkedCommentId;
|
||||
await tick();
|
||||
document.getElementById(`comment-${linkedCommentId}`)?.scrollIntoView({ block: 'nearest' });
|
||||
if (alive && request === version) scrollToComment(linkedCommentId);
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive && request === version) onError(e);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Annotation from '$lib/components/icons/Annotation.svelte';
|
||||
import CommentsPanel from './CommentsPanel.svelte';
|
||||
import { selectedComment } from './anchors';
|
||||
import CommentHighlights from './CommentHighlights.svelte';
|
||||
import { selectedComment, commentSelectionDraft } from './anchors';
|
||||
import { toast } from 'svelte-sonner';
|
||||
const i18n = getContext<any>('i18n');
|
||||
let selection: ReturnType<typeof selectedComment> = null;
|
||||
|
|
@ -30,13 +31,19 @@
|
|||
navigation += 1;
|
||||
deepLink = '';
|
||||
commentsView.set(null);
|
||||
commentSelectionDraft.set(null);
|
||||
selection = null;
|
||||
}
|
||||
$: commentsAllowed = !!($config?.features?.enable_comments && $user && !$page.url.pathname.startsWith('/s/'));
|
||||
$: commentsAllowed = !!(
|
||||
$config?.features?.enable_comments &&
|
||||
$user &&
|
||||
!$page.url.pathname.startsWith('/s/')
|
||||
);
|
||||
let preparing = false;
|
||||
$: if (($user?.id ?? null) !== account) {
|
||||
account = $user?.id ?? null;
|
||||
commentsView.set(null);
|
||||
commentSelectionDraft.set(null);
|
||||
selection = null;
|
||||
tick().then(() => commentDrafts.clear());
|
||||
}
|
||||
|
|
@ -59,17 +66,12 @@
|
|||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
$: if (
|
||||
commentsAllowed &&
|
||||
$page.url.searchParams.get('comment') &&
|
||||
deepLink !== $page.url.href
|
||||
) {
|
||||
$: if (commentsAllowed && $page.url.searchParams.get('comment') && deepLink !== $page.url.href) {
|
||||
deepLink = $page.url.href;
|
||||
loadLink($page.url.searchParams.get('comment')!);
|
||||
}
|
||||
const select = () => {
|
||||
if (!preparing)
|
||||
selection = commentsAllowed ? selectedComment() : null;
|
||||
if (!preparing) selection = commentsAllowed ? selectedComment() : null;
|
||||
};
|
||||
const commentSelection = async () => {
|
||||
const selected = selection;
|
||||
|
|
@ -80,17 +82,19 @@
|
|||
try {
|
||||
await selected.options.prepare?.();
|
||||
if (version !== navigation || accountId !== account) return;
|
||||
openComments(
|
||||
{
|
||||
resource_type: selected.options.resource_type,
|
||||
resource_id: selected.options.resource_id
|
||||
},
|
||||
{
|
||||
target_type: selected.options.target_type,
|
||||
target_id: selected.options.target_id,
|
||||
anchor: selected.anchor
|
||||
}
|
||||
);
|
||||
if (selected.options.resource_type === 'chat') commentSelectionDraft.set(selected);
|
||||
else
|
||||
openComments(
|
||||
{
|
||||
resource_type: selected.options.resource_type,
|
||||
resource_id: selected.options.resource_id
|
||||
},
|
||||
{
|
||||
target_type: selected.options.target_type,
|
||||
target_id: selected.options.target_id,
|
||||
anchor: selected.anchor
|
||||
}
|
||||
);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
selection = null;
|
||||
} catch (e) {
|
||||
|
|
@ -119,6 +123,7 @@
|
|||
on:scroll|capture={() => (selection = null)}
|
||||
/>
|
||||
{#if commentsAllowed}
|
||||
{#key account}<CommentHighlights />{/key}
|
||||
{#if selection && !selection.options.toolbar_id}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import CommentReplies from './CommentReplies.svelte';
|
||||
import CommentComposer from './CommentComposer.svelte';
|
||||
import CommentReplyComposer from './CommentReplyComposer.svelte';
|
||||
import { scrollToComment } from '$lib/utils';
|
||||
export let view: CommentsView;
|
||||
export let embedded = false;
|
||||
export let onCountChange: (count: number) => void = () => {};
|
||||
|
|
@ -150,8 +151,7 @@
|
|||
if (alive) loading = false;
|
||||
}
|
||||
await tick();
|
||||
if (alive && linkedCommentId === linkedRootId)
|
||||
document.getElementById(`comment-${linkedRootId}`)?.scrollIntoView({ block: 'nearest' });
|
||||
if (alive && linkedRootId && linkedCommentId === linkedRootId) scrollToComment(linkedRootId);
|
||||
};
|
||||
const more = async () => {
|
||||
if (!resource || loadingMore) return;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { isSavedChatId } from '$lib/utils/chatId';
|
||||
import { sha256 } from 'js-sha256';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { CommentAnchor, CommentResource, CommentTarget } from '$lib/apis/comments';
|
||||
|
||||
export type CommentSurface = CommentResource &
|
||||
|
|
@ -21,14 +22,32 @@ export function noteCommentText(content: { json?: unknown; md?: string }) {
|
|||
return content.json && typeof content.json === 'object' ? text(content.json) : (content.md ?? '');
|
||||
}
|
||||
const surfaces = new Map<HTMLElement, CommentSurface>();
|
||||
export const surfaceRevision = writable(0);
|
||||
export const commentSelectionDraft = writable<ReturnType<typeof selectedComment>>(null);
|
||||
export const getCommentSurfaces = () => surfaces;
|
||||
let pendingSurfaceUpdate = false;
|
||||
const surfaceChanged = () => {
|
||||
if (pendingSurfaceUpdate) return;
|
||||
pendingSurfaceUpdate = true;
|
||||
queueMicrotask(() => {
|
||||
pendingSurfaceUpdate = false;
|
||||
surfaceRevision.update((value) => value + 1);
|
||||
});
|
||||
};
|
||||
export function commentSurface(node: HTMLElement, options: CommentSurface) {
|
||||
surfaces.set(node, options);
|
||||
surfaceChanged();
|
||||
const observer = new MutationObserver(surfaceChanged);
|
||||
observer.observe(node, { childList: true, characterData: true, subtree: true });
|
||||
return {
|
||||
update(value: CommentSurface) {
|
||||
surfaces.set(node, value);
|
||||
surfaceChanged();
|
||||
},
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
surfaces.delete(node);
|
||||
surfaceChanged();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -85,8 +104,7 @@ export function selectedComment() {
|
|||
for (const [node, options] of surfaces) {
|
||||
if (
|
||||
options.enabled === false ||
|
||||
(options.resource_type === 'chat' &&
|
||||
!isSavedChatId(options.resource_id)) ||
|
||||
(options.resource_type === 'chat' && !isSavedChatId(options.resource_id)) ||
|
||||
!options.resource_id ||
|
||||
!node.contains(range.startContainer) ||
|
||||
!node.contains(range.endContainer)
|
||||
|
|
@ -132,6 +150,25 @@ export function selectedComment() {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
export function anchorRange(node: HTMLElement, anchor: CommentAnchor): Range | null {
|
||||
const { nodes, text } = surfaceText(node);
|
||||
const position = anchorPosition(text, anchor);
|
||||
if (!position) return null;
|
||||
const range = document.createRange();
|
||||
const start = [...text].slice(0, position[0]).join('').length;
|
||||
const end = [...text].slice(0, position[1]).join('').length;
|
||||
let offset = 0;
|
||||
for (const n of nodes) {
|
||||
if (start >= offset && start < offset + n.length) range.setStart(n, start - offset);
|
||||
if (end > offset && end <= offset + n.length) {
|
||||
range.setEnd(n, end - offset);
|
||||
return range;
|
||||
}
|
||||
offset += n.length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function highlightAnchor(
|
||||
resource: CommentResource,
|
||||
targetId: string,
|
||||
|
|
@ -145,21 +182,8 @@ export function highlightAnchor(
|
|||
options.surface_id !== anchor.surface_id
|
||||
)
|
||||
continue;
|
||||
const { nodes, text } = surfaceText(node);
|
||||
const position = anchorPosition(text, anchor);
|
||||
if (!position) return false;
|
||||
const range = document.createRange();
|
||||
let start = [...text].slice(0, position[0]).join('').length;
|
||||
let end = [...text].slice(0, position[1]).join('').length;
|
||||
let offset = 0;
|
||||
for (const n of nodes) {
|
||||
if (start >= offset && start <= offset + n.length) range.setStart(n, start - offset);
|
||||
if (end >= offset && end <= offset + n.length) {
|
||||
range.setEnd(n, end - offset);
|
||||
break;
|
||||
}
|
||||
offset += n.length;
|
||||
}
|
||||
const range = anchorRange(node, anchor);
|
||||
if (!range) return false;
|
||||
const highlights = (CSS as any).highlights;
|
||||
const HighlightClass = (window as any).Highlight;
|
||||
if (highlights && HighlightClass) {
|
||||
|
|
|
|||
|
|
@ -2519,6 +2519,14 @@ export function groupReplies(items: ResourceComment[], parentId: string) {
|
|||
return children;
|
||||
}
|
||||
|
||||
export function scrollToComment(id: string) {
|
||||
const element = document.getElementById(`comment-${id}`);
|
||||
element?.scrollIntoView({ block: 'center' });
|
||||
element?.animate([{ backgroundColor: '#a5a5c544' }, { backgroundColor: 'transparent' }], {
|
||||
duration: 2200
|
||||
});
|
||||
}
|
||||
|
||||
export function commentLink(
|
||||
comment: Pick<
|
||||
ResourceComment,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue