mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-10 03:27:57 +00:00
refac
This commit is contained in:
parent
37138282fb
commit
33dd7dd224
22 changed files with 1921 additions and 1200 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? '';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
174
src/lib/components/workspace/common/VersionDiffContent.svelte
Normal file
174
src/lib/components/workspace/common/VersionDiffContent.svelte
Normal 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>
|
||||
59
src/lib/components/workspace/common/versionDiff.test.ts
Normal file
59
src/lib/components/workspace/common/versionDiff.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
48
src/lib/components/workspace/common/versionDiff.ts
Normal file
48
src/lib/components/workspace/common/versionDiff.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue