This commit is contained in:
Timothy Jaeryang Baek
2026-06-23 23:13:32 +02:00
parent b1c2536ed2
commit 15c7e37438
15 changed files with 2929 additions and 394 deletions
+301 -1
View File
@@ -38,6 +38,304 @@ export const createNewKnowledge = async (
return res;
};
export const getExternalKnowledgeConnections = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/connections`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createExternalKnowledgeConnection = async (token: string, connection: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/connections`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(connection)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateExternalKnowledgeConnection = async (
token: string,
id: string,
connection: object
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/connections/${id}`, {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(connection)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteExternalKnowledgeConnection = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/connections/${id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const testExternalKnowledgeConnection = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/connections/${id}/test`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const testExternalKnowledgeRetrieval = async (
token: string,
id: string,
payload: object
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/knowledge/external/connections/${id}/retrieve-test`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(payload)
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const testExternalKnowledgeSource = async (token: string, payload: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/source/test`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(payload)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createExternalKnowledgeSource = async (token: string, payload: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/source/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(payload)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateExternalKnowledgeSource = async (token: string, id: string, payload: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/source/${id}`, {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(payload)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createExternalKnowledge = async (token: string, payload: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/external/knowledge/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(payload)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getKnowledgeBases = async (token: string = '', page: number | null = null) => {
let error = null;
@@ -76,13 +374,15 @@ export const searchKnowledgeBases = async (
token: string = '',
query: string | null = null,
viewOption: string | null = null,
page: number | null = null
page: number | null = null,
source: string | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (query) searchParams.append('query', query);
if (viewOption) searchParams.append('view_option', viewOption);
if (source) searchParams.append('source', source);
if (page) searchParams.append('page', page.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/search?${searchParams.toString()}`, {
+1
View File
@@ -54,6 +54,7 @@ type RAGConfigForm = {
PDF_EXTRACT_IMAGES?: boolean;
ENABLE_GOOGLE_DRIVE_INTEGRATION?: boolean;
ENABLE_ONEDRIVE_INTEGRATION?: boolean;
EXTERNAL_DOCUMENT_LOADER_HEADERS?: Record<string, string>;
chunk?: ChunkConfigForm;
content_extraction?: ContentExtractConfigForm;
web_loader_ssl_verification?: boolean;
+15 -1
View File
@@ -155,7 +155,21 @@
id: 'integrations',
title: 'Integrations',
route: '/admin/settings/integrations',
keywords: ['tools', 'integrations', 'plugins', 'extensions', 'functions', 'openapi', 'server']
keywords: [
'tools',
'integrations',
'plugins',
'extensions',
'functions',
'openapi',
'server',
'knowledge',
'vector db',
'qdrant',
'rag',
'retrieval',
'sources'
]
},
{
id: 'documents',
@@ -37,6 +37,7 @@
let showResetConfirm = false;
let showResetUploadDirConfirm = false;
let showReindexConfirm = false;
let showExternalDocumentLoaderHeadersHint = false;
let RAG_EMBEDDING_ENGINE = '';
let RAG_EMBEDDING_MODEL = '';
@@ -149,6 +150,21 @@
toast.error($i18n.t('External Document Loader URL required.'));
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'external' &&
RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS
) {
try {
const headers = JSON.parse(RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS);
if (headers === null || typeof headers !== 'object' || Array.isArray(headers)) {
throw new Error('Headers must be a valid JSON object');
}
RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS = JSON.stringify(headers, null, 2);
} catch (error) {
toast.error($i18n.t('Headers must be a valid JSON object'));
return;
}
}
if (RAGConfig.CONTENT_EXTRACTION_ENGINE === 'tika' && RAGConfig.TIKA_SERVER_URL === '') {
toast.error($i18n.t('Tika Server URL required.'));
return;
@@ -241,6 +257,11 @@
typeof RAGConfig.DOCLING_PARAMS === 'string' && RAGConfig.DOCLING_PARAMS.trim() !== ''
? JSON.parse(RAGConfig.DOCLING_PARAMS)
: {},
EXTERNAL_DOCUMENT_LOADER_HEADERS:
typeof RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS === 'string' &&
RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS.trim() !== ''
? JSON.parse(RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS)
: {},
MINERU_PARAMS:
typeof RAGConfig.MINERU_PARAMS === 'string' && RAGConfig.MINERU_PARAMS.trim() !== ''
? JSON.parse(RAGConfig.MINERU_PARAMS)
@@ -289,6 +310,13 @@
? JSON.stringify(config.MINERU_PARAMS ?? {}, null, 2)
: config.MINERU_PARAMS;
config.EXTERNAL_DOCUMENT_LOADER_HEADERS =
typeof config.EXTERNAL_DOCUMENT_LOADER_HEADERS === 'object'
? Object.keys(config.EXTERNAL_DOCUMENT_LOADER_HEADERS ?? {}).length > 0
? JSON.stringify(config.EXTERNAL_DOCUMENT_LOADER_HEADERS, null, 2)
: ''
: config.EXTERNAL_DOCUMENT_LOADER_HEADERS;
config.MINERU_FILE_EXTENSIONS = (config?.MINERU_FILE_EXTENSIONS ?? ['pdf']).join(', ');
RAGConfig = config;
@@ -581,17 +609,78 @@
</div>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'external'}
<div class="my-0.5 flex gap-2 pr-2">
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter External Document Loader URL')}
bind:value={RAGConfig.EXTERNAL_DOCUMENT_LOADER_URL}
/>
<SensitiveInput
placeholder={$i18n.t('Enter External Document Loader API Key')}
required={false}
bind:value={RAGConfig.EXTERNAL_DOCUMENT_LOADER_API_KEY}
/>
<div class="my-0.5 flex flex-col gap-2 pr-2">
<div class="flex gap-2">
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter External Document Loader URL')}
bind:value={RAGConfig.EXTERNAL_DOCUMENT_LOADER_URL}
/>
<SensitiveInput
placeholder={$i18n.t('Enter External Document Loader API Key')}
required={false}
bind:value={RAGConfig.EXTERNAL_DOCUMENT_LOADER_API_KEY}
/>
</div>
<div class="flex flex-col">
<div class="mb-0.5 text-xs text-gray-500">{$i18n.t('Headers')}</div>
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={RAGConfig.EXTERNAL_DOCUMENT_LOADER_HEADERS}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
/>
</Tooltip>
<button
type="button"
class="mt-1 flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition w-fit"
on:click={() =>
(showExternalDocumentLoaderHeadersHint =
!showExternalDocumentLoaderHeadersHint)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 transition-transform {showExternalDocumentLoaderHeadersHint
? 'rotate-90'
: ''}"
>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
clip-rule="evenodd"
/>
</svg>
{$i18n.t('Header variables')}
</button>
{#if showExternalDocumentLoaderHeadersHint}
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400 leading-5">
<div>
{$i18n.t('No additional headers are sent unless configured.')}
</div>
<div>
{$i18n.t('Example')}:
<code class="text-gray-700 dark:text-gray-300"
>{'{"X-OpenWebUI-File-Id": "{{FILE_ID}}"}'}</code
>
</div>
<div>
{$i18n.t('Available variables')}:
<code class="text-gray-700 dark:text-gray-300">{'{{FILE_ID}}'}</code>,
<code class="text-gray-700 dark:text-gray-300">{'{{FILE_NAME}}'}</code>,
<code class="text-gray-700 dark:text-gray-300"
>{'{{FILE_CONTENT_TYPE}}'}</code
>
</div>
</div>
{/if}
</div>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'tika'}
<div class="flex w-full mt-1">
@@ -0,0 +1,883 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { toast } from 'svelte-sonner';
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import DatabaseSettings from '$lib/components/icons/DatabaseSettings.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import {
createExternalKnowledgeSource,
getExternalKnowledgeConnections,
searchKnowledgeBases,
testExternalKnowledgeSource,
updateExternalKnowledgeConnection,
updateExternalKnowledgeSource
} from '$lib/apis/knowledge';
const i18n = getContext<Writable<i18nType>>('i18n');
type ExternalKnowledgeConnection = {
id: string;
name: string;
provider: string;
endpoint: string;
config?: Record<string, any>;
capabilities?: Record<string, any>;
enabled?: boolean;
auth_configured?: boolean;
};
type ExternalKnowledgeItem = {
id: string;
name: string;
description?: string;
access_grants?: any[];
meta?: {
external?: {
connection_id?: string;
provider?: string;
source?: {
name?: string;
config?: Record<string, any>;
};
};
};
};
let loading = false;
let testing = false;
let creating = false;
let showSourceModal = false;
let connections: ExternalKnowledgeConnection[] = [];
let items: ExternalKnowledgeItem[] = [];
let editingItem: ExternalKnowledgeItem | null = null;
let editingConnection: ExternalKnowledgeConnection | null = null;
let accessGrants: any[] = [];
let testResult: {
documents?: string[];
metadatas?: Record<string, any>[];
distances?: number[];
} | null = null;
let sourceForm = {
name: '',
description: '',
provider: 'qdrant',
endpoint: '',
apiKey: '',
timeout: 30,
dbName: '',
sourceName: '',
contentField: 'payload.text',
vectorField: '',
metadataField: 'payload.metadata',
documentIdField: 'id',
tableName: 'document_chunk',
collectionField: 'collection_name',
testQuery: ''
};
const schemaDefaults = (provider = sourceForm.provider) => {
if (provider === 'milvus') {
return {
contentField: 'data.text',
vectorField: 'vector',
metadataField: 'metadata',
documentIdField: 'id',
tableName: 'document_chunk',
collectionField: 'collection_name'
};
}
if (provider === 'pgvector') {
return {
contentField: 'text',
vectorField: 'vector',
metadataField: 'vmetadata',
documentIdField: 'id',
tableName: 'document_chunk',
collectionField: 'collection_name'
};
}
return {
contentField: 'payload.text',
vectorField: '',
metadataField: 'payload.metadata',
documentIdField: 'id',
tableName: 'document_chunk',
collectionField: 'collection_name'
};
};
const resetForm = () => {
sourceForm = {
name: '',
description: '',
provider: 'qdrant',
endpoint: '',
apiKey: '',
timeout: 30,
dbName: '',
sourceName: '',
testQuery: '',
...schemaDefaults('qdrant')
};
accessGrants = [];
testResult = null;
editingItem = null;
editingConnection = null;
};
const openCreateSource = () => {
resetForm();
showSourceModal = true;
};
const openEditSource = (item: ExternalKnowledgeItem) => {
const connection = connectionForItem(item);
if (!connection) {
toast.error($i18n.t('External connection not found.'));
return;
}
resetForm();
editingItem = item;
editingConnection = connection;
const source = item.meta?.external?.source;
const sourceConfig = source?.config ?? {};
const defaults = schemaDefaults(connection.provider);
sourceForm = {
name: item.name ?? '',
description: item.description ?? '',
provider: connection.provider ?? 'qdrant',
endpoint: connection.endpoint ?? '',
apiKey: '',
timeout: connection.config?.timeout ?? 30,
dbName: connection.config?.db_name ?? '',
sourceName: source?.name ?? '',
testQuery: '',
contentField: sourceConfig.content_field ?? defaults.contentField,
vectorField: sourceConfig.vector_field ?? defaults.vectorField,
metadataField: sourceConfig.metadata_field ?? defaults.metadataField,
documentIdField: sourceConfig.document_id_field ?? defaults.documentIdField,
tableName: sourceConfig.table_name ?? defaults.tableName,
collectionField: sourceConfig.collection_field ?? defaults.collectionField
};
accessGrants = item.access_grants ?? [];
showSourceModal = true;
};
const markUntested = () => {
testResult = null;
};
const refresh = async () => {
loading = true;
const [connectionRes, knowledgeRes] = await Promise.all([
getExternalKnowledgeConnections(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
}),
searchKnowledgeBases(localStorage.token, null, null, 1, 'external').catch((error) => {
toast.error(`${error}`);
return null;
})
]);
connections = connectionRes?.items ?? [];
items = knowledgeRes?.items ?? [];
loading = false;
};
const endpointPlaceholder = () => {
if (sourceForm.provider === 'pgvector') {
return 'postgresql://user:password@host:5432/db';
}
if (sourceForm.provider === 'milvus') {
return 'http://milvus.example.com:19530';
}
return 'https://qdrant.example.com';
};
const connectionForItem = (item: ExternalKnowledgeItem) =>
connections.find((connection) => connection.id === item?.meta?.external?.connection_id);
const connectionPayload = () => ({
name:
sourceForm.name.trim() ||
sourceForm.sourceName.trim() ||
$i18n.t('External Knowledge Source'),
provider: sourceForm.provider,
endpoint: sourceForm.endpoint,
auth_config:
sourceForm.provider === 'pgvector'
? {}
: sourceForm.apiKey
? { type: 'bearer', api_key: sourceForm.apiKey }
: editingConnection
? null
: {},
config: {
timeout: Number(sourceForm.timeout) || 30,
...(sourceForm.provider === 'milvus' && sourceForm.dbName
? { db_name: sourceForm.dbName }
: {})
},
capabilities: { retrieve: true },
enabled: editingConnection?.enabled !== false
});
const sourcePayload = () => {
const config: Record<string, string> = {
content_field: sourceForm.contentField.trim(),
...(sourceForm.vectorField.trim() ? { vector_field: sourceForm.vectorField.trim() } : {}),
...(sourceForm.metadataField.trim()
? { metadata_field: sourceForm.metadataField.trim() }
: {}),
...(sourceForm.documentIdField.trim()
? { document_id_field: sourceForm.documentIdField.trim() }
: {})
};
if (sourceForm.provider === 'pgvector') {
config.table_name = sourceForm.tableName.trim();
config.collection_field = sourceForm.collectionField.trim();
}
return {
type: 'collection',
name: sourceForm.sourceName.trim(),
config
};
};
const testFormIsValid = () =>
!!sourceForm.endpoint.trim() &&
!!sourceForm.sourceName.trim() &&
!!sourceForm.contentField.trim() &&
(sourceForm.provider === 'qdrant' || !!sourceForm.vectorField.trim()) &&
(sourceForm.provider !== 'pgvector' ||
(!!sourceForm.tableName.trim() && !!sourceForm.collectionField.trim())) &&
!!sourceForm.testQuery.trim();
const sourceFormIsValid = () => !!sourceForm.name.trim() && testFormIsValid();
const testSource = async () => {
if (!testFormIsValid()) {
toast.error($i18n.t('Fill the source fields and test query first.'));
return;
}
testing = true;
testResult = null;
const res = await testExternalKnowledgeSource(localStorage.token, {
...(editingConnection?.id ? { connection_id: editingConnection.id } : {}),
connection: connectionPayload(),
source: sourcePayload(),
query: sourceForm.testQuery,
count: 5
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res?.documents?.length) {
testResult = res;
toast.success($i18n.t('Test succeeded.'));
} else if (res) {
toast.error($i18n.t('Test returned no results.'));
}
testing = false;
};
const createSource = async () => {
if (!sourceFormIsValid()) {
toast.error($i18n.t('Fill the required fields first.'));
return;
}
if (!testResult?.documents?.length) {
toast.error($i18n.t('Test the source before creating it.'));
return;
}
creating = true;
const res = await createExternalKnowledgeSource(localStorage.token, {
name: sourceForm.name,
description: sourceForm.description,
connection: connectionPayload(),
source: sourcePayload(),
access_grants: accessGrants,
test_query: sourceForm.testQuery,
test_count: 5
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success($i18n.t('Knowledge source created.'));
showSourceModal = false;
resetForm();
await refresh();
}
creating = false;
};
const updateSource = async () => {
if (!editingItem) return;
if (!sourceFormIsValid()) {
toast.error($i18n.t('Fill the required fields first.'));
return;
}
if (!testResult?.documents?.length) {
toast.error($i18n.t('Test the source before saving it.'));
return;
}
creating = true;
const res = await updateExternalKnowledgeSource(localStorage.token, editingItem.id, {
name: sourceForm.name,
description: sourceForm.description,
connection: connectionPayload(),
source: sourcePayload(),
access_grants: accessGrants,
test_query: sourceForm.testQuery,
test_count: 5
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success($i18n.t('Knowledge source updated.'));
showSourceModal = false;
resetForm();
await refresh();
}
creating = false;
};
const submitSource = async () => {
if (editingItem) {
await updateSource();
} else {
await createSource();
}
};
const toggleSource = async (item: ExternalKnowledgeItem) => {
const connection = connectionForItem(item);
if (!connection) return;
const res = await updateExternalKnowledgeConnection(localStorage.token, connection.id, {
name: connection.name,
provider: connection.provider,
endpoint: connection.endpoint,
auth_config: null,
config: connection.config ?? {},
capabilities: connection.capabilities ?? { retrieve: true },
enabled: !connection.enabled
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
await refresh();
}
};
const updateProvider = () => {
sourceForm = {
...sourceForm,
...schemaDefaults(sourceForm.provider)
};
markUntested();
};
onMount(refresh);
</script>
<Modal bind:show={showSourceModal} size="sm">
<div>
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<h1 class="text-lg font-medium self-center font-primary">
{editingItem ? $i18n.t('Edit Knowledge Connection') : $i18n.t('Add Knowledge Connection')}
</h1>
<button
class="self-center"
aria-label={$i18n.t('Close')}
type="button"
on:click={() => {
showSourceModal = false;
resetForm();
}}
>
<XMark className="size-5" />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form class="flex flex-col w-full" on:submit|preventDefault={submitSource}>
<div class="px-1">
<div class="flex gap-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-name"
>{$i18n.t('Name')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-name"
class="w-full flex-1 text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={sourceForm.name}
on:input={markUntested}
placeholder={$i18n.t('Research Knowledge')}
required
/>
</div>
</div>
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-provider"
>{$i18n.t('Provider')}</label
>
</div>
<div class="flex flex-1 items-center">
<select
id="external-source-provider"
class="w-full text-sm bg-transparent outline-hidden"
bind:value={sourceForm.provider}
on:change={updateProvider}
>
<option value="qdrant">Qdrant</option>
<option value="milvus">Milvus</option>
<option value="pgvector">pgvector</option>
</select>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<label class="text-xs text-gray-500 mb-0.5" for="external-source-description"
>{$i18n.t('Description')}</label
>
<textarea
id="external-source-description"
class="w-full text-sm bg-transparent outline-hidden resize-none placeholder:text-gray-300 dark:placeholder:text-gray-700"
rows="2"
bind:value={sourceForm.description}
></textarea>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-endpoint"
>{$i18n.t('Endpoint')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-endpoint"
class="w-full flex-1 text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={sourceForm.endpoint}
on:input={markUntested}
placeholder={endpointPlaceholder()}
required
/>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-timeout"
>{$i18n.t('Timeout')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-timeout"
class="w-full text-sm bg-transparent outline-hidden"
type="number"
bind:value={sourceForm.timeout}
on:input={markUntested}
/>
</div>
</div>
{#if sourceForm.provider !== 'pgvector'}
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-api-key"
>{$i18n.t('API Key / Token')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-api-key"
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
type="password"
bind:value={sourceForm.apiKey}
on:input={markUntested}
autocomplete="off"
/>
</div>
</div>
{/if}
</div>
{#if sourceForm.provider === 'milvus'}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-db-name"
>{$i18n.t('Database')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-db-name"
class="w-full text-sm bg-transparent outline-hidden"
bind:value={sourceForm.dbName}
on:input={markUntested}
placeholder={$i18n.t('Default')}
/>
</div>
</div>
</div>
{/if}
<hr class="border-gray-100/50 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-collection"
>{$i18n.t('Collection')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-collection"
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={sourceForm.sourceName}
on:input={markUntested}
placeholder="research-docs"
required
/>
</div>
</div>
</div>
{#if sourceForm.provider === 'pgvector'}
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-table"
>{$i18n.t('Table')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-table"
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={sourceForm.tableName}
on:input={markUntested}
placeholder="document_chunk"
required
/>
</div>
</div>
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-collection-field"
>{$i18n.t('Collection Field')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-collection-field"
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={sourceForm.collectionField}
on:input={markUntested}
placeholder="collection_name"
required
/>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-content-field"
>{$i18n.t('Content Field')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-content-field"
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={sourceForm.contentField}
on:input={markUntested}
placeholder={sourceForm.provider === 'pgvector' ? 'text' : 'payload.text'}
required
/>
</div>
</div>
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-vector-field">
{$i18n.t('Vector Field')}
</label>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-vector-field"
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={sourceForm.vectorField}
on:input={markUntested}
placeholder={sourceForm.provider === 'qdrant' ? $i18n.t('Default') : 'vector'}
required={sourceForm.provider !== 'qdrant'}
/>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-metadata-field"
>{$i18n.t('Metadata Field')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-metadata-field"
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={sourceForm.metadataField}
on:input={markUntested}
placeholder={sourceForm.provider === 'pgvector'
? 'vmetadata'
: 'payload.metadata'}
/>
</div>
</div>
<div class="flex flex-col flex-1">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-document-id-field"
>{$i18n.t('Document ID Field')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-document-id-field"
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={sourceForm.documentIdField}
on:input={markUntested}
placeholder="id"
/>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
<label class="text-xs text-gray-500" for="external-source-test-query"
>{$i18n.t('Test Query')}</label
>
</div>
<div class="flex flex-1 items-center">
<input
id="external-source-test-query"
class="w-full flex-1 text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={sourceForm.testQuery}
on:input={markUntested}
placeholder={$i18n.t('Ask a test question')}
required
/>
<Tooltip
content={$i18n.t('Verify Connection')}
className="shrink-0 flex items-center mr-1"
>
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
type="button"
on:click={testSource}
disabled={testing}
aria-label={$i18n.t('Verify Connection')}
>
{#if testing}
<Spinner />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</Tooltip>
</div>
<div class="text-xs text-gray-500 mt-1">
{$i18n.t(
'External vectors must be generated with the same embedding model configured in Open WebUI.'
)}
</div>
</div>
</div>
<hr class="border-gray-100/50 dark:border-gray-700/10 my-2.5 w-full" />
<AccessControl
bind:accessGrants
accessRoles={['read']}
share={true}
sharePublic={true}
shareUsers={true}
/>
<div class="flex justify-between items-center pt-3 text-sm font-medium">
<div></div>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
type="submit"
disabled={creating || !sourceFormIsValid() || !testResult?.documents?.length}
>
{editingItem ? $i18n.t('Save') : $i18n.t('Create')}
{#if creating}<Spinner />{/if}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</Modal>
<div class="mb-2.5 flex flex-col w-full justify-between text-sm">
<div class="flex justify-between items-center mb-1">
<div class="flex items-center gap-2">
<div class="font-medium">{$i18n.t('External Knowledge Sources')}</div>
<span
class="text-[0.65rem] font-medium uppercase px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
>{$i18n.t('Experimental')}</span
>
</div>
<Tooltip content={$i18n.t('Add Connection')}>
<button class="px-1" on:click={openCreateSource} type="button">
<Plus />
</button>
</Tooltip>
</div>
<div class="flex flex-col gap-1.5">
{#each items as item}
{@const connection = connectionForItem(item)}
<div class="flex w-full gap-2 items-center">
<Tooltip className="w-full relative" content={''} placement="top-start">
<div class="flex w-full">
<div
class="flex-1 relative flex gap-1.5 items-center min-w-0 {connection?.enabled ===
false
? 'opacity-50'
: ''}"
>
<Tooltip content={$i18n.t('Knowledge')}>
<DatabaseSettings className="size-4" strokeWidth="1.5" />
</Tooltip>
<div class="outline-hidden w-full bg-transparent text-sm min-w-0 line-clamp-1">
<span>{item.name}</span>
{' '}
<span class="text-gray-500">
{item?.meta?.external?.provider ?? connection?.provider}
{item?.meta?.external?.source?.name ? `· ${item.meta.external.source.name}` : ''}
</span>
</div>
</div>
</div>
</Tooltip>
<div class="flex gap-1 items-center">
<Tooltip content={$i18n.t('Configure')}>
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
disabled={!connection}
aria-label={$i18n.t('Configure')}
on:click={() => {
openEditSource(item);
}}
>
<Cog6 />
</button>
</Tooltip>
<Tooltip
content={connection?.enabled !== false ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
state={connection?.enabled !== false}
on:change={() => {
toggleSource(item);
}}
/>
</Tooltip>
</div>
</div>
{/each}
</div>
{#if loading}
<div class="py-2">
<Spinner />
</div>
{:else if items.length === 0}
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('No external knowledge sources configured.')}
</div>
{/if}
<div class="my-1.5">
<div class="text-xs text-gray-500">
{$i18n.t(
'Create one read-only Knowledge source per external collection. Test must pass before the source is created.'
)}
</div>
</div>
</div>
@@ -1,11 +1,12 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { v4 as uuidv4 } from 'uuid';
import { getModels as _getModels } from '$lib/apis';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
const i18n = getContext<Writable<i18nType>>('i18n');
import { models, settings, user, terminalServers } from '$lib/stores';
import { getTerminalServers } from '$lib/apis/terminal';
@@ -22,6 +23,7 @@
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
import AddTerminalServerModal from '$lib/components/AddTerminalServerModal.svelte';
import ExternalKnowledge from './ExternalKnowledge.svelte';
import {
getToolServerConnections,
@@ -32,16 +34,26 @@
export let saveSettings: Function;
let servers = null;
type ToolServerConnection = any;
type TerminalConnection = {
id?: string;
url?: string;
name?: string;
key?: string;
enabled?: boolean;
[key: string]: any;
};
let servers: ToolServerConnection[] | null = null;
let showConnectionModal = false;
// Terminal server admin connections
let terminalConnections = [];
let terminalConnections: TerminalConnection[] = [];
let showAddTerminalModal = false;
let editTerminalIdx: number | null = null;
const addConnectionHandler = async (server) => {
servers = [...servers, server];
const addConnectionHandler = async (server: ToolServerConnection) => {
servers = [...(servers ?? []), server];
await updateHandler();
};
@@ -71,7 +83,9 @@
// Refresh the terminalServers store so changes are reflected immediately
// Preserve user direct terminals, refresh system terminals from backend
const existingDirectTerminals = ($terminalServers ?? []).filter((t) => !t.id);
const existingDirectTerminals = (($terminalServers ?? []) as TerminalConnection[]).filter(
(t) => !t.id
);
const systemTerminals = await getTerminalServers(localStorage.token);
const systemEntries = systemTerminals.map((t) => ({
id: t.id,
@@ -79,16 +93,19 @@
name: t.name,
key: localStorage.token
}));
terminalServers.set([...existingDirectTerminals, ...systemEntries]);
terminalServers.set([...existingDirectTerminals, ...systemEntries] as any);
}
};
const addTerminalConnection = (server) => {
terminalConnections = [...terminalConnections, { ...server, id: server.id ?? uuidv4() }];
const addTerminalConnection = (server: TerminalConnection) => {
terminalConnections = [
...terminalConnections,
{ ...server, id: server.id ?? crypto.randomUUID() }
];
saveTerminalServers();
};
const updateTerminalConnection = (idx: number, updated) => {
const updateTerminalConnection = (idx: number, updated: TerminalConnection) => {
terminalConnections = terminalConnections.map((c, i) =>
i === idx ? { ...c, ...updated, id: updated.id ?? c.id } : c
);
@@ -102,13 +119,13 @@
onMount(async () => {
const res = await getToolServerConnections(localStorage.token);
servers = res.TOOL_SERVER_CONNECTIONS;
servers = res.TOOL_SERVER_CONNECTIONS as ToolServerConnection[];
// Load terminal server connections
try {
const terminalRes = await getTerminalServerConnections(localStorage.token);
if (terminalRes?.TERMINAL_SERVER_CONNECTIONS) {
terminalConnections = terminalRes.TERMINAL_SERVER_CONNECTIONS;
terminalConnections = terminalRes.TERMINAL_SERVER_CONNECTIONS as TerminalConnection[];
}
} catch {
// Not configured yet
@@ -122,7 +139,7 @@
bind:show={showAddTerminalModal}
edit={editTerminalIdx !== null}
connection={editTerminalIdx !== null ? terminalConnections[editTerminalIdx] : null}
onSubmit={(c) => {
onSubmit={(c: TerminalConnection) => {
if (editTerminalIdx !== null) {
updateTerminalConnection(editTerminalIdx, c);
editTerminalIdx = null;
@@ -148,13 +165,13 @@
{#if servers !== null}
<div class="">
<div class="mb-3">
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Tools')}</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="mb-2.5 flex flex-col w-full justify-between">
<div class="flex justify-between items-center mb-0.5">
<div class="font-medium">{$i18n.t('Manage Tool Servers')}</div>
<div class="font-medium">{$i18n.t('External Tool Servers')}</div>
<Tooltip content={$i18n.t(`Add Connection`)}>
<button
@@ -170,21 +187,21 @@
</div>
<div class="flex flex-col gap-1">
{#each servers as server, idx}
{#each servers ?? [] as server, idx}
<Connection
bind:connection={server}
onSubmit={() => {
updateHandler();
}}
onDelete={() => {
servers = servers.filter((_, i) => i !== idx);
servers = (servers ?? []).filter((_, i) => i !== idx);
updateHandler();
}}
/>
{/each}
</div>
{#if servers.length === 0}
{#if (servers ?? []).length === 0}
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('No tool server connections configured.')}
</div>
@@ -197,17 +214,9 @@
</div>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-4" />
<div class="mb-2.5 flex flex-col w-full">
<div class="mt-4 mb-2.5 flex flex-col w-full">
<div class="flex justify-between items-center mb-1">
<div class="flex items-center gap-2">
<div class="font-medium">{$i18n.t('Open Terminal')}</div>
<span
class="text-[0.65rem] font-medium uppercase px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
>{$i18n.t('Experimental')}</span
>
</div>
<div class="font-medium">{$i18n.t('Open Terminal')}</div>
<Tooltip content={$i18n.t('Add Connection')}>
<button
@@ -300,6 +309,12 @@
</div>
</div>
</div>
<div class="mt-8 mb-2.5 text-base font-medium">{$i18n.t('Knowledge')}</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<ExternalKnowledge />
</div>
</div>
{:else}
+67 -18
View File
@@ -5,7 +5,10 @@
import { toast } from 'svelte-sonner';
import { onMount, getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n');
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
const i18n = getContext<Writable<i18nType>>('i18n');
import { WEBUI_NAME, knowledge, user } from '$lib/stores';
import {
@@ -28,19 +31,33 @@
import ViewSelector from './common/ViewSelector.svelte';
import Loader from '../common/Loader.svelte';
type KnowledgeListItem = {
id: string;
name: string;
description?: string;
updated_at: number;
write_access?: boolean;
meta?: any;
user?: {
name?: string;
email?: string;
};
};
let loaded = false;
let showDeleteConfirm = false;
let tagsContainerElement: HTMLDivElement;
let selectedItem = null;
let selectedItem: KnowledgeListItem | null = null;
let page = 1;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let viewOption = '';
let sourceOption = '';
let items = null;
let total = null;
let items: KnowledgeListItem[] | null = null;
let total: number | null = null;
let allItemsLoaded = false;
let itemsLoading = false;
@@ -56,7 +73,7 @@
clearTimeout(searchDebounceTimer);
});
$: if (loaded && viewOption !== undefined) {
$: if (loaded && viewOption !== undefined && sourceOption !== undefined) {
init();
}
@@ -83,16 +100,20 @@
const getItemsPage = async () => {
itemsLoading = true;
const res = await searchKnowledgeBases(localStorage.token, query, viewOption, page).catch(
() => {
return [];
}
);
const res = await searchKnowledgeBases(
localStorage.token,
query,
viewOption,
page,
sourceOption
).catch(() => {
return [];
});
if (res) {
console.log(res);
total = res.total;
const pageItems = res.items;
const pageItems: KnowledgeListItem[] = res.items ?? [];
if ((pageItems ?? []).length === 0) {
allItemsLoaded = true;
@@ -113,7 +134,9 @@
return res;
};
const deleteHandler = async (item) => {
const deleteHandler = async (item: KnowledgeListItem | null) => {
if (!item) return;
const res = await deleteKnowledgeById(localStorage.token, item.id).catch((e) => {
toast.error(`${e}`);
});
@@ -124,7 +147,7 @@
}
};
const exportHandler = async (item) => {
const exportHandler = async (item: KnowledgeListItem) => {
try {
const blob = await exportKnowledgeById(localStorage.token, item.id);
if (blob) {
@@ -145,6 +168,7 @@
onMount(async () => {
viewOption = localStorage?.workspaceViewOption || '';
sourceOption = localStorage?.workspaceKnowledgeSourceOption || '';
loaded = true;
});
</script>
@@ -241,6 +265,19 @@
await tick();
}}
/>
<select
class="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl outline-hidden"
bind:value={sourceOption}
on:change={async () => {
localStorage.workspaceKnowledgeSourceOption = sourceOption;
await tick();
}}
>
<option value="">{$i18n.t('All Sources')}</option>
<option value="local">{$i18n.t('Local')}</option>
<option value="external">{$i18n.t('Connected')}</option>
</select>
</div>
</div>
@@ -267,11 +304,23 @@
<div class=" self-center flex-1 justify-between">
<div class="flex items-center justify-between -my-1 h-8">
<div class=" flex gap-2 items-center justify-between w-full">
<div>
<Badge type="success" content={$i18n.t('Collection')} />
</div>
{#if item?.meta?.source === 'external'}
<div>
<Badge
type="muted"
content={item?.meta?.external?.provider ?? $i18n.t('Connected')}
/>
</div>
<div>
<Badge type="muted" content={$i18n.t('Read Only')} />
</div>
{:else}
<div>
<Badge type="success" content={$i18n.t('Collection')} />
</div>
{/if}
{#if !item?.write_access}
{#if !item?.write_access && item?.meta?.source !== 'external'}
<div>
<Badge type="muted" content={$i18n.t('Read Only')} />
</div>
@@ -282,7 +331,7 @@
<div class="flex items-center gap-2">
<div class=" flex self-center">
<ItemMenu
onExport={$user.role === 'admin'
onExport={$user?.role === 'admin'
? () => {
exportHandler(item);
}
@@ -5,7 +5,10 @@
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import { onMount, getContext, onDestroy, tick } from 'svelte';
const i18n = getContext('i18n');
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
const i18n = getContext<Writable<i18nType>>('i18n');
import { goto } from '$app/navigation';
import { page } from '$app/stores';
@@ -40,7 +43,8 @@
deleteKnowledgeDirectory,
moveFileInKnowledge,
syncKnowledgeDiff,
syncKnowledgeCleanup
syncKnowledgeCleanup,
testExternalKnowledgeRetrieval
} from '$lib/apis/knowledge';
import { processWeb, processYoutubeVideo } from '$lib/apis/retrieval';
@@ -98,11 +102,13 @@
files: any[];
access_grants?: any[];
write_access?: boolean;
meta?: any;
};
let id = null;
let knowledge: Knowledge | null = null;
let knowledgeId = null;
let isExternalKnowledge = false;
let selectedFileId = null;
let selectedFile = null;
@@ -132,6 +138,14 @@
let deleteDirectoryContents = true;
let pendingPollTimer: ReturnType<typeof setInterval> | null = null;
let externalTestQuery = '';
let externalTestResult: {
documents?: string[];
metadatas?: Record<string, any>[];
distances?: number[];
} | null = null;
$: isExternalKnowledge = knowledge?.meta?.source === 'external';
const reset = () => {
currentPage = 1;
@@ -243,6 +257,24 @@
}
};
const externalTestHandler = async () => {
if (!isExternalKnowledge || !externalTestQuery.trim()) return;
const external = knowledge?.meta?.external ?? {};
const res = await testExternalKnowledgeRetrieval(localStorage.token, external.connection_id, {
query: externalTestQuery,
source: external.source,
count: 5
}).catch((e) => {
toast.error(`${e}`);
return null;
});
if (res) {
externalTestResult = res;
}
};
const createFileFromText = (name, content) => {
const blob = new Blob([content], { type: 'text/plain' });
const file = blobToFile(blob, `${name}.txt`);
@@ -1299,284 +1331,357 @@
<div
class="mt-2 mb-2.5 py-2 -mx-0 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30 flex-1"
>
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
<div class="flex flex-1 items-center">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Collection')}
placeholder={$i18n.t('Search Collection')}
on:focus={() => {
selectedFileId = null;
}}
/>
<Dropdown align="end">
<button
class="p-1.5 mr-1 rounded-xl text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
type="button"
>
<AdjustmentsHorizontal className="size-3.5" strokeWidth="2" />
</button>
<div slot="content">
<div
class="min-w-[180px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
type="button"
on:click={() => {
includeContent = !includeContent;
}}
>
<Checkbox
state={includeContent ? 'checked' : 'unchecked'}
on:change={(e) => {
includeContent = e.detail === 'checked';
}}
/>
{$i18n.t('File content')}
</button>
</div>
{#if isExternalKnowledge}
<div class="p-5 flex flex-col gap-4">
<div class="flex flex-wrap gap-2 text-xs">
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{$i18n.t('Connected')}
</div>
</Dropdown>
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{$i18n.t('Read Only')}
</div>
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{knowledge?.meta?.external?.provider ?? $i18n.t('Provider')}
</div>
<div class="px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-850">
{$i18n.t('Service Account')}
</div>
</div>
{#if knowledge?.write_access}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<AddContentMenu
onUpload={(data) => {
if (data.type === 'directory') {
uploadDirectoryHandler();
} else if (data.type === 'new_directory') {
showNewDirectoryModal = true;
} else if (data.type === 'web') {
showAddWebpageModal = true;
} else if (data.type === 'text') {
showAddTextContentModal = true;
} else {
document.getElementById('files-input').click();
}
}}
onSync={async () => {
pendingSyncFiles = await collectDirectoryFiles();
if (pendingSyncFiles?.length) {
showSyncConfirmModal = true;
}
}}
onReset={() => {
showResetConfirm = true;
}}
/>
<div class="text-xs text-gray-500 mb-1">{$i18n.t('Mapped Source')}</div>
<div class="rounded-xl bg-gray-50 dark:bg-gray-850 px-3 py-2">
{knowledge?.meta?.external?.source?.name ?? $i18n.t('Not configured')}
</div>
</div>
{/if}
</div>
</div>
<div class="px-3 flex justify-between">
<div
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
>
<DropdownOptions
align="start"
className="flex shrink-0 items-center gap-2 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl placeholder-gray-400 outline-hidden focus:outline-hidden"
bind:value={viewOption}
items={[
{ value: null, label: $i18n.t('All') },
{ value: 'created', label: $i18n.t('Created by you') },
{ value: 'shared', label: $i18n.t('Shared with you') }
]}
onChange={(value) => {
if (value) {
localStorage.workspaceViewOption = value;
} else {
delete localStorage.workspaceViewOption;
}
}}
/>
<DropdownOptions
align="start"
bind:value={sortKey}
placeholder={$i18n.t('Sort')}
items={[
{ value: 'name', label: $i18n.t('Name') },
{ value: 'created_at', label: $i18n.t('Created At') },
{ value: 'updated_at', label: $i18n.t('Updated At') }
]}
/>
{#if sortKey}
<DropdownOptions
align="start"
bind:value={direction}
items={[
{ value: 'asc', label: $i18n.t('Asc') },
{ value: null, label: $i18n.t('Desc') }
]}
/>
{/if}
</div>
</div>
</div>
{#if currentDirectoryId !== null}
<div class="px-5 mt-2">
<KnowledgeBreadcrumbs
rootLabel={knowledge.name}
{breadcrumbs}
onNavigate={(dirId) => navigateToDirectory(dirId)}
onMoveFile={(fileId, dirId) => moveFileToDirectoryHandler(fileId, dirId)}
onMoveDir={(dirId, targetId) => moveDirectoryHandler(dirId, targetId)}
/>
</div>
{/if}
{#if syncing}
<div class="mx-2.5 mt-2.5 -mb-0.5">
<div class="flex items-center gap-2.5 rounded-xl py-2 px-3 bg-gray-50 dark:bg-gray-850">
<Spinner className="size-3.5 shrink-0" />
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
{syncing}
</div>
</div>
</div>
{/if}
{#if fileItems !== null && fileItemsTotal !== null}
<div class="flex flex-row flex-1 gap-3 px-2.5 mt-2">
<div class="flex-1 flex">
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
<div class="w-full h-full flex flex-col min-h-full">
{#if fileItems.length > 0 || directoryItems.length > 0}
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files
files={fileItems}
directories={directoryItems}
{knowledge}
{selectedFileId}
onClick={(fileId) => {
selectedFileId = fileId;
if (fileItems) {
const file = fileItems.find((file) => file.id === selectedFileId);
if (file) {
fileSelectHandler(file);
} else {
selectedFile = null;
}
}
}}
onDelete={(fileId) => {
selectedFileId = null;
selectedFile = null;
deleteFileHandler(fileId);
}}
onRename={(fileId, name) => renameFileHandler(fileId, name)}
onNavigateDirectory={(dirId) => navigateToDirectory(dirId)}
onRenameDirectory={(id, name) => renameDirectoryHandler(id, name)}
onDeleteDirectory={(id) => confirmDeleteDirectory(id)}
onMoveFileToDirectory={(fileId, dirId) =>
moveFileToDirectoryHandler(fileId, dirId)}
onMoveDirectoryToDirectory={(dirId, targetId) =>
moveDirectoryHandler(dirId, targetId)}
/>
</div>
{#if fileItemsTotal > 30}
<Pagination bind:page={currentPage} count={fileItemsTotal} perPage={30} />
{/if}
{:else}
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
<div>
{$i18n.t('No content found')}
</div>
</div>
{/if}
<div>
<div class="text-xs text-gray-500 mb-1">{$i18n.t('Auth Mode')}</div>
<div class="rounded-xl bg-gray-50 dark:bg-gray-850 px-3 py-2">
{$i18n.t('Admin-managed service account')}
</div>
</div>
</div>
{#if selectedFileId !== null}
<Drawer
className="h-full"
show={selectedFileId !== null}
onClose={() => {
selectedFileId = null;
selectedFile = null;
}}
>
<div class="flex flex-col justify-start h-full max-h-full">
<div class=" flex flex-col w-full h-full max-h-full">
<div class="shrink-0 flex items-center p-2">
<div class="mr-2">
<button
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
aria-label={$i18n.t('Close')}
on:click={() => {
selectedFileId = null;
selectedFile = null;
}}
>
<ChevronLeft strokeWidth="2.5" />
</button>
</div>
<div class=" flex-1 text-lg line-clamp-1">
{selectedFile?.meta?.name}
</div>
<div class="text-xs text-gray-500">
{$i18n.t(
'This knowledge base retrieves from a connected source. Open WebUI can query it, but cannot upload, sync, edit, delete, reset, or reindex its source data.'
)}
</div>
{#if knowledge?.write_access}
<div>
<button
class="flex self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSaving}
on:click={() => {
updateFileContentHandler();
}}
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
{/if}
<div class="flex flex-col gap-2">
<div class="font-medium text-sm">{$i18n.t('Test Query')}</div>
<div class="flex gap-2">
<input
class="w-full text-sm rounded-xl bg-gray-50 dark:bg-gray-850 px-3 py-2 outline-hidden"
bind:value={externalTestQuery}
placeholder={$i18n.t('Ask this knowledge source a test question')}
/>
<button
class="px-3 py-2 rounded-xl bg-black text-white dark:bg-white dark:text-black text-sm"
on:click={externalTestHandler}
>
{$i18n.t('Test')}
</button>
</div>
</div>
{#if externalTestResult}
<div class="rounded-xl bg-gray-50 dark:bg-gray-850 p-3 text-xs">
<div class="font-medium mb-2">{$i18n.t('Preview')}</div>
{#each externalTestResult.documents ?? [] as document, idx}
<div class="border-t border-gray-100 dark:border-gray-800 py-2">
<div class="line-clamp-4">{document}</div>
<div class="text-gray-500 mt-1">
{externalTestResult.metadatas?.[idx]?.source ?? ''}
</div>
{#key selectedFile.id}
<textarea
class="w-full h-full text-sm outline-none resize-none px-3 py-2"
bind:value={selectedFileContent}
disabled={!knowledge?.write_access}
aria-label={$i18n.t('File content')}
placeholder={$i18n.t('Add content here')}
/>
{/key}
</div>
</div>
</Drawer>
{/each}
</div>
{/if}
</div>
{:else}
<div class="my-10">
<Spinner className="size-4" />
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
<div class="flex flex-1 items-center">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
on:input={handleSearchInput}
aria-label={$i18n.t('Search Collection')}
placeholder={$i18n.t('Search Collection')}
on:focus={() => {
selectedFileId = null;
}}
/>
<Dropdown align="end">
<button
class="p-1.5 mr-1 rounded-xl text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
type="button"
>
<AdjustmentsHorizontal className="size-3.5" strokeWidth="2" />
</button>
<div slot="content">
<div
class="min-w-[180px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
type="button"
on:click={() => {
includeContent = !includeContent;
}}
>
<Checkbox
state={includeContent ? 'checked' : 'unchecked'}
on:change={(e) => {
includeContent = e.detail === 'checked';
}}
/>
{$i18n.t('File content')}
</button>
</div>
</div>
</Dropdown>
{#if knowledge?.write_access}
<div>
<AddContentMenu
onUpload={(data) => {
if (data.type === 'directory') {
uploadDirectoryHandler();
} else if (data.type === 'new_directory') {
showNewDirectoryModal = true;
} else if (data.type === 'web') {
showAddWebpageModal = true;
} else if (data.type === 'text') {
showAddTextContentModal = true;
} else {
document.getElementById('files-input').click();
}
}}
onSync={async () => {
pendingSyncFiles = await collectDirectoryFiles();
if (pendingSyncFiles?.length) {
showSyncConfirmModal = true;
}
}}
onReset={() => {
showResetConfirm = true;
}}
/>
</div>
{/if}
</div>
</div>
<div class="px-3 flex justify-between">
<div
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
>
<DropdownOptions
align="start"
className="flex shrink-0 items-center gap-2 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl placeholder-gray-400 outline-hidden focus:outline-hidden"
bind:value={viewOption}
items={[
{ value: null, label: $i18n.t('All') },
{ value: 'created', label: $i18n.t('Created by you') },
{ value: 'shared', label: $i18n.t('Shared with you') }
]}
onChange={(value) => {
if (value) {
localStorage.workspaceViewOption = value;
} else {
delete localStorage.workspaceViewOption;
}
}}
/>
<DropdownOptions
align="start"
bind:value={sortKey}
placeholder={$i18n.t('Sort')}
items={[
{ value: 'name', label: $i18n.t('Name') },
{ value: 'created_at', label: $i18n.t('Created At') },
{ value: 'updated_at', label: $i18n.t('Updated At') }
]}
/>
{#if sortKey}
<DropdownOptions
align="start"
bind:value={direction}
items={[
{ value: 'asc', label: $i18n.t('Asc') },
{ value: null, label: $i18n.t('Desc') }
]}
/>
{/if}
</div>
</div>
</div>
{#if currentDirectoryId !== null}
<div class="px-5 mt-2">
<KnowledgeBreadcrumbs
rootLabel={knowledge.name}
{breadcrumbs}
onNavigate={(dirId) => navigateToDirectory(dirId)}
onMoveFile={(fileId, dirId) => moveFileToDirectoryHandler(fileId, dirId)}
onMoveDir={(dirId, targetId) => moveDirectoryHandler(dirId, targetId)}
/>
</div>
{/if}
{#if syncing}
<div class="mx-2.5 mt-2.5 -mb-0.5">
<div class="flex items-center gap-2.5 rounded-xl py-2 px-3 bg-gray-50 dark:bg-gray-850">
<Spinner className="size-3.5 shrink-0" />
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
{syncing}
</div>
</div>
</div>
{/if}
{#if fileItems !== null && fileItemsTotal !== null}
<div class="flex flex-row flex-1 gap-3 px-2.5 mt-2">
<div class="flex-1 flex">
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
<div class="w-full h-full flex flex-col min-h-full">
{#if fileItems.length > 0 || directoryItems.length > 0}
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files
files={fileItems}
directories={directoryItems}
{knowledge}
{selectedFileId}
onClick={(fileId) => {
selectedFileId = fileId;
if (fileItems) {
const file = fileItems.find((file) => file.id === selectedFileId);
if (file) {
fileSelectHandler(file);
} else {
selectedFile = null;
}
}
}}
onDelete={(fileId) => {
selectedFileId = null;
selectedFile = null;
deleteFileHandler(fileId);
}}
onRename={(fileId, name) => renameFileHandler(fileId, name)}
onNavigateDirectory={(dirId) => navigateToDirectory(dirId)}
onRenameDirectory={(id, name) => renameDirectoryHandler(id, name)}
onDeleteDirectory={(id) => confirmDeleteDirectory(id)}
onMoveFileToDirectory={(fileId, dirId) =>
moveFileToDirectoryHandler(fileId, dirId)}
onMoveDirectoryToDirectory={(dirId, targetId) =>
moveDirectoryHandler(dirId, targetId)}
/>
</div>
{#if fileItemsTotal > 30}
<Pagination bind:page={currentPage} count={fileItemsTotal} perPage={30} />
{/if}
{:else}
<div
class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs"
>
<div>
{$i18n.t('No content found')}
</div>
</div>
{/if}
</div>
</div>
</div>
{#if selectedFileId !== null}
<Drawer
className="h-full"
show={selectedFileId !== null}
onClose={() => {
selectedFileId = null;
selectedFile = null;
}}
>
<div class="flex flex-col justify-start h-full max-h-full">
<div class=" flex flex-col w-full h-full max-h-full">
<div class="shrink-0 flex items-center p-2">
<div class="mr-2">
<button
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
aria-label={$i18n.t('Close')}
on:click={() => {
selectedFileId = null;
selectedFile = null;
}}
>
<ChevronLeft strokeWidth="2.5" />
</button>
</div>
<div class=" flex-1 text-lg line-clamp-1">
{selectedFile?.meta?.name}
</div>
{#if knowledge?.write_access}
<div>
<button
class="flex self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSaving}
on:click={() => {
updateFileContentHandler();
}}
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
{/if}
</div>
{#key selectedFile.id}
<textarea
class="w-full h-full text-sm outline-none resize-none px-3 py-2"
bind:value={selectedFileContent}
disabled={!knowledge?.write_access}
aria-label={$i18n.t('File content')}
placeholder={$i18n.t('Add content here')}
/>
{/key}
</div>
</div>
</Drawer>
{/if}
</div>
{:else}
<div class="my-10">
<Spinner className="size-4" />
</div>
{/if}
{/if}
</div>
{:else}