This commit is contained in:
Timothy Jaeryang Baek 2026-10-09 16:17:11 +04:00
parent 37138282fb
commit 33dd7dd224
22 changed files with 1921 additions and 1200 deletions

View file

@ -169,6 +169,9 @@ class KnowledgeForm(BaseModel):
class FileUserResponse(FileModelResponse):
directory_id: str | None = None
directory_path: str = ''
has_original: bool = True
user: Optional[UserResponse] = None
@ -535,7 +538,7 @@ class KnowledgeTable:
try:
async with get_async_db_context(db) as db:
stmt = (
select(File, User)
select(File, User, KnowledgeFile.directory_id)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
@ -603,9 +606,27 @@ class KnowledgeTable:
result = await db.execute(stmt)
items = result.all()
directories = {directory.id: directory for directory in await self.get_all_directories(knowledge_id, db=db)}
paths = {}
def directory_path(directory_id):
if directory_id not in paths:
names, seen = [], set()
current = directory_id
while current in directories and current not in seen:
seen.add(current)
directory = directories[current]
names.append(directory.name)
current = directory.parent_id
paths[directory_id] = '/'.join(reversed(names))
return paths[directory_id]
files = [
FileUserResponse(
id=file.id,
directory_id=directory_id,
directory_path=directory_path(directory_id),
has_original=bool(file.path),
user_id=file.user_id,
hash=file.hash,
filename=file.filename,
@ -614,7 +635,7 @@ class KnowledgeTable:
updated_at=file.updated_at,
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
for file, user in items
for file, user, directory_id in items
]
return KnowledgeFileListResponse(

View file

@ -176,8 +176,8 @@ class PromptHistoryTable:
diff_lines = list(
difflib.unified_diff(
from_content.splitlines(keepends=True),
to_content.splitlines(keepends=True),
from_content.splitlines(),
to_content.splitlines(),
fromfile=f'v{from_id[:8]}',
tofile=f'v{to_id[:8]}',
lineterm='',
@ -190,6 +190,7 @@ class PromptHistoryTable:
'from_snapshot': from_snapshot,
'to_snapshot': to_snapshot,
'content_diff': diff_lines,
'line_endings_only': not diff_lines and from_content != to_content,
'name_changed': from_snapshot.get('name') != to_snapshot.get('name'),
}

View file

@ -748,12 +748,14 @@ async def update_file_data_content_by_id(
file = await Files.get_file_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
log.error(f'Error processing file: {file.id}')
log.error(f'Error processing file: {id}')
raise HTTPException(status_code=500, detail='Failed to process indexed text. Your changes were not fully indexed.') from e
# Propagate content change to all knowledge collections referencing
# this file. Without this the old embeddings remain in the knowledge
# collection and RAG returns both stale and current data (#20558).
knowledges = await Knowledges.get_knowledges_by_file_id(id, db=db)
failed_collections = []
for knowledge in knowledges:
try:
old_vectors = await ASYNC_VECTOR_DB_CLIENT.query(collection_name=knowledge.id, filter={'file_id': id})
@ -771,6 +773,7 @@ async def update_file_data_content_by_id(
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, ids=old_vector_ids)
except Exception as e:
log.warning(f'Failed to update knowledge {knowledge.id} after content change for file {id}: {e}')
failed_collections.append(knowledge.id)
await publish_event(
request,
@ -779,6 +782,11 @@ async def update_file_data_content_by_id(
subject_id=id,
data={'content_preview': form_data.content[:300]},
)
if failed_collections:
raise HTTPException(
status_code=500,
detail='Indexed text was saved, but some knowledge collections could not be reindexed. Retry saving to complete indexing.',
)
return {'content': file.data.get('content', '')}
else:
raise HTTPException(
@ -809,6 +817,8 @@ async def get_file_content_by_id(
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db):
try:
if not file.path:
raise HTTPException(status_code=404, detail='Original file is unavailable.')
file_path = await asyncio.to_thread(Storage.get_file, file.path)
file_path = Path(file_path)
@ -931,12 +941,10 @@ async def get_file_content_by_id(
# Check if the file already exists in the cache
if file_path.is_file():
return FileResponse(file_path, headers=headers)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
else:
# Legacy records can retain a path after their original upload has disappeared.
# Preserve their indexed text as the download fallback.
if not file_path or not file_path.is_file():
# File path doesn’t exist, return the content as .txt if possible
file_content = file.data.get('content', '')
file_name = file.filename

View file

@ -661,17 +661,20 @@ async def diff_skill_file(
raise HTTPException(404, 'File not found')
if any(f and f.get('encoding') for f in files):
return {'binary': True}
before, after = ((file or {}).get('content', '') for file in files)
diff = '\n'.join(
difflib.unified_diff(
before.splitlines(),
after.splitlines(),
fromfile=f'{from_id[:7]}/{path}',
tofile=f'{to_id[:7]}/{path}',
lineterm='',
)
)
return {
'binary': False,
'diff': ''.join(
line if line.endswith('\n') else line + '\n\\ No newline at end of file\n'
for line in difflib.unified_diff(
(files[0] or {}).get('content', '').splitlines(True),
(files[1] or {}).get('content', '').splitlines(True),
fromfile=f'{from_id[:7]}/{path}',
tofile=f'{to_id[:7]}/{path}',
)
),
'diff': diff,
'line_endings_only': not diff and before != after,
}

View file

@ -430,3 +430,58 @@ export const deleteAllFiles = async (token: string) => {
return res;
};
export class FileContentError extends Error {
constructor(
message: string,
public status: number
) {
super(message);
}
}
export const getFileBlobById = async (
token: string,
id: string,
{
signal,
attachment = false,
filename
}: { signal?: AbortSignal; attachment?: boolean; filename?: string } = {}
): Promise<Blob> => {
const suffix = filename ? `/${encodeURIComponent(filename)}` : `?attachment=${attachment}`;
const response = await fetch(
`${WEBUI_API_BASE_URL}/files/${encodeURIComponent(id)}/content${suffix}`,
{
headers: { authorization: `Bearer ${token}` },
signal
}
);
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new FileContentError(
error?.detail || `Unable to load file (${response.status})`,
response.status
);
}
return response.blob();
};
export const getFileIndexedText = async (
token: string,
id: string,
signal?: AbortSignal
): Promise<string> => {
const response = await fetch(
`${WEBUI_API_BASE_URL}/files/${encodeURIComponent(id)}/data/content`,
{
headers: { authorization: `Bearer ${token}` },
signal
}
);
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(error?.detail || 'Unable to load indexed text');
}
return (await response.json()).content ?? '';
};

View file

@ -41,6 +41,7 @@ type PromptDiff = {
from_snapshot: object;
to_snapshot: object;
content_diff: string[];
line_endings_only?: boolean;
name_changed: boolean;
access_grants_changed: boolean;
};

View file

@ -46,6 +46,7 @@
export let overlay = false;
export let readOnly = false;
export let allowScripts: boolean | undefined = undefined;
export let onSave: ((content: string) => Promise<void>) | null = null;
export let searchTarget: {
@ -386,7 +387,7 @@
{/if}
<iframe
src={serveUrl}
sandbox="{($settings?.iframeSandboxAllowScripts ?? true)
sandbox="{(allowScripts ?? $settings?.iframeSandboxAllowScripts ?? true)
? 'allow-scripts'
: ''}{($settings?.iframeSandboxAllowDownloads ?? true)
? ' allow-downloads'
@ -402,7 +403,7 @@
{/if}
<iframe
srcdoc={injectCsp(fileContent, $config?.ui?.iframe_csp ?? '')}
sandbox="{($settings?.iframeSandboxAllowScripts ?? true)
sandbox="{(allowScripts ?? $settings?.iframeSandboxAllowScripts ?? true)
? 'allow-scripts'
: ''}{($settings?.iframeSandboxAllowDownloads ?? true)
? ' allow-downloads'
@ -465,7 +466,13 @@
</div>
{:else if isNotebook && !showRaw && parsedNotebook}
<div class="overflow-auto h-full">
<NotebookView notebook={parsedNotebook} filePath={selectedFile ?? ''} {baseUrl} {apiKey} />
<NotebookView
notebook={parsedNotebook}
filePath={selectedFile ?? ''}
{baseUrl}
{apiKey}
{readOnly}
/>
</div>
{:else if isJson && !showRaw && parsedJson !== undefined}
<div class="overflow-auto h-full">

View file

@ -18,6 +18,7 @@
export let filePath: string = '';
export let baseUrl: string = '';
export let apiKey: string = '';
export let readOnly = false;
interface NotebookCell {
cell_type: 'markdown' | 'code' | 'raw';
@ -106,6 +107,7 @@
let editedSources: Record<number, string> = {};
const startEditing = (index: number) => {
if (readOnly) return;
editingCell[index] = true;
editedSources[index] = toStr(cells[index].source);
editingCell = editingCell;
@ -126,7 +128,7 @@
let runningCell: number | null = null;
let runAllActive = false;
const canExecute = baseUrl && apiKey && filePath;
const canExecute = !readOnly && baseUrl && apiKey && filePath;
const startSession = async (): Promise<boolean> => {
if (!baseUrl || !apiKey || !filePath) return false;
@ -217,7 +219,7 @@
<div class="notebook-view">
<!-- Toolbar -->
{#if baseUrl && apiKey && filePath}
{#if !readOnly && baseUrl && apiKey && filePath}
<div class="nb-toolbar flex items-center gap-1 px-2 py-0.5">
<button
class="nb-btn text-[0.6rem]"
@ -304,7 +306,7 @@
<div class="nb-cell-gutter">
{#if runningCell === i}
<div class="nb-cell-label"><Spinner className="size-3" /></div>
{:else if baseUrl && apiKey && filePath}
{:else if !readOnly && baseUrl && apiKey && filePath}
<button
class="nb-run-btn"
on:click={() => runCell(i)}

View file

@ -81,7 +81,7 @@ export function parseDiffRows(code: string): DiffRow[] {
return rows;
}
function emphasizePair(oldRow: DiffRow, newRow: DiffRow) {
export function emphasizePair(oldRow: DiffRow, newRow: DiffRow) {
if (
oldRow.content === newRow.content ||
Math.max(oldRow.content.length, newRow.content.length) >= 1024

View file

@ -9,6 +9,8 @@
export let className = '';
export let zIndexClass = 'z-999';
export let onClose = () => {};
export let onRequestClose: (() => void) | null = null;
const requestClose = () => (onRequestClose ? onRequestClose() : (show = false));
let modalElement = null;
let mounted = false;
@ -20,8 +22,7 @@
matchKeybinding(event) === Shortcut.CLOSE_MODAL)) &&
isTopModal()
) {
console.log('Escape');
show = false;
requestClose();
}
};
@ -67,9 +68,7 @@
bind:this={modalElement}
class="modal fixed right-0 bottom-0 left-0 {zIndexClass} flex h-screen max-h-[100dvh] w-full justify-center overflow-hidden overscroll-contain bg-black/60"
in:fly={{ y: 100, duration: 100 }}
on:mousedown={() => {
show = false;
}}
on:mousedown={requestClose}
>
<div
class=" mt-auto w-full bg-gray-50 dark:bg-gray-900 dark:text-gray-100 {className} scrollbar-hidden max-h-[100dvh] overflow-y-auto"

File diff suppressed because it is too large Load diff

View file

@ -46,13 +46,13 @@
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
<path stroke-linecap="round" d="M12 5v14M5 12h14" />
</svg>
</button>
</Tooltip>

View file

@ -0,0 +1,241 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation';
import { mobile } from '$lib/stores';
import { getKnowledgeById } from '$lib/apis/knowledge';
import { createFocusTrap } from 'focus-trap';
import Files from './Files.svelte';
import FileViewer from './FileViewer.svelte';
import AddContentMenu from './AddContentMenu.svelte';
import ResizableSidePanel from '$lib/components/common/ResizableSidePanel.svelte';
import Drawer from '$lib/components/common/Drawer.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Search from '$lib/components/icons/Search.svelte';
import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
import { newViewerState, type KnowledgeFile, type KnowledgeDirectory } from './types';
export let knowledge: { id: string; name: string; write_access?: boolean };
export let transfers: KnowledgeFile[] = [];
export let syncing: string | null = null;
export let onUpload: (
type: string,
parent: string | null,
breadcrumbs: KnowledgeDirectory[]
) => void = () => {};
export let onSync = () => {};
export let onReset = () => {};
const i18n = getContext<any>('i18n');
let tree: Files;
let selected: KnowledgeFile | null = null;
let state = newViewerState();
let saving = false;
let width = 280;
let query = '';
let inputQuery = '';
let includeContent = false;
let viewOption: string | null = null;
let sortKey = 'updated_at';
let direction = 'desc';
let debounce: ReturnType<typeof setTimeout>;
let showDiscard = false;
let pendingAction: (() => void | Promise<void>) | null = null;
let allowNavigation = false;
$: dirty = state.editing && state.indexed !== null && state.draft !== state.indexed;
$: drawerOpen = $mobile && selected !== null;
$: if (!query) inputQuery = '';
export const refresh = async () => {
await tree?.refresh();
};
export const clearRemovedSelection = (ids?: string[]) => {
if (selected && (!ids || ids.includes(selected.id!))) {
selected = null;
state = newViewerState();
}
};
export const guard = (action: () => void | Promise<void>) => {
if (saving) return;
if (dirty) {
pendingAction = action;
showDiscard = true;
} else action();
};
const select = (file: KnowledgeFile) => {
if (file.id === selected?.id) return;
guard(() => {
selected = file;
state = newViewerState();
});
};
const close = () =>
guard(() => {
selected = null;
state = newViewerState();
});
const changed = async (file?: KnowledgeFile, removed?: string[]) => {
if (file && file.id === selected?.id) selected = file;
else if (removed?.includes(selected?.id || '')) {
selected = null;
state = newViewerState();
} else if (!file && !removed && selected) {
const id = selected.id;
const result = await getKnowledgeById(localStorage.token, knowledge.id);
if (selected?.id === id && !result.files?.some((item: KnowledgeFile) => item.id === id)) {
selected = null;
state = newViewerState();
}
}
};
beforeNavigate((navigation) => {
if (allowNavigation || (!dirty && !saving)) return;
navigation.cancel();
if (!navigation.willUnload && navigation.to && !saving)
guard(() => {
allowNavigation = true;
goto(navigation.to!.url.href).finally(() => (allowNavigation = false));
});
});
const trapFocus = (node: HTMLElement) => {
const trap = createFocusTrap(node, { escapeDeactivates: false });
trap.activate();
return { destroy: () => trap.deactivate() };
};
onDestroy(() => clearTimeout(debounce));
</script>
<ConfirmDialog
bind:show={showDiscard}
title={$i18n.t('Discard unsaved changes?')}
on:confirm={() => {
const action = pendingAction;
pendingAction = null;
state = { ...state, editing: false, draft: state.indexed || '' };
action?.();
}}
on:cancel={() => (pendingAction = null)}
/>
<div
class="flex h-full min-h-0 overflow-hidden rounded-2xl border border-gray-100 bg-gray-50/60 dark:border-white/5 dark:bg-white/[0.03]"
>
{#if $mobile}{@render sidebar()}
{:else}<ResizableSidePanel
open
side="left"
bind:width
minWidth={220}
maxWidth={480}
minSiblingWidth={320}
className="min-h-0"
resizerId="knowledge-files-resizer">{@render sidebar()}</ResizableSidePanel
>{@render viewer()}{/if}
</div>
{#if $mobile}<Drawer show={drawerOpen} className="h-full" onRequestClose={close}
><div
class="h-full"
role="dialog"
aria-modal="true"
aria-label={$i18n.t('File preview')}
tabindex="-1"
use:trapFocus
>
{@render viewer()}
</div></Drawer
>{/if}
{#snippet sidebar()}
<div class="flex h-full min-h-0 w-full flex-col">
<div class="flex shrink-0 items-center gap-1 p-1.5">
<Search className="ml-1 size-3.5 shrink-0 text-gray-400" /><input
aria-label={$i18n.t('Search Collection')}
placeholder={$i18n.t('Search Collection')}
bind:value={inputQuery}
class="h-7 min-w-0 flex-1 bg-transparent px-1 text-xs outline-none"
on:input={() => {
clearTimeout(debounce);
debounce = setTimeout(() => (query = inputQuery.trim()), 300);
}}
/>
<Dropdown align="end"
><button type="button" aria-label={$i18n.t('Filter and sort')} class="p-1 text-gray-500"
><AdjustmentsHorizontal className="size-3.5" /></button
>
<div slot="content">
<DropdownMenu className="min-w-44">
<button
type="button"
role="menuitemcheckbox"
aria-checked={includeContent}
on:click={() => (includeContent = !includeContent)}
>{includeContent ? '✓ ' : ''}{$i18n.t('Search file content')}</button
>
<hr class="border-gray-100 dark:border-gray-800" />
{#each [{ value: null, label: 'All' }, { value: 'created', label: 'Created by you' }, { value: 'shared', label: 'Shared with you' }] as option}<button
type="button"
role="menuitemradio"
aria-checked={viewOption === option.value}
on:click={() => (viewOption = option.value)}
>{viewOption === option.value ? '✓ ' : ''}{$i18n.t(option.label)}</button
>{/each}
<hr class="border-gray-100 dark:border-gray-800" />
{#each [{ value: 'name', label: 'Name' }, { value: 'created_at', label: 'Created' }, { value: 'updated_at', label: 'Updated' }] as option}<button
type="button"
role="menuitemradio"
aria-checked={sortKey === option.value}
on:click={() => (sortKey = option.value)}
>{sortKey === option.value ? '✓ ' : ''}{$i18n.t(option.label)}</button
>{/each}
<button
type="button"
on:click={() => (direction = direction === 'asc' ? 'desc' : 'asc')}
>{$i18n.t(direction === 'asc' ? 'Ascending' : 'Descending')}</button
>
</DropdownMenu>
</div></Dropdown
>
{#if knowledge.write_access}<AddContentMenu
onUpload={(data: { type: string }) => {
if (data.type === 'new_directory') {
query = '';
tree.createDirectory();
} else onUpload(data.type, null, []);
}}
onSync={() => guard(onSync)}
onReset={() => guard(onReset)}
/>{/if}
</div>
{#if syncing}<p class="px-3 py-1 text-xs text-gray-500">{syncing}</p>{/if}
<Files
bind:this={tree}
{knowledge}
{transfers}
bind:query
{includeContent}
{viewOption}
{sortKey}
{direction}
selectedFileId={selected?.id || null}
onSelect={select}
{onUpload}
onChanged={changed}
{guard}
/>
</div>
{/snippet}
{#snippet viewer()}
{#if selected}{#key selected.id}<FileViewer
file={selected}
writeAccess={!!knowledge.write_access && !syncing}
mobile={$mobile}
bind:state
bind:saving
onClose={close}
onSaved={refresh}
/>{/key}
{:else}<div
class="flex min-w-0 flex-1 items-center justify-center bg-white text-xs text-gray-400 dark:bg-gray-900"
>
{$i18n.t('Select a file to preview')}
</div>{/if}
{/snippet}

View file

@ -0,0 +1,201 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
import { formatFileSize } from '$lib/utils';
import FileTypeIcon from '$lib/components/chat/FileNav/FileTypeIcon.svelte';
import Icon from '$lib/components/chat/FileNav/Icon.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { fileName, type KnowledgeFile, type KnowledgeDirectory } from './types';
export let item: KnowledgeFile | KnowledgeDirectory;
export let directory = false;
export let depth = 0;
export let expanded = false;
export let active = false;
export let writeAccess = false;
export let search = false;
export let onOpen = () => {};
export let onRename: (name: string) => Promise<void> = async () => {};
export let onDelete = () => {};
export let onAdd: (type: string) => void = () => {};
export let onDrop: (kind: 'file' | 'directory', id: string) => void = () => {};
export let onDownload = () => {};
const i18n = getContext<any>('i18n');
let renaming = false;
let renameValue = '';
let input: HTMLInputElement;
let saving = false;
let dragOver = false;
$: file = item as KnowledgeFile;
$: name = directory ? (item as KnowledgeDirectory).name : fileName(file);
$: status = file.status || file.data?.status;
$: pending = ['uploading', 'pending', 'processing'].includes(status || '');
$: details = [
file.directory_path,
file.user?.name || file.user?.email,
item.updated_at ? new Date(item.updated_at * 1000).toLocaleString() : ''
]
.filter(Boolean)
.join(' · ');
const startRename = async () => {
renameValue = name;
renaming = true;
await tick();
input?.select();
};
const rename = async () => {
if (!renaming || saving) return;
if (!renameValue.trim() || renameValue.trim() === name) {
renaming = false;
return;
}
saving = true;
try {
await onRename(renameValue.trim());
renaming = false;
} catch {
/* Keep the name editable after the caller reports the error. */
} finally {
saving = false;
}
};
const accepts = (event: DragEvent) =>
writeAccess &&
directory &&
['application/x-kb-file-move', 'application/x-kb-dir-move'].some((type) =>
event.dataTransfer?.types.includes(type)
);
</script>
<div
data-knowledge-row={item.id || file.itemId}
class="flex h-7 min-w-0 items-center rounded-xl {active || dragOver
? 'bg-gray-100 dark:bg-white/8'
: ''}"
style:padding-left={`${8 + depth * 16}px`}
role="presentation"
on:dragover={(event) => {
if (accepts(event)) {
event.preventDefault();
event.stopPropagation();
dragOver = true;
}
}}
on:dragleave={() => (dragOver = false)}
on:drop={(event) => {
if (!accepts(event)) return;
event.preventDefault();
event.stopPropagation();
dragOver = false;
try {
const raw = event.dataTransfer!.getData('application/x-kb-file-move');
if (raw) onDrop('file', JSON.parse(raw).fileId);
else
onDrop(
'directory',
JSON.parse(event.dataTransfer!.getData('application/x-kb-dir-move')).dirId
);
} catch {}
}}
>
<button
type="button"
data-knowledge-entry
class="flex h-full min-w-0 flex-1 items-center gap-1.5 pr-1 text-left text-xs"
title={details || name}
aria-expanded={directory ? expanded : undefined}
on:keydown={(event) => {
if (renaming || !directory) return;
if ((event.key === 'ArrowRight' && !expanded) || (event.key === 'ArrowLeft' && expanded)) {
event.preventDefault();
onOpen();
}
}}
draggable={writeAccess && !!item.id && !pending && !renaming}
on:dragstart={(event) =>
event.dataTransfer?.setData(
directory ? 'application/x-kb-dir-move' : 'application/x-kb-file-move',
JSON.stringify(directory ? { dirId: item.id } : { fileId: item.id })
)}
on:click={() => {
if (!renaming && item.id && status !== 'uploading') onOpen();
}}
>
{#if directory}<Icon name={expanded ? 'chevron-down' : 'chevron-right'} size={12} />{:else}<span
class="w-3 shrink-0"
></span>{/if}
{#if pending}<Spinner className="size-3.5 shrink-0" />{:else}<FileTypeIcon
{name}
type={directory ? 'directory' : 'file'}
size={14}
/>{/if}
{#if renaming}<input
bind:this={input}
bind:value={renameValue}
aria-label={$i18n.t('Rename')}
disabled={saving}
class="h-4 min-w-0 flex-1 bg-transparent p-0 text-xs leading-4 outline-none"
on:click|stopPropagation
on:keydown={(event) => {
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
rename();
}
if (event.key === 'Escape') {
event.preventDefault();
renaming = false;
}
}}
on:blur={rename}
/>
{:else}<span class="min-w-0 flex-1 truncate"
>{name}{#if search && file.directory_path}<span class="ml-1 text-gray-400"
>{file.directory_path}</span
>{/if}</span
>{/if}
{#if !directory && !renaming}<span class="shrink-0 text-[0.6875rem] text-gray-400"
>{pending
? $i18n.t(status === 'uploading' ? 'Uploading' : 'Processing')
: status === 'failed'
? $i18n.t('Failed')
: file.meta?.size != null
? formatFileSize(file.meta.size)
: ''}</span
>{/if}
</button>
{#if item.id && !pending}<Dropdown closeOnSelect align="end"
><button
type="button"
aria-label={$i18n.t('More')}
class="mr-1 flex size-5 shrink-0 items-center justify-center text-gray-400"
><Icon name="three-dots" size={12} /></button
>
<div slot="content">
<DropdownMenu className="min-w-36">
{#if directory}<button type="button" on:click={onOpen}
>{$i18n.t(expanded ? 'Collapse' : 'Expand')}</button
>{:else}<button type="button" on:click={onDownload}>{$i18n.t('Download')}</button>{/if}
{#if writeAccess}
{#if directory}<hr class="border-gray-100 dark:border-gray-800" />
<button type="button" on:click={() => onAdd('new_directory')}
>{$i18n.t('New folder')}</button
><button type="button" on:click={() => onAdd('files')}
>{$i18n.t('Upload files')}</button
><button type="button" on:click={() => onAdd('directory')}
>{$i18n.t('Upload directory')}</button
><button type="button" on:click={() => onAdd('text')}
>{$i18n.t('Add text content')}</button
><button type="button" on:click={() => onAdd('web')}>{$i18n.t('Add webpage')}</button
>{/if}
<hr class="border-gray-100 dark:border-gray-800" />
<button type="button" on:click={startRename}>{$i18n.t('Rename')}</button><button
type="button"
on:click={onDelete}
>{$i18n.t(directory ? 'Delete folder' : 'Remove from knowledge')}</button
>
{/if}
</DropdownMenu>
</div></Dropdown
>{/if}
</div>

View file

@ -0,0 +1,318 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import DOMPurify from 'dompurify';
import {
FileContentError,
getFileBlobById,
getFileIndexedText,
updateFileDataContentById
} from '$lib/apis/files';
import FilePreview from '$lib/components/chat/FileNav/FilePreview.svelte';
import FileCodeEditor from '$lib/components/chat/FileNav/FileCodeEditor.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import { fileName, type KnowledgeFile, type ViewerState, newViewerState } from './types';
export let file: KnowledgeFile;
export let writeAccess = false;
export let mobile = false;
export let state: ViewerState = newViewerState();
export let onClose = () => {};
export let onSaved = async () => {};
export let saving = false;
const i18n = getContext<any>('i18n');
let previewData: Record<string, any> = {};
let loading = false;
let indexedLoading = false;
let previewError = '';
let indexedError = '';
let sourceToggle = false;
let showRaw = false;
let previewName = fileName(file);
let request = 0;
let originalLoaded = false;
let controller: AbortController;
let indexedController: AbortController;
let objectUrl = '';
let workbook: any = null;
let selectedExcelSheet = '';
let excelSheetNames: string[] = [];
let indexedEditor: FileCodeEditor;
let mounted = false;
$: name = fileName(file);
$: noOriginal = file.has_original === false || !!state.originalUnavailable;
$: if (
mounted &&
state.mode === 'indexed' &&
state.indexed === null &&
!indexedLoading &&
!indexedError
)
loadIndexed();
$: if (
mounted &&
state.mode === 'preview' &&
!noOriginal &&
!originalLoaded &&
!loading &&
!previewError
)
loadOriginal();
$: if (noOriginal) state.mode = 'indexed';
const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
const loadIndexed = async () => {
indexedController?.abort();
indexedController = new AbortController();
const signal = indexedController.signal;
indexedLoading = true;
indexedError = '';
try {
const content = await getFileIndexedText(localStorage.token, file.id!, signal);
if (!signal.aborted)
state = { ...state, indexed: content, draft: state.editing ? state.draft : content };
} catch (error) {
if (!signal.aborted) indexedError = message(error);
} finally {
if (!signal.aborted) indexedLoading = false;
}
};
const selectSheet = async (sheet: string) => {
const key = request;
const { excelToTable } = await import('$lib/utils/excelToTable');
const result = await excelToTable(workbook.Sheets[sheet]);
if (key !== request) return;
selectedExcelSheet = sheet;
previewData = { fileOfficeHtml: DOMPurify.sanitize(result.html) };
};
const loadOriginal = async () => {
controller?.abort();
controller = new AbortController();
const signal = controller.signal;
const key = ++request;
loading = true;
previewError = '';
previewData = {};
sourceToggle = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = '';
try {
const blob = await getFileBlobById(localStorage.token, file.id!, { signal });
if (signal.aborted || key !== request) return;
const mime = (file.meta?.content_type || blob.type).split(';')[0].toLowerCase();
const ext = name.split('.').pop()?.toLowerCase() || '';
let data: Record<string, any>;
previewName = name;
if (mime === 'application/pdf' || ext === 'pdf')
data = { filePdfData: await blob.arrayBuffer() };
else if (ext === 'svg' || mime === 'image/svg+xml') {
previewName = `${name}.svg`;
data = { fileContent: await blob.text() };
sourceToggle = true;
} else if (mime.startsWith('image/') || /^(png|jpe?g|gif|webp|avif|bmp|ico)$/.test(ext)) {
objectUrl = URL.createObjectURL(blob);
data = { fileImageUrl: objectUrl };
} else if (mime.startsWith('audio/') || /^(mp3|wav|ogg|m4a|flac)$/.test(ext)) {
objectUrl = URL.createObjectURL(blob);
data = { fileAudioUrl: objectUrl };
} else if (mime.startsWith('video/') || /^(mp4|webm|mov)$/.test(ext)) {
objectUrl = URL.createObjectURL(blob);
data = { fileVideoUrl: objectUrl };
} else if (ext === 'docx' || mime.includes('wordprocessingml'))
data = { fileDocxData: await blob.arrayBuffer() };
else if (ext === 'xlsx' || mime.includes('spreadsheetml')) {
const XLSX = await import('xlsx');
const bytes = await blob.arrayBuffer();
if (signal.aborted || key !== request) return;
workbook = XLSX.read(bytes, { type: 'array' });
excelSheetNames = workbook.SheetNames;
if (!excelSheetNames.length) throw new Error('This workbook has no sheets.');
await selectSheet(excelSheetNames[0]);
data = previewData;
} else if (ext === 'pptx' || mime.includes('presentationml')) {
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
const result = await pptxToImages(await blob.arrayBuffer());
data = { fileOfficeSlides: result.images };
} else if (
mime.startsWith('text/') ||
/^(md|markdown|mdx|txt|csv|tsv|json|jsonc|jsonl|json5|html?|xml|ya?ml|toml|ini|log|py|js|ts|tsx|jsx|css|sh|sql|rs|go|java|c|cpp|h|ipynb)$/.test(
ext
) ||
mime.includes('json')
) {
data = { fileContent: await blob.text() };
if (mime === 'text/csv' && ext !== 'csv') previewName = `${name}.csv`;
if (mime === 'text/html' && !/^html?$/.test(ext)) previewName = `${name}.html`;
if (mime.includes('json') && !/^(json|jsonc|jsonl|json5|ipynb)$/.test(ext))
previewName = `${name}.json`;
sourceToggle = /\.(md|markdown|mdx|csv|tsv|json|jsonc|jsonl|json5|html?|svg|ipynb)$/i.test(
previewName
);
} else throw new Error('Preview is not available for this file type.');
if (signal.aborted || key !== request) return;
previewData = data;
originalLoaded = true;
} catch (error) {
if (!signal.aborted && key === request) {
if (error instanceof FileContentError && error.status === 404)
state = { ...state, originalUnavailable: true, mode: 'indexed' };
else previewError = message(error);
}
} finally {
if (!signal.aborted && key === request) loading = false;
}
};
const download = async () => {
try {
const blob = await getFileBlobById(
localStorage.token,
file.id!,
noOriginal ? { filename: name } : { attachment: true }
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = noOriginal && !name.endsWith('.txt') ? `${name}.txt` : name;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (error) {
toast.error(message(error));
}
};
const save = async () => {
if (!writeAccess || saving || !state.editing) return;
state.draft = indexedEditor?.getValue() ?? state.draft;
saving = true;
try {
const result = await updateFileDataContentById(localStorage.token, file.id!, state.draft);
state = { ...state, indexed: result.content, draft: result.content, editing: false };
toast.success($i18n.t('Indexed text saved.'));
await onSaved();
} catch (error) {
toast.error(message(error));
} finally {
saving = false;
}
};
onMount(() => {
mounted = true;
});
onDestroy(() => {
++request;
controller?.abort();
indexedController?.abort();
if (objectUrl) URL.revokeObjectURL(objectUrl);
});
</script>
<div class="flex h-full min-h-0 min-w-0 flex-1 flex-col bg-white dark:bg-gray-900">
<div class="flex h-8 shrink-0 items-center gap-1 px-2.5 text-xs dark:bg-black">
{#if mobile}<button
type="button"
aria-label={$i18n.t('Back to files')}
class="flex shrink-0 items-center gap-1 whitespace-nowrap pr-1 text-gray-500"
on:click={onClose}><ChevronLeft className="size-3.5" />{$i18n.t('Back')}</button
>{/if}
<span class="min-w-0 flex-1 truncate text-gray-500" title={name}>{name}</span>
{#if !noOriginal}<button
type="button"
class="shrink-0 px-1 py-0.5 {state.mode === 'preview' ? '' : 'text-gray-400'}"
aria-pressed={state.mode === 'preview'}
on:click={() => (state.mode = 'preview')}>{$i18n.t('Preview')}</button
>{/if}
<button
type="button"
class="shrink-0 px-1 py-0.5 {state.mode === 'indexed' ? '' : 'text-gray-400'}"
aria-pressed={state.mode === 'indexed'}
on:click={() => (state.mode = 'indexed')}>{$i18n.t('Indexed text')}</button
>
{#if state.mode === 'indexed' && state.indexed !== null && writeAccess}
{#if state.editing}
<button
type="button"
class="shrink-0 px-1 py-0.5 text-gray-500"
disabled={saving}
on:click={() => (state = { ...state, draft: state.indexed!, editing: false })}
>{$i18n.t('Cancel')}</button
>
<button
type="button"
class="flex shrink-0 items-center gap-1 px-1 py-0.5"
disabled={saving}
on:click={save}
>
{$i18n.t('Save')}{#if saving}<Spinner className="size-3" />{/if}
</button>
{:else}
<button
type="button"
class="shrink-0 px-1 py-0.5 text-gray-500"
on:click={() => {
state = { ...state, editing: true, draft: state.indexed! };
toast.info(
$i18n.t(
'Edits change retrieval text in all linked collections; the original file stays unchanged.'
),
{ position: 'bottom-center', duration: 5000 }
);
}}>{$i18n.t('Edit')}</button
>
{/if}
{/if}
<Dropdown closeOnSelect align="end"
><button type="button" aria-label={$i18n.t('File actions')} class="shrink-0 p-1 text-gray-500"
><EllipsisHorizontal className="size-3.5" /></button
>
<div slot="content">
<DropdownMenu
><button type="button" on:click={download}>{$i18n.t('Download')}</button
>{#if state.mode === 'preview' && !noOriginal}<button
type="button"
on:click={loadOriginal}>{$i18n.t('Reload preview')}</button
>{/if}
{#if state.mode === 'preview' && sourceToggle}<button
type="button"
on:click={() => (showRaw = !showRaw)}
>{$i18n.t(showRaw ? 'Rendered preview' : 'View source')}</button
>{/if}</DropdownMenu
>
</div></Dropdown
>
</div>
{#if state.mode === 'preview'}
{#if previewError}<div class="p-4 text-xs text-gray-500">
<p role="alert">{previewError}</p>
<button class="mt-2" type="button" on:click={loadOriginal}>{$i18n.t('Retry')}</button>
</div>
{:else}<FilePreview
selectedFile={previewName}
fileLoading={loading}
{...previewData}
readOnly
allowScripts={false}
bind:showRaw
{excelSheetNames}
{selectedExcelSheet}
onSheetChange={selectSheet}
/>{/if}
{:else if indexedLoading}<div class="p-4"><Spinner className="size-4" /></div>
{:else if indexedError}<div class="p-4 text-xs">
<p role="alert">{indexedError}</p>
<button type="button" on:click={loadIndexed}>{$i18n.t('Retry')}</button>
</div>
{:else if state.indexed !== null}
<div class="min-h-0 flex-1">
<FileCodeEditor
bind:this={indexedEditor}
bind:value={state.draft}
filePath="indexed.txt"
readOnly={!writeAccess || !state.editing || saving}
onSave={save}
/>
</div>
{/if}
</div>

View file

@ -1,254 +1,462 @@
<script lang="ts">
import dayjs from '$lib/dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(duration);
dayjs.extend(relativeTime);
import { getContext } from 'svelte';
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
import { capitalizeFirstLetter, formatFileSize } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Download from '$lib/components/icons/Download.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import { getContext, onMount, onDestroy, tick } from 'svelte';
import { toast } from 'svelte-sonner';
import {
searchKnowledgeFilesById,
getPendingKnowledgeFiles,
createKnowledgeDirectory,
updateKnowledgeDirectory,
deleteKnowledgeDirectory,
moveFileInKnowledge,
removeFileFromKnowledgeById
} from '$lib/apis/knowledge';
import { renameFileById, getFileBlobById } from '$lib/apis/files';
import Spinner from '$lib/components/common/Spinner.svelte';
import DirectoryRow from './DirectoryRow.svelte';
type KnowledgeFile = {
id?: string;
tempId?: string;
itemId?: string;
name?: string;
status?: string;
meta?: {
name?: string;
size?: number;
};
updated_at?: number;
user?: {
email?: string;
name?: string;
};
};
export let knowledge = null;
export let selectedFileId = null;
export let files: KnowledgeFile[] = [];
export let directories = [];
export let onClick: (fileId: string | undefined) => void = () => {};
export let onDelete: (fileId: string | undefined) => void = () => {};
export let onRename: (fileId: string, name: string) => void = () => {};
export let onNavigateDirectory: (directoryId: string) => void = () => {};
export let onRenameDirectory: (id: string, name: string) => void = () => {};
export let onDeleteDirectory: (id: string) => void = () => {};
export let onMoveFileToDirectory: (fileId: string, directoryId: string) => void = () => {};
export let onMoveDirectoryToDirectory: (
dirId: string,
targetDirectoryId: string
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import EntryRow from './EntryRow.svelte';
import { fileName, type KnowledgeFile, type KnowledgeDirectory } from './types';
export let knowledge: { id: string; write_access?: boolean; name: string };
export let selectedFileId: string | null = null;
export let transfers: KnowledgeFile[] = [];
export let query = '';
export let includeContent = false;
export let viewOption: string | null = null;
export let sortKey = 'updated_at';
export let direction = 'desc';
export let onSelect: (file: KnowledgeFile) => void = () => {};
export let onUpload: (
type: string,
directoryId: string | null,
breadcrumbs: KnowledgeDirectory[]
) => void = () => {};
let editingFileId: string | null = null;
let editName = '';
let editInput: HTMLInputElement;
const startRename = (file: KnowledgeFile) => {
editingFileId = file?.id ?? file?.tempId;
editName = file?.name ?? file?.meta?.name ?? '';
setTimeout(() => editInput?.select(), 0);
export let onChanged: (file?: KnowledgeFile, removed?: string[]) => void = () => {};
export let guard: (action: () => void | Promise<void>) => void = (action) => {
action();
};
const submitRename = () => {
if (editingFileId && editName.trim()) {
onRename(editingFileId, editName.trim());
const i18n = getContext<any>('i18n');
type Listing = {
items: KnowledgeFile[];
directories: KnowledgeDirectory[];
breadcrumbs: KnowledgeDirectory[];
total: number;
page: number;
loading: boolean;
error: string;
};
const empty = (): Listing => ({
items: [],
directories: [],
breadcrumbs: [],
total: 0,
page: 0,
loading: false,
error: ''
});
let cache: Record<string, Listing> = {};
let results: Listing = empty();
let expanded = new Set<string>();
let pending: KnowledgeFile[] = [];
let timer: ReturnType<typeof setTimeout>;
let mounted = false;
let generation = 0;
let requestIds = new Map<string, number>();
let filters = '';
let creating: { parent: string | null; name: string } | null = null;
let createInput: HTMLInputElement;
let creatingBusy = false;
let deletion: { file?: KnowledgeFile; directory?: KnowledgeDirectory } | null = null;
let showDelete = false;
let deleteContents = true;
const key = (id: string | null) => id ?? 'root';
const report = (error: unknown) =>
toast.error(error instanceof Error ? error.message : String(error));
const load = async (id: string | null, more = false, search = false, pages = 1) => {
const token = generation;
const cacheKey = search ? 'search' : key(id);
const request = (requestIds.get(cacheKey) || 0) + 1;
requestIds.set(cacheKey, request);
const previous = search ? results : cache[key(id)] || empty();
const next = { ...previous, loading: true, error: '' };
if (search) results = next;
else cache = { ...cache, [key(id)]: next };
try {
const page = more ? previous.page + 1 : 1;
const fetchPage = (page: number) =>
searchKnowledgeFilesById(
localStorage.token,
knowledge.id,
search ? query : '',
viewOption,
sortKey,
direction,
page,
search ? undefined : id,
includeContent
);
const response = await fetchPage(page);
if (!more && pages > 1) {
for (
let nextPage = 2;
nextPage <= pages && response.items.length < response.total;
nextPage++
) {
if (generation !== token || requestIds.get(cacheKey) !== request) return;
const next = await fetchPage(nextPage);
response.items.push(...next.items);
}
}
if (generation !== token || requestIds.get(cacheKey) !== request) return;
const listing = {
...response,
page: more ? page : Math.max(1, Math.ceil(response.items.length / 30)),
items: more ? [...previous.items, ...response.items] : response.items,
loading: false,
error: ''
};
if (search) results = listing;
else cache = { ...cache, [key(id)]: listing };
} catch (error) {
if (generation !== token || requestIds.get(cacheKey) !== request) return;
const listing = { ...previous, loading: false, error: String(error) };
if (search) results = listing;
else cache = { ...cache, [key(id)]: listing };
}
editingFileId = null;
};
const cancelRename = () => {
editingFileId = null;
const reload = async (ids: (string | null)[] = [null, ...expanded]) => {
const targets = new Set(ids);
// Collapsed branches must not retain stale children after a move or upload.
cache = Object.fromEntries(
Object.entries(cache).filter(([id]) => id === 'root' || expanded.has(id) || targets.has(id))
);
await Promise.all([...targets].map((id) => load(id, false, false, cache[key(id)]?.page || 1)));
if (query) await load(null, false, true, results.page || 1);
};
const poll = async () => {
clearTimeout(timer);
try {
const previous = pending;
const response = await getPendingKnowledgeFiles(localStorage.token, knowledge.id);
if (!mounted) return;
pending = response.map((file: KnowledgeFile) => ({
...file,
directory_id: file.meta?.data?.directory_id ?? null,
status: file.data?.status || 'processing'
}));
const completed = previous.filter((file) => !pending.some((item) => item.id === file.id));
if (completed.length) await reload(completed.map((file) => file.directory_id ?? null));
} catch (error) {
console.warn('Unable to refresh processing files', error);
}
if (mounted && pending.length) timer = setTimeout(poll, 5000);
};
export const refresh = async () => {
await reload();
await poll();
};
$: if (
mounted &&
JSON.stringify([query, includeContent, viewOption, sortKey, direction]) !== filters
) {
const next = JSON.stringify([query, includeContent, viewOption, sortKey, direction]);
const previous = filters ? JSON.parse(filters) : null;
filters = next;
if (
!previous ||
JSON.stringify(previous.slice(1)) !==
JSON.stringify([includeContent, viewOption, sortKey, direction])
) {
++generation;
cache = {};
reload();
} else if (query) load(null, false, true);
else {
requestIds.set('search', (requestIds.get('search') || 0) + 1);
if (!cache.root) reload();
}
}
const toggle = async (directory: KnowledgeDirectory) => {
if (expanded.has(directory.id)) expanded.delete(directory.id);
else {
expanded.add(directory.id);
if (!cache[directory.id]) await load(directory.id);
}
expanded = new Set(expanded);
};
export const createDirectory = async (parent: string | null = null) => {
if (!knowledge.write_access) return;
if (parent) {
expanded = new Set([...expanded, parent]);
if (!cache[parent]) await load(parent);
}
query = '';
creating = { parent, name: '' };
await tick();
createInput?.focus();
};
const submitDirectory = async () => {
if (!creating || creatingBusy) return;
if (!creating.name.trim()) {
creating = null;
return;
}
creatingBusy = true;
const { parent, name } = creating;
try {
await createKnowledgeDirectory(localStorage.token, knowledge.id, name.trim(), parent);
creating = null;
await reload([parent]);
} catch (error) {
report(error);
} finally {
creatingBusy = false;
}
};
const download = async (file: KnowledgeFile) => {
try {
const name = fileName(file);
const blob = await getFileBlobById(
localStorage.token,
file.id!,
file.has_original === false ? { filename: name } : { attachment: true }
);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download =
file.has_original === false && !name.endsWith('.txt') ? `${name}.txt` : name;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (error) {
report(error);
}
};
const renameFile = async (file: KnowledgeFile, name: string) => {
try {
await renameFileById(localStorage.token, file.id!, name);
onChanged({ ...file, filename: name, name, meta: { ...file.meta, name } });
await reload([file.directory_id ?? null]);
} catch (error) {
report(error);
throw error;
}
};
const renameDirectory = async (directory: KnowledgeDirectory, name: string) => {
try {
await updateKnowledgeDirectory(localStorage.token, knowledge.id, directory.id, { name });
await reload();
} catch (error) {
report(error);
throw error;
}
};
const move = async (kind: 'file' | 'directory', id: string, target: string | null) => {
if (!knowledge.write_access || (kind === 'directory' && id === target)) return;
try {
if (kind === 'file') await moveFileInKnowledge(localStorage.token, knowledge.id, id, target);
else
await updateKnowledgeDirectory(localStorage.token, knowledge.id, id, { parent_id: target });
if (target) expanded = new Set([...expanded, target]);
await reload();
} catch (error) {
report(error);
}
};
const remove = async () => {
if (!deletion || !knowledge.write_access) return;
try {
if (deletion.file) {
await removeFileFromKnowledgeById(localStorage.token, knowledge.id, deletion.file.id!);
onChanged(undefined, [deletion.file.id!]);
} else if (deletion.directory) {
await deleteKnowledgeDirectory(
localStorage.token,
knowledge.id,
deletion.directory.id,
!deleteContents
);
// The selected document may be in an unloaded descendant; let the browser verify membership.
onChanged();
expanded.delete(deletion.directory.id);
expanded = new Set(expanded);
}
cache = {};
await reload();
deletion = null;
} catch (error) {
report(error);
}
};
const askDelete = (value: typeof deletion) =>
guard(() => {
deletion = value;
deleteContents = true;
showDelete = true;
});
const upload = async (type: string, directory: KnowledgeDirectory) => {
if (type === 'new_directory') {
await createDirectory(directory.id);
return;
}
if (!cache[directory.id]) await load(directory.id);
onUpload(type, directory.id, cache[directory.id]?.breadcrumbs || []);
};
const rootDrop = (event: DragEvent) => {
if (!knowledge.write_access) return;
const raw = event.dataTransfer?.getData('application/x-kb-file-move');
const dirRaw = event.dataTransfer?.getData('application/x-kb-dir-move');
if (!raw && !dirRaw) return;
event.preventDefault();
event.stopPropagation();
try {
if (raw) move('file', JSON.parse(raw).fileId, null);
else if (dirRaw) move('directory', JSON.parse(dirRaw).dirId, null);
} catch {}
};
onMount(() => {
mounted = true;
poll();
});
onDestroy(() => {
mounted = false;
++generation;
clearTimeout(timer);
});
</script>
<div class=" max-h-full flex flex-col w-full gap-[0.03125rem]" role="list">
<!-- Directories first -->
{#each directories as dir (dir.id)}
<DirectoryRow
directory={dir}
writeAccess={knowledge?.write_access}
onNavigate={(id) => onNavigateDirectory(id)}
onRename={(id, name) => onRenameDirectory(id, name)}
onDelete={(id) => onDeleteDirectory(id)}
onFileDrop={(fileId, directoryId) => onMoveFileToDirectory(fileId, directoryId)}
onDirDrop={(dirId, targetId) => onMoveDirectoryToDirectory(dirId, targetId)}
/>
{/each}
<!-- Files -->
{#each files as file (file?.id ?? file?.itemId ?? file?.tempId)}
<div
class=" flex cursor-pointer w-full px-2 bg-transparent dark:hover:bg-gray-850/50 hover:bg-white rounded-xl transition {selectedFileId
? ''
: 'hover:bg-gray-100 dark:hover:bg-gray-850'}"
role="listitem"
draggable="true"
on:dragstart={(e) => {
const fileId = file?.id ?? file?.tempId;
if (fileId) {
e.dataTransfer?.setData('application/x-kb-file-move', JSON.stringify({ fileId }));
}
}}
>
<div class="flex items-center">
{#if file?.status !== 'uploading'}
<button
class="p-1 rounded-full transition"
type="button"
on:click={() => {
onClick(file?.id ?? file?.tempId);
}}
>
<DocumentPage className="size-3.5" />
</button>
{:else}
<Spinner className="size-3.5" />
{/if}
</div>
<button
class="relative flex items-center gap-1 rounded-xl p-2 text-left flex-1 justify-between"
type="button"
on:click={() => {
if (editingFileId) return;
onClick(file?.id ?? file?.tempId);
}}
on:dblclick={() => {
if (knowledge?.write_access) startRename(file);
}}
>
<div>
<div class="flex gap-2 items-center line-clamp-1">
{#if editingFileId === (file?.id ?? file?.tempId)}
<!-- svelte-ignore a11y-autofocus -->
<input
bind:this={editInput}
bind:value={editName}
class="text-xs w-full bg-transparent border-none outline-hidden"
on:keydown={(e) => {
if (e.key === 'Enter') submitRename();
if (e.key === 'Escape') cancelRename();
if (e.key === ' ') e.stopPropagation();
}}
on:keyup={(e) => {
if (e.key === ' ') e.stopPropagation();
}}
on:blur={submitRename}
on:click={(e) => e.stopPropagation()}
autofocus
/>
{:else}
<div class="line-clamp-1 text-xs">
{file?.name ?? file?.meta?.name}
{#if file?.meta?.size}
<span class="text-[0.6875rem] text-gray-500"
>{formatFileSize(file?.meta?.size)}</span
>
{/if}
</div>
{/if}
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
{#if file?.updated_at}
<Tooltip
content={dayjs(file.updated_at * 1000)
.locale($i18n.language)
.format('LLLL')}
>
<div class="text-xs text-gray-400">
{dayjs(file.updated_at * 1000)
.locale($i18n.language)
.fromNow()}
</div>
</Tooltip>
{/if}
{#if file?.user}
<Tooltip
content={file?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
<div class="shrink-0 text-gray-500">
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
file?.user?.name ?? file?.user?.email ?? $i18n.t('Deleted User')
)
})}
</div>
</Tooltip>
{/if}
</div>
</button>
{#if knowledge?.write_access}
<div class="flex items-center">
<Dropdown align="end" sideOffset={4}>
<button
class="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
>
<EllipsisHorizontal className="size-3.5" />
</button>
<div slot="content">
<DropdownMenu className="min-w-[8.75rem] z-[9999999]">
<button
type="button"
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-xs transition hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
startRename(file);
}}
>
<Pencil className="size-3.5" />
{$i18n.t('Rename')}
</button>
<button
type="button"
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-xs transition hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
let fileId = file?.id ?? file?.tempId;
window.open(`${WEBUI_BASE_URL}/api/v1/files/${fileId}/content`, '_blank');
}}
>
<Download className="size-3.5" />
{$i18n.t('Download')}
</button>
<button
type="button"
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-xs transition hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => {
onDelete(file?.id ?? file?.tempId);
}}
>
<GarbageBin className="size-3.5" />
{$i18n.t('Delete')}
</button>
</DropdownMenu>
</div>
</Dropdown>
</div>
{/if}
</div>
{/each}
<ConfirmDialog
bind:show={showDelete}
title={$i18n.t(deletion?.directory ? 'Delete folder?' : 'Remove file from knowledge?')}
on:confirm={remove}
>
{#if deletion?.directory}<label class="flex items-center gap-2 text-xs"
><input type="checkbox" bind:checked={deleteContents} />{$i18n.t(
'Delete all contents inside this directory'
)}</label
>{/if}
</ConfirmDialog>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="min-h-0 flex-1 overflow-auto p-1.5"
on:keydown={(event) => {
if (
!(event.target instanceof HTMLButtonElement) ||
!event.target.hasAttribute('data-knowledge-entry')
)
return;
const entries = [
...event.currentTarget.querySelectorAll<HTMLButtonElement>('button[data-knowledge-entry]')
];
const index = entries.indexOf(event.target);
const next =
event.key === 'ArrowDown'
? index + 1
: event.key === 'ArrowUp'
? index - 1
: event.key === 'Home'
? 0
: event.key === 'End'
? entries.length - 1
: null;
if (next === null) return;
event.preventDefault();
entries[Math.max(0, Math.min(entries.length - 1, next))]?.focus();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mb-1 px-2 py-1 text-xs text-gray-400"
on:dragover={(event) => {
if (
knowledge.write_access &&
event.dataTransfer?.types.some((type) => type.startsWith('application/x-kb-'))
)
event.preventDefault();
}}
on:drop={rootDrop}
title={$i18n.t('Drop here to move to root')}
>
{$i18n.t(query ? 'Search results' : 'All files')}
</div>
{#if query}{@render listing(null, results, 0, true)}{:else}{@render branch(null, 0)}{/if}
</div>
{#snippet branch(id: string | null, depth: number)}
{#if creating && creating.parent === id}<div
class="flex h-7 items-center"
style:padding-left={`${26 + depth * 16}px`}
>
<input
bind:this={createInput}
bind:value={creating.name}
disabled={creatingBusy}
aria-label={$i18n.t('Folder name')}
placeholder={$i18n.t('Folder name')}
class="h-4 w-full min-w-0 bg-transparent p-0 text-xs leading-4 outline-none"
on:keydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitDirectory();
}
if (event.key === 'Escape') {
event.preventDefault();
creating = null;
}
}}
on:blur={submitDirectory}
/>
</div>{/if}
{#if cache[key(id)]}{@render listing(id, cache[key(id)], depth, false)}{/if}
{/snippet}
{#snippet listing(id: string | null, data: Listing, depth: number, search: boolean)}
{#if !search}
{#each data.directories as directory (directory.id)}
<EntryRow
item={directory}
directory
{depth}
expanded={expanded.has(directory.id)}
writeAccess={!!knowledge.write_access}
onOpen={() => toggle(directory)}
onRename={(name) => renameDirectory(directory, name)}
onDelete={() => askDelete({ directory })}
onAdd={(type) => upload(type, directory)}
onDrop={(kind, itemId) => move(kind, itemId, directory.id)}
/>
{#if expanded.has(directory.id)}{@render branch(directory.id, depth + 1)}{/if}
{/each}
{#each [...transfers, ...pending].filter((file, index, all) => (file.directory_id ?? file.meta?.data?.directory_id ?? null) === id && !data.items.some((item) => item.id === file.id) && all.findIndex((item) => (item.id || item.itemId) === (file.id || file.itemId)) === index) as file (file.id || file.itemId)}
<EntryRow
item={file}
{depth}
active={selectedFileId === file.id}
onOpen={() => onSelect(file)}
/>
{/each}
{/if}
{#each data.items as file (file.id)}<EntryRow
item={file}
{depth}
{search}
active={selectedFileId === file.id}
writeAccess={!!knowledge.write_access}
onOpen={() => onSelect(file)}
onRename={(name) => renameFile(file, name)}
onDelete={() => askDelete({ file })}
onDownload={() => download(file)}
/>{/each}
{#if data.loading}<div class="p-2"><Spinner className="size-3" /></div>
{:else if data.error}<div class="p-2 text-xs">
<p role="alert">{data.error}</p>
<button type="button" on:click={() => load(id, false, search)}>{$i18n.t('Retry')}</button>
</div>
{:else if data.items.length < data.total}<button
type="button"
class="px-2 py-1 text-xs text-gray-500"
on:click={() => load(id, true, search)}>{$i18n.t('Load more')}</button
>
{:else if !data.items.length && (search || !data.directories.length)}<p
class="px-2 py-1 text-xs text-gray-400"
>
{$i18n.t(search ? 'No results found' : 'No files')}
</p>{/if}
{/snippet}

View file

@ -0,0 +1,41 @@
export type KnowledgeFile = {
id?: string;
itemId?: string;
filename?: string;
name?: string;
directory_id?: string | null;
directory_path?: string;
has_original?: boolean;
status?: string;
updated_at?: number;
meta?: {
name?: string;
size?: number;
content_type?: string;
data?: { directory_id?: string | null };
[key: string]: unknown;
};
data?: { status?: string; error?: string };
user?: { name?: string; email?: string };
};
export type KnowledgeDirectory = {
id: string;
name: string;
parent_id: string | null;
updated_at?: number;
};
export type ViewerState = {
mode: 'preview' | 'indexed';
indexed: string | null;
draft: string;
editing: boolean;
originalUnavailable?: boolean;
};
export const fileName = (file: KnowledgeFile) =>
file.meta?.name || file.name || file.filename || 'Untitled';
export const newViewerState = (): ViewerState => ({
mode: 'preview',
indexed: null,
draft: '',
editing: false
});

View file

@ -170,18 +170,12 @@
metadata: Object.fromEntries(
['name', 'tags'].map((key) => [key, { before: before[key], after: after[key] }])
),
files: result.content_diff.length
? [{ path: $i18n.t('Prompt Content'), status: 'modified', binary: false }]
: [],
content: result.content_diff
.map((line, index) =>
line.endsWith('\n')
? line
: line +
'\n' +
(index >= 3 && !line.startsWith('@@') ? '\\ No newline at end of file\n' : '')
)
.join('')
files:
result.content_diff.length || result.line_endings_only
? [{ path: $i18n.t('Prompt Content'), status: 'modified', binary: false }]
: [],
line_endings_only: result.line_endings_only,
content: result.content_diff.join('\n')
};
} catch (error) {
toast.error(`${error}`);
@ -481,7 +475,11 @@
<VersionDiff
diff={historyDiff}
currentLabel="Production"
loadFileDiff={async () => ({ diff: historyDiff.content })}
showFileHeaders={false}
loadFileDiff={async () => ({
diff: historyDiff.content,
line_endings_only: historyDiff.line_endings_only
})}
onClose={() => (historyDiff = null)}
/>
{:else}
@ -632,7 +630,7 @@
bind:value={commitMessage}
/>{:else}<div class="flex-1"></div>{/if}
<div class="ml-auto flex shrink-0 items-center gap-3">
{#if edit}<label
{#if historical}<label
class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-xs text-gray-500"
><input
type="checkbox"

View file

@ -1,12 +1,21 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import DiffBlock from '$lib/components/chat/Messages/DiffBlock.svelte';
import VersionDiffContent from './VersionDiffContent.svelte';
import Icon from '$lib/components/chat/FileNav/Icon.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Check from '$lib/components/icons/Check.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let currentLabel = 'Current';
export let loadFileDiff: (path: string) => Promise<{ binary?: boolean; diff?: string }>;
export let showFileHeaders = true;
let mode: 'split' | 'unified' = 'split';
let showMode = false;
export let loadFileDiff: (
path: string
) => Promise<{ binary?: boolean; diff?: string; line_endings_only?: boolean }>;
export let diff: {
fromId: string;
toId: string;
@ -18,7 +27,13 @@
let selectedPath: string | null = null;
let results: Record<
string,
{ loading?: boolean; error?: string; binary?: boolean; diff?: string }
{
loading?: boolean;
error?: string;
binary?: boolean;
diff?: string;
line_endings_only?: boolean;
}
> = {};
const isObject = (value: unknown): value is Record<string, unknown> =>
@ -81,6 +96,11 @@
if (selectedPath) loadFile(path);
}
onMount(() => {
try {
mode = localStorage.getItem('versionDiff:mode') === 'unified' ? 'unified' : 'split';
} catch {
/* Use the default when storage is unavailable. */
}
if (diff.files.length) selectFile(diff.files[0].path);
});
</script>
@ -91,6 +111,44 @@
>
<div class="flex shrink-0 items-center gap-2 bg-gray-50/60 px-3 py-1.5 dark:bg-black">
<span class="min-w-0 flex-1 font-medium">{$i18n.t('Compare to current')}</span>
<Dropdown bind:show={showMode} align="end">
<button
type="button"
aria-label={$i18n.t('Diff layout')}
class="flex items-center gap-1 px-1 text-gray-500"
><Icon
name={mode === 'split' ? 'split-horizontal' : 'list'}
size={13}
class="shrink-0"
/>{$i18n.t(mode === 'split' ? 'Split' : 'Unified')}<ChevronDown
className="size-3"
/></button
>
<div slot="content">
<DropdownMenu className="w-32">
{#each ['split', 'unified'] as option}<button
type="button"
aria-pressed={mode === option}
on:click={() => {
mode = option as 'split' | 'unified';
showMode = false;
try {
localStorage.setItem('versionDiff:mode', mode);
} catch {
/* Keep the current selection in memory. */
}
}}
><Icon
name={option === 'split' ? 'split-horizontal' : 'list'}
size={13}
class="shrink-0 text-gray-500"
/><span class="flex-1 text-left"
>{$i18n.t(option === 'split' ? 'Split' : 'Unified')}</span
>{#if mode === option}<Check className="size-3" />{/if}</button
>{/each}
</DropdownMenu>
</div>
</Dropdown>
<button
type="button"
class="p-1 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
@ -100,11 +158,9 @@
</div>
<div class="grid shrink-0 grid-cols-2 border-b border-gray-100 dark:border-white/5">
<div class="min-w-0 px-3 py-2 text-gray-500">
<span class="text-red-600 dark:text-red-400">−</span>
{$i18n.t('Selected version')} <span class="font-mono">{diff.fromId.slice(0, 7)}</span>
</div>
<div class="min-w-0 px-3 py-2 text-gray-500">
<span class="text-green-600 dark:text-green-400">+</span>
{$i18n.t(currentLabel)} <span class="font-mono">{diff.toId.slice(0, 7)}</span>
</div>
</div>
@ -130,28 +186,29 @@
{/each}
{#each diff.files as file, index}
<div class="border-b border-gray-100 dark:border-white/5">
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left"
aria-expanded={selectedPath === file.path}
aria-controls={`version-diff-file-${index}`}
on:click={() => selectFile(file.path)}
>
<span class:rotate-[-90deg]={selectedPath !== file.path}
><ChevronDown className="size-3 shrink-0 text-gray-400" /></span
{#if showFileHeaders}<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left"
aria-expanded={selectedPath === file.path}
aria-controls={`version-diff-file-${index}`}
on:click={() => selectFile(file.path)}
>
<span class="min-w-0 flex-1 truncate font-mono" title={file.path}>{file.path}</span>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[0.625rem] {file.status === 'added'
? 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-400'
: file.status === 'deleted'
? 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-850 dark:text-gray-400'}"
>{$i18n.t(statuses[file.status])}</span
>
</button>
<span class:rotate-[-90deg]={selectedPath !== file.path}
><ChevronDown className="size-3 shrink-0 text-gray-400" /></span
>
<span class="min-w-0 flex-1 truncate font-mono" title={file.path}>{file.path}</span>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[0.625rem] {file.status === 'added'
? 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-400'
: file.status === 'deleted'
? 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-850 dark:text-gray-400'}"
>{$i18n.t(statuses[file.status])}</span
>
</button>
{/if}
{#if selectedPath === file.path}
<div id={`version-diff-file-${index}`} class="min-w-0 [&_.diff-block]:text-xs">
<div id={`version-diff-file-${index}`} class="min-w-0">
{#if results[file.path]?.loading}
<div class="flex items-center gap-2 px-3 py-3 text-gray-500" role="status">
<Spinner className="size-3" />{$i18n.t('Loading...')}
@ -169,9 +226,15 @@
{$i18n.t('Binary file — text comparison is unavailable.')}
</p>
{:else if results[file.path]?.diff}
<DiffBlock code={results[file.path].diff} />
<VersionDiffContent code={results[file.path].diff ?? ''} {mode} />
{:else}
<p class="px-3 py-3 text-gray-500">{$i18n.t('No text differences')}</p>
<p class="px-3 py-3 text-gray-500">
{$i18n.t(
results[file.path]?.line_endings_only
? 'Only line endings changed'
: 'No text differences'
)}
</p>
{/if}
</div>
{/if}

View file

@ -0,0 +1,174 @@
<script lang="ts">
import { getContext } from 'svelte';
import { versionDiffHunks, type VersionDiffLine } from './versionDiff';
export let code: string;
export let mode: 'split' | 'unified' = 'split';
const i18n = getContext<any>('i18n');
$: hunks = versionDiffHunks(code);
</script>
{#snippet lineText(line: VersionDiffLine | null)}
<span class="line-content"
>{#if line}{#each line.segments as segment}<span class:changed={segment.changed}
>{segment.text}</span
>{/each}{#if line.noNewline}<span
class="newline-marker"
title={$i18n.t('No newline at end of file')}
aria-label={$i18n.t('No newline at end of file')}>↵</span
>{/if}{:else}{' '}{/if}</span
>
{/snippet}
{#snippet cell(line: VersionDiffLine | null, side: 'before' | 'after')}
<div
class="diff-cell"
class:addition={line?.type === 'addition'}
class:deletion={line?.type === 'deletion'}
class:empty={!line}
>
<span class="line-number" aria-hidden="true"
>{(side === 'before' ? line?.oldNumber : line?.newNumber) ?? ''}</span
>
{@render lineText(line)}
</div>
{/snippet}
{#snippet unifiedLine(line: VersionDiffLine)}
<div
class="diff-cell unified-row"
class:addition={line.type === 'addition'}
class:deletion={line.type === 'deletion'}
>
<span class="line-number" aria-hidden="true">{line.oldNumber ?? ''}</span>
<span class="line-number" aria-hidden="true">{line.newNumber ?? ''}</span>
<span class="select-none text-center" aria-hidden="true"
>{line.type === 'addition' ? '+' : line.type === 'deletion' ? '−' : ''}</span
>
{@render lineText(line)}
</div>
{/snippet}
<div class="version-diff-content font-mono text-xs leading-5" class:split={mode === 'split'}>
{#each hunks as hunk, index}
{#if index > 0 || hunk.beforeStart > 1 || hunk.afterStart > 1}
<div class="hunk-gap" title={$i18n.t('Unchanged lines omitted')}>···</div>
{/if}
{#each hunk.rows as row}
{#if mode === 'split'}
<div class="split-row">
{@render cell(row.before, 'before')}
{@render cell(row.after, 'after')}
</div>
{:else if row.before?.type === 'context'}
{@render unifiedLine(row.before)}
{:else}
{#if row.before}{@render unifiedLine(row.before)}{/if}
{#if row.after}{@render unifiedLine(row.after)}{/if}
{/if}
{/each}
{/each}
</div>
<style>
.version-diff-content {
color: var(--color-gray-700);
}
.split-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.diff-cell {
display: grid;
grid-template-columns: 2.5rem minmax(0, 1fr);
min-width: 0;
min-height: 1.25rem;
}
.diff-cell.unified-row {
grid-template-columns: 2.5rem 2.5rem 1rem minmax(0, 1fr);
}
.split-row > .diff-cell + .diff-cell {
border-left: 1px solid rgb(0 0 0 / 0.06);
}
.line-number {
padding: 0 0.5rem;
text-align: right;
user-select: none;
color: var(--color-gray-400);
border-right: 1px solid rgb(0 0 0 / 0.04);
}
.line-content {
padding: 0 0.5rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
tab-size: 4;
}
.addition {
background: #dafbe1;
color: #116329;
}
.deletion {
background: #ffebe9;
color: #a40e26;
}
.empty {
background: rgb(0 0 0 / 0.025);
}
.changed {
border-radius: 2px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.addition .changed {
background: #16a34a35;
}
.deletion .changed {
background: #dc262630;
}
.newline-marker {
padding-left: 0.5rem;
opacity: 0.45;
user-select: none;
}
.hunk-gap {
padding: 0.125rem 1rem;
text-align: center;
color: var(--color-gray-400);
background: rgb(0 0 0 / 0.025);
}
:global(.dark) .version-diff-content {
color: var(--color-gray-300);
}
:global(.dark) .addition {
background: #12261e;
color: #aff5b4;
}
:global(.dark) .deletion {
background: #2d161b;
color: #ffdcd7;
}
:global(.dark) .addition .changed {
background: #22c55e40;
}
:global(.dark) .deletion .changed {
background: #f8717140;
}
:global(.dark) .empty,
:global(.dark) .hunk-gap {
background: rgb(255 255 255 / 0.025);
}
:global(.dark) .line-number,
:global(.dark) .split-row > .diff-cell + .diff-cell {
border-color: rgb(255 255 255 / 0.06);
}
@media (max-width: 640px) {
.diff-cell.unified-row {
grid-template-columns: 1.75rem 1.75rem 1rem minmax(0, 1fr);
}
.diff-cell {
grid-template-columns: 1.75rem minmax(0, 1fr);
}
.line-number {
padding: 0 0.25rem;
}
}
</style>

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { versionDiffHunks } from './versionDiff';
describe('versionDiffHunks', () => {
it('aligns a replacement followed by added lines, preserving missing newlines', () => {
const [hunk] = versionDiffHunks(
[
'--- old',
'+++ new',
'@@ -1 +1,3 @@',
'-Enterprise features.',
'\\ No newline at end of file',
'+Enterprise features.',
'+',
'+{{test}}',
'\\ No newline at end of file',
''
].join('\n')
);
expect(hunk.rows).toHaveLength(3);
expect(hunk.rows[0].before).toMatchObject({
content: 'Enterprise features.',
oldNumber: 1,
noNewline: true
});
expect(hunk.rows[0].after).toMatchObject({ content: 'Enterprise features.', newNumber: 1 });
expect(hunk.rows[1].before).toBeNull();
expect(hunk.rows[2].after).toMatchObject({
content: '{{test}}',
newNumber: 3,
noNewline: true
});
});
it('renders an unchanged paragraph once with only the appended lines added', () => {
const [hunk] = versionDiffHunks('@@ -1 +1,3 @@\n Enterprise features. \n+\n+{{test}}');
expect(hunk.rows[0].before).toMatchObject({ type: 'context', oldNumber: 1, newNumber: 1 });
expect(hunk.rows[0].before).toBe(hunk.rows[0].after);
expect(hunk.rows.filter((row) => row.before?.type === 'deletion')).toHaveLength(0);
expect(hunk.rows.filter((row) => row.after?.type === 'addition')).toHaveLength(2);
});
it('highlights changed text within aligned lines', () => {
const [hunk] = versionDiffHunks('@@ -1 +1 @@\n-Use the old value\n+Use the new value\n');
expect(hunk.rows[0].before?.segments.find((part) => part.changed)?.text).toBe('old');
expect(hunk.rows[0].after?.segments.find((part) => part.changed)?.text).toBe('new');
});
it('keeps hunks separate, numbers context correctly, and drops patch headers', () => {
const hunks = versionDiffHunks(
'--- old\n+++ new\n@@ -2,2 +2 @@\n same\n-removed\n@@ -10 +9 @@\n-last\n+updated\n'
);
expect(hunks).toHaveLength(2);
expect(hunks[0].rows[0].before?.oldNumber).toBe(2);
expect(hunks[0].rows[0].after?.newNumber).toBe(2);
expect(hunks[0].rows[1].after).toBeNull();
expect(hunks[1].beforeStart).toBe(10);
expect(hunks[1].afterStart).toBe(9);
expect(hunks[1].rows).toHaveLength(1);
expect(versionDiffHunks('')).toEqual([]);
});
});

View file

@ -0,0 +1,48 @@
import { parseDiffRows, emphasizePair, type DiffRow } from '../../chat/Messages/diff';
export type VersionDiffLine = DiffRow & { noNewline?: boolean };
export type VersionDiffRow = { before: VersionDiffLine | null; after: VersionDiffLine | null };
export type VersionDiffHunk = { beforeStart: number; afterStart: number; rows: VersionDiffRow[] };
export function versionDiffHunks(code: string): VersionDiffHunk[] {
const hunks: VersionDiffHunk[] = [];
let hunk: VersionDiffHunk | null = null;
let removed: VersionDiffLine[] = [];
let added: VersionDiffLine[] = [];
let previous: VersionDiffLine | null = null;
const flush = () => {
for (let index = 0; index < Math.max(removed.length, added.length); index++) {
const before = removed[index] ?? null;
const after = added[index] ?? null;
if (before && after) emphasizePair(before, after);
hunk?.rows.push({ before, after });
}
removed = [];
added = [];
};
for (const row of parseDiffRows(code)) {
if (row.type === 'hunk') {
flush();
const match = row.content.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
hunk = {
beforeStart: Number(match?.[1] ?? 0),
afterStart: Number(match?.[2] ?? 0),
rows: []
};
hunks.push(hunk);
previous = null;
} else if (row.type === 'meta') {
if (previous && row.content.startsWith('\\ No newline')) previous.noNewline = true;
} else if (hunk && row.type !== 'file' && (row.oldNumber !== null || row.newNumber !== null)) {
previous = row;
if (row.type === 'deletion') removed.push(row);
else if (row.type === 'addition') added.push(row);
else {
flush();
hunk.rows.push({ before: row, after: row });
}
}
}
flush();
return hunks;
}