feat(agents): make sources optional and drop the synthetic "Default" source

The sources list used to start with a fake "Default" entry that had no id
and, at run time, meant "no source, skip retrieval". The agent form
pre-selected it, snapped back to it when the last source was deselected,
and refused to publish without it, so a new agent always looked like it
had a knowledge base when it had none.

Backend
- /api/sources returns only ingested sources; no placeholder row.
- Publishing an agent no longer requires a source on create or update.
  The legacy "default" value is still accepted and maps to NULL.

Frontend
- The agent source picker starts empty, can be cleared, and shows a hint
  that a source-less agent answers from the model and its tools only.
- The picker groups sources into "Your sources" and "Shared with team"
  when any team-shared source exists, shows "N sources selected" for a
  multi-selection, and gets the same "Go to Sources" / "Upload new"
  footer as the chat picker. A source uploaded from the form is selected
  when it lands.
- Source selection serialisation and the picker id live in one helper
  shared with the chat picker; the four copies in the form are gone.
- The client no longer seeds a placeholder source in the store, and the
  dead auto-select of a "default" document is removed.
This commit is contained in:
Alex committed 2026-09-09 17:59:08 +01:00
1 parent fb15649a3f
commit 79d418f32a
23 files changed
+532 -365

No files matched your search

@@ -647,9 +647,9 @@ class StreamProcessor:
def _configure_source(self):
"""Configure the source based on agent data.
The literal string ``"default"`` is a placeholder meaning "no
ingested source" and is normalized to an empty source so that no
retrieval is attempted.
The literal string ``"default"`` is a legacy placeholder (older
clients sent it for "no ingested source") and is normalized to an
empty source so that no retrieval is attempted.
"""
if self._agent_data:
agent_data = self._agent_data
+5 -38
View File
@@ -76,7 +76,8 @@ AGENT_TYPE_SCHEMAS = {
# is acceptable and maps to NULL downstream.
"validate_published": ["name", "description"],
"validate_draft": [],
"require_source": True,
# No source is required: a source-less agent answers from the model
# and its tools, and ``source``/``sources`` map to NULL downstream.
"fields": [
"name",
"description",
@@ -661,20 +662,6 @@ class CreateAgent(Resource):
if data.get("status") == "published":
required_fields = schema["required_published"]
validate_fields = schema["validate_published"]
if (
schema.get("require_source")
and not data.get("source")
and not data.get("sources")
):
return make_response(
jsonify(
{
"success": False,
"message": "Either 'source' or 'sources' field is required for published agents",
}
),
400,
)
else:
required_fields = schema["required_draft"]
validate_fields = schema["validate_draft"]
@@ -1190,29 +1177,9 @@ class UpdateAgent(Resource):
)
if not final_value:
missing_published_fields.append(field_label)
source_final = update_fields.get(
"source_id", existing_agent.get("source_id"),
)
extra_final = update_fields.get(
"extra_source_ids", existing_agent.get("extra_source_ids") or [],
)
# ``retriever`` carries the runtime identity for
# agents that publish against the synthetic
# "Default" source (frontend's auto-selected
# ``{name: "Default", retriever: "classic"}``
# entry has no ``id``, so ``source_id`` ends up
# NULL even though the user picked something).
# Without this fallback the most common new-agent
# publish flow gets a 400.
retriever_final = update_fields.get(
"retriever", existing_agent.get("retriever"),
)
if (
not source_final
and not extra_final
and not retriever_final
):
missing_published_fields.append("Source or retriever")
# Sources are optional: a published agent with no
# ``source_id`` and no ``extra_source_ids`` skips
# retrieval and answers from the model and its tools.
if missing_published_fields:
return _reject(
"Cannot publish agent. Missing or invalid required "
+4 -10
View File
@@ -73,16 +73,10 @@ class CombinedJson(Resource):
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = [
{
"name": "Default",
"date": "default",
"model": settings.EMBEDDINGS_NAME,
"location": "remote",
"tokens": "",
"retriever": "classic",
}
]
# Only ingested sources are listed. There is no synthetic "Default"
# entry: "no source" is expressed by an empty selection on the client,
# and the answer path treats a missing source as "skip retrieval".
data = []
try:
with db_readonly() as conn:
+118 -190
View File
@@ -1,6 +1,12 @@
import isEqual from 'lodash/isEqual';
import { MoreHorizontal } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
@@ -32,12 +38,13 @@ import {
MultiSelectPopover,
type MultiSelectPopoverItem,
} from '../components/MultiSelectPopover';
import SourcesPopoverFooter from '../components/SourcesPopoverFooter';
import Spinner from '../components/Spinner';
import ToolIcon from '../components/ToolIcon';
import AgentDetailsModal from '../modals/AgentDetailsModal';
import ShareToTeamModal from '../teams/ShareToTeamModal';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState, Doc, Prompt } from '../models/misc';
import { ActiveState, Prompt } from '../models/misc';
import {
selectAgentFolders,
selectSelectedAgent,
@@ -51,6 +58,13 @@ import {
import PromptsModal from '../preferences/PromptsModal';
import Prompts from '../settings/Prompts';
import { UserToolType } from '../settings/types';
import Upload from '../upload/Upload';
import {
selectedSourceIdsFromAgent,
serializeAgentSources,
sourceItemId,
toSourcePickerItems,
} from '../utils/sourceUtils';
import {
getToolDisplayName,
isClassicAgentToolVisible,
@@ -137,6 +151,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const [isSourcePopupOpen, setIsSourcePopupOpen] = useState(false);
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
const [isModelsPopupOpen, setIsModelsPopupOpen] = useState(false);
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
// Set once an upload started from this form finishes, so the source it
// created can be selected as soon as the refreshed list arrives.
const [pendingUploadSelect, setPendingUploadSelect] = useState(false);
const [selectedSourceIds, setSelectedSourceIds] = useState<Set<string>>(
new Set(),
);
@@ -162,6 +181,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const sourceAnchorButtonRef = useRef<HTMLButtonElement>(null);
const toolAnchorButtonRef = useRef<HTMLButtonElement>(null);
const modelAnchorButtonRef = useRef<HTMLButtonElement>(null);
const sourceIdsBeforeUploadRef = useRef<Set<string>>(new Set());
const modeConfig = {
new: {
@@ -199,11 +219,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
agent.name && agent.description && agent.prompt_id && agent.agent_type;
const isJsonSchemaValidOrEmpty =
jsonSchemaText.trim() === '' || jsonSchemaValid;
const hasSource = selectedSourceIds.size > 0;
const guardrailsOk = !guardrailsIncomplete(agent.config?.guardrails);
return (
hasRequiredFields && isJsonSchemaValidOrEmpty && hasSource && guardrailsOk
);
// Sources are optional: an agent without one answers from the model and
// its tools only.
return hasRequiredFields && isJsonSchemaValidOrEmpty && guardrailsOk;
};
const isJsonSchemaInvalid = () => {
@@ -222,8 +241,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
// source name instead of "External KB"; only then show the generic label.
const resolveSourceLabel = (id: string): string => {
const matchedDoc = sourceDocs?.find(
(source) =>
source.id === id || source.name === id || source.retriever === id,
(source) => sourceItemId(source) === id,
);
if (matchedDoc?.name) return matchedDoc.name;
const detail = agent.source_details?.find((d) => d.id === id);
@@ -231,6 +249,50 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
return t('agents.form.externalKb');
};
const sourceItems = useMemo(
() =>
toSourcePickerItems(
sourceDocs,
{
own: t('agents.form.sourcePopup.groupOwn'),
team: t('agents.form.sourcePopup.groupTeam'),
},
SourceIcon,
),
[sourceDocs, t],
);
const selectedSourceNames = Array.from(selectedSourceIds)
.map((id) => resolveSourceLabel(id))
.filter(Boolean);
const sourceTriggerLabel =
selectedSourceIds.size === 0
? t('agents.form.placeholders.selectSources')
: selectedSourceIds.size === 1
? selectedSourceNames[0]
: t('conversation.sources.selectedCount', {
count: selectedSourceIds.size,
});
const handleUploadClick = () => {
sourceIdsBeforeUploadRef.current = new Set(
(sourceDocs ?? []).map(sourceItemId),
);
setIsSourcePopupOpen(false);
setUploadModalState('ACTIVE');
};
// Sources go out as the legacy single ``source`` for one selection and as
// the ``sources`` list for several; an empty selection sends both empty.
const appendSourceFields = (formData: FormData) => {
const { source, sources } = serializeAgentSources(
selectedSourceIds,
sourceDocs,
);
formData.append('source', source);
formData.append('sources', JSON.stringify(sources));
};
const handleUpload = useCallback((files: File[]) => {
if (files && files.length > 0) {
const file = files[0];
@@ -261,39 +323,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
formData.append('name', agent.name);
formData.append('description', agent.description);
if (selectedSourceIds.size > 1) {
const sourcesArray = Array.from(selectedSourceIds)
.map((id) => {
const sourceDoc = sourceDocs?.find(
(source) =>
source.id === id || source.retriever === id || source.name === id,
);
if (sourceDoc?.name === 'Default' && !sourceDoc?.id) {
return 'default';
}
return sourceDoc?.id || id;
})
.filter(Boolean);
formData.append('sources', JSON.stringify(sourcesArray));
formData.append('source', '');
} else if (selectedSourceIds.size === 1) {
const singleSourceId = Array.from(selectedSourceIds)[0];
const sourceDoc = sourceDocs?.find(
(source) =>
source.id === singleSourceId ||
source.retriever === singleSourceId ||
source.name === singleSourceId,
);
let finalSourceId;
if (sourceDoc?.name === 'Default' && !sourceDoc?.id)
finalSourceId = 'default';
else finalSourceId = sourceDoc?.id || singleSourceId;
formData.append('source', String(finalSourceId));
formData.append('sources', JSON.stringify([]));
} else {
formData.append('source', '');
formData.append('sources', JSON.stringify([]));
}
appendSourceFields(formData);
formData.append('chunks', agent.chunks);
formData.append('retriever', agent.retriever);
@@ -386,39 +416,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
formData.append('name', agent.name);
formData.append('description', agent.description);
if (selectedSourceIds.size > 1) {
const sourcesArray = Array.from(selectedSourceIds)
.map((id) => {
const sourceDoc = sourceDocs?.find(
(source) =>
source.id === id || source.retriever === id || source.name === id,
);
if (sourceDoc?.name === 'Default' && !sourceDoc?.id) {
return 'default';
}
return sourceDoc?.id || id;
})
.filter(Boolean);
formData.append('sources', JSON.stringify(sourcesArray));
formData.append('source', '');
} else if (selectedSourceIds.size === 1) {
const singleSourceId = Array.from(selectedSourceIds)[0];
const sourceDoc = sourceDocs?.find(
(source) =>
source.id === singleSourceId ||
source.retriever === singleSourceId ||
source.name === singleSourceId,
);
let finalSourceId;
if (sourceDoc?.name === 'Default' && !sourceDoc?.id)
finalSourceId = 'default';
else finalSourceId = sourceDoc?.id || singleSourceId;
formData.append('source', String(finalSourceId));
formData.append('sources', JSON.stringify([]));
} else {
formData.append('source', '');
formData.append('sources', JSON.stringify([]));
}
appendSourceFields(formData);
formData.append('chunks', agent.chunks);
formData.append('retriever', agent.retriever);
@@ -658,16 +656,15 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
validateAndSetFolder();
}, [folderIdFromUrl, agentFolders, token, dispatch]);
// Auto-select default source if none selected
// After an upload started from this form, select the source it created.
useEffect(() => {
if (sourceDocs && sourceDocs.length > 0 && selectedSourceIds.size === 0) {
const defaultSource = sourceDocs.find((s) => s.name === 'Default');
const fallback = defaultSource || sourceDocs[0];
setSelectedSourceIds(
new Set([String(fallback.id || fallback.retriever || fallback.name)]),
);
}
}, [sourceDocs, selectedSourceIds.size]);
if (!pendingUploadSelect || !sourceDocs) return;
const before = sourceIdsBeforeUploadRef.current;
const added = sourceDocs.map(sourceItemId).filter((id) => !before.has(id));
if (added.length === 0) return;
setPendingUploadSelect(false);
setSelectedSourceIds((prev) => new Set([...prev, ...added]));
}, [pendingUploadSelect, sourceDocs]);
useEffect(() => {
if ((mode === 'edit' || mode === 'draft') && agentId) {
@@ -679,31 +676,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
const data = await response.json();
if (data.sources && data.sources.length > 0) {
const mappedSources = data.sources.map((sourceId: string) => {
if (sourceId === 'default') {
const defaultSource = sourceDocs?.find(
(source) => source.name === 'Default',
);
return defaultSource?.retriever || 'classic';
}
return sourceId;
});
setSelectedSourceIds(new Set(mappedSources));
} else if (data.source) {
if (data.source === 'default') {
const defaultSource = sourceDocs?.find(
(source) => source.name === 'Default',
);
setSelectedSourceIds(
new Set([defaultSource?.retriever || 'classic']),
);
} else {
setSelectedSourceIds(new Set([data.source]));
}
} else if (data.retriever) {
setSelectedSourceIds(new Set([data.retriever]));
}
setSelectedSourceIds(new Set(selectedSourceIdsFromAgent(data)));
if (data.tool_details) setSelectedTools(data.tool_details);
if (data.status === 'draft') setEffectiveMode('draft');
@@ -763,56 +736,18 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}, [selectedModelIds]);
useEffect(() => {
const selectedSources = Array.from(selectedSourceIds)
.map((id) =>
sourceDocs?.find(
(source) =>
source.id === id || source.retriever === id || source.name === id,
),
)
.filter(Boolean);
if (selectedSources.length > 0) {
// Handle multiple sources
if (selectedSources.length > 1) {
// Multiple sources selected - store in sources array
const sourceIds = selectedSources
.map((source) => source?.id)
.filter((id): id is string => Boolean(id));
setAgent((prev) => ({
...prev,
sources: sourceIds,
source: '', // Clear single source for multiple sources
retriever: '',
}));
} else {
// Single source selected - maintain backward compatibility
const selectedSource = selectedSources[0];
if (selectedSource && 'id' in selectedSource) {
setAgent((prev) => ({
...prev,
source: selectedSource?.id || 'default',
sources: [], // Clear sources array for single source
retriever: '',
}));
} else {
setAgent((prev) => ({
...prev,
source: '',
sources: [], // Clear sources array
retriever: selectedSource?.retriever || 'classic',
}));
}
}
} else {
// No sources selected
setAgent((prev) => ({
...prev,
source: '',
sources: [],
retriever: '',
}));
}
const { source, sources } = serializeAgentSources(
selectedSourceIds,
sourceDocs,
);
setAgent((prev) => ({
...prev,
source,
sources,
// A source-less agent keeps the default retriever name so its runtime
// config stays valid; real sources carry their own retriever.
retriever: selectedSourceIds.size === 0 ? 'classic' : '',
}));
}, [selectedSourceIds]);
useEffect(() => {
@@ -999,63 +934,46 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
open={isSourcePopupOpen}
onOpenChange={setIsSourcePopupOpen}
title={t('agents.form.sourcePopup.title')}
items={
sourceDocs?.map((doc: Doc) => ({
id: String(doc.id || doc.retriever || doc.name),
label: doc.name,
icon: <img src={SourceIcon} alt="" />,
})) || []
}
items={sourceItems}
selectedIds={Array.from(selectedSourceIds)}
onToggle={(id) => {
const next = new Set(selectedSourceIds);
if (next.has(id)) next.delete(id);
else next.add(id);
if (
next.size === 0 &&
sourceDocs &&
sourceDocs.length > 0
) {
const defaultSource = sourceDocs.find(
(s) => s.name === 'Default',
);
const fallback = defaultSource || sourceDocs[0];
setSelectedSourceIds(
new Set([
String(
fallback.id || fallback.retriever || fallback.name,
),
]),
);
} else {
setSelectedSourceIds(next);
}
setSelectedSourceIds(next);
}}
searchPlaceholder={t(
'agents.form.sourcePopup.searchPlaceholder',
)}
emptyMessage={t('agents.form.sourcePopup.noOptionsMessage')}
footer={
<SourcesPopoverFooter
onNavigate={() => setIsSourcePopupOpen(false)}
onUploadClick={handleUploadClick}
/>
}
trigger={
<Button
type="button"
variant="outline"
ref={sourceAnchorButtonRef}
title={selectedSourceNames.join(', ')}
className={`bg-card h-auto w-full justify-start truncate rounded-3xl px-5 py-3 text-left text-sm font-normal ${
selectedSourceIds.size > 0
? 'text-foreground dark:text-foreground'
: 'dark:text-muted-foreground text-gray-400'
}`}
>
{selectedSourceIds.size > 0
? Array.from(selectedSourceIds)
.map((id) => resolveSourceLabel(id))
.filter(Boolean)
.join(', ')
: t('agents.form.placeholders.selectSources')}
{sourceTriggerLabel}
</Button>
}
/>
</div>
{selectedSourceIds.size === 0 && (
<p className="text-muted-foreground mt-2 text-xs">
{t('agents.form.sourcePopup.noSourceHint')}
</p>
)}
</div>
</div>
<div className="bg-card rounded-2xl px-6 py-3">
@@ -1551,6 +1469,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
onClose={() => setShareModalOpen(false)}
/>
)}
{uploadModalState === 'ACTIVE' && (
<Upload
receivedFile={[]}
setModalState={setUploadModalState}
isOnboarding={false}
renderTab={null}
close={() => setUploadModalState('INACTIVE')}
onSuccessfulUpload={() => setPendingUploadSelect(true)}
/>
)}
<AddPromptModal
prompts={prompts}
isOpen={addPromptModal}
@@ -241,13 +241,7 @@ function createWorkflowPayload(
nodes: workflowNodes.map((node) => ({
id: node.id,
type: node.type as
| 'start'
| 'end'
| 'agent'
| 'note'
| 'state'
| 'condition'
| 'code',
'start' | 'end' | 'agent' | 'note' | 'state' | 'condition' | 'code',
title: node.data.title || node.data.label || node.type,
position: node.position,
data:
@@ -327,10 +321,9 @@ function WorkflowBuilderInner() {
const [availableTools, setAvailableTools] = useState<UserTool[]>([]);
const sourceOptions = useMemo(
() =>
(sourceDocs ?? []).map((doc) => ({
value: doc.id ?? 'default',
label: doc.name,
})),
(sourceDocs ?? [])
.filter((doc) => Boolean(doc.id))
.map((doc) => ({ value: doc.id as string, label: doc.name })),
[sourceDocs],
);
const [agentJsonSchemaDrafts, setAgentJsonSchemaDrafts] = useState<
@@ -3042,11 +3035,7 @@ function WorkflowBuilderInner() {
.map((n) => ({
id: n.id,
type: n.type as
| 'start'
| 'end'
| 'agent'
| 'state'
| 'code',
'start' | 'end' | 'agent' | 'state' | 'code',
title: n.data.title || n.data.label || n.type,
position: n.position,
data:
+1 -3
View File
@@ -55,6 +55,7 @@ import {
partitionAttachmentFiles,
} from '../constants/fileUpload';
import { UserToolType } from '../settings/types';
import { sourceItemId } from '../utils/sourceUtils';
import { isChatToolVisible } from '../utils/toolUtils';
const generateId = (): string =>
@@ -1459,9 +1460,6 @@ export default function MessageInput({
void _docs;
};
// Stable id for matching selected sources: prefer ``id``, fall back to ``date``.
const sourceItemId = (doc: Doc): string => doc.id || doc.date;
const sourceItems: MultiSelectPopoverItem[] = (sourceDocs || []).map(
(doc) => ({
id: sourceItemId(doc),
@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next';
import RedirectIcon from '../assets/redirect.svg';
import { Button } from './ui/button';
type SourcesPopoverFooterProps = {
/** Fires when the sources link is followed so the caller can close its popover. */
onNavigate: () => void;
onUploadClick: () => void;
};
/** Shared footer for source pickers: a link to the sources page and an upload shortcut. */
export default function SourcesPopoverFooter({
onNavigate,
onUploadClick,
}: SourcesPopoverFooterProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<a
href="/settings/sources"
className="text-primary inline-flex items-center gap-2 text-base font-medium"
onClick={onNavigate}
>
{t('settings.sources.goToSources')}
<img src={RedirectIcon} alt="" aria-hidden="true" className="h-3 w-3" />
</a>
<Button
type="button"
variant="outline"
onClick={onUploadClick}
className="border-primary text-primary hover:bg-primary/90 h-auto w-auto self-start rounded-full border bg-transparent px-4 py-2 text-sm font-medium shadow-none transition-colors duration-200 hover:text-white"
>
{t('settings.sources.uploadNew')}
</Button>
</div>
);
}
@@ -1,12 +1,12 @@
import { useTranslation } from 'react-i18next';
import RedirectIcon from '../../assets/redirect.svg';
import SourceIcon from '../../assets/source.svg';
import type { Doc } from '../../models/misc';
import {
MultiSelectPopover,
type MultiSelectPopoverItem,
} from '../MultiSelectPopover';
import SourcesPopoverFooter from '../SourcesPopoverFooter';
import { Button } from '../ui/button';
type SourcesTriggerProps = {
@@ -41,29 +41,10 @@ export default function SourcesTrigger({
searchPlaceholder={t('settings.sources.searchPlaceholder')}
emptyMessage={t('conversation.sources.noSourcesAvailable')}
footer={
<div className="flex flex-col gap-3">
<a
href="/settings/sources"
className="text-primary inline-flex items-center gap-2 text-base font-medium"
onClick={() => onOpenChange(false)}
>
{t('settings.sources.goToSources')}
<img
src={RedirectIcon}
alt=""
aria-hidden="true"
className="h-3 w-3"
/>
</a>
<Button
type="button"
variant="outline"
onClick={onUploadClick}
className="border-primary text-primary hover:bg-primary/90 h-auto w-auto self-start rounded-full border bg-transparent px-4 py-2 text-sm font-medium shadow-none transition-colors duration-200 hover:text-white"
>
{t('settings.sources.uploadNew')}
</Button>
</div>
<SourcesPopoverFooter
onNavigate={() => onOpenChange(false)}
onUploadClick={onUploadClick}
/>
}
trigger={
<Button
@@ -86,7 +67,9 @@ export default function SourcesTrigger({
{selectedDocs && selectedDocs.length > 0
? selectedDocs.length === 1
? selectedDocs[0].name
: `${selectedDocs.length} sources selected`
: t('conversation.sources.selectedCount', {
count: selectedDocs.length,
})
: t('conversation.sources.title')}
</span>
</Button>
+1 -19
View File
@@ -1,7 +1,6 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Doc } from '../models/misc';
import {
getDocs,
getConversations,
@@ -9,11 +8,9 @@ import {
} from '../preferences/preferenceApi';
import {
selectConversations,
selectSelectedDocs,
selectToken,
setConversations,
setPrompts,
setSelectedDocs,
setSourceDocs,
} from '../preferences/preferenceSlice';
@@ -22,7 +19,7 @@ import {
*
* Custom hook responsible for initializing all application data on mount.
* This hook handles:
* - Fetching and setting up documents (source docs and selected docs)
* - Fetching and setting up source documents
* - Fetching and setting up prompts
* - Fetching and setting up conversations
*
@@ -31,7 +28,6 @@ import {
export default function useDataInitializer(isAuthLoading: boolean) {
const dispatch = useDispatch();
const token = useSelector(selectToken);
const selectedDoc = useSelector(selectSelectedDocs);
const conversations = useSelector(selectConversations);
// Initialize documents
@@ -45,20 +41,6 @@ export default function useDataInitializer(isAuthLoading: boolean) {
try {
const data = await getDocs(token);
dispatch(setSourceDocs(data));
// Auto-select default document if none selected
if (
!selectedDoc ||
(Array.isArray(selectedDoc) && selectedDoc.length === 0)
) {
if (Array.isArray(data)) {
data.forEach((doc: Doc) => {
if (doc.model && doc.name === 'default') {
dispatch(setSelectedDocs([doc]));
}
});
}
}
} catch (error) {
console.error('Failed to fetch documents:', error);
}
+6 -2
View File
@@ -1088,7 +1088,8 @@
"text": "Wähle deine Quellen",
"link": "Quellen-Link",
"view_more": "{{count}} weitere Quellen",
"noSourcesAvailable": "Keine Quellen verfügbar"
"noSourcesAvailable": "Keine Quellen verfügbar",
"selectedCount": "{{count}} Quellen ausgewählt"
},
"attachments": {
"attach": "Anhängen",
@@ -1248,7 +1249,10 @@
"sourcePopup": {
"title": "Quellen auswählen",
"searchPlaceholder": "Quellen suchen...",
"noOptionsMessage": "Keine Quellen verfügbar"
"noOptionsMessage": "Keine Quellen verfügbar",
"groupOwn": "Deine Quellen",
"groupTeam": "Mit dem Team geteilt",
"noSourceHint": "Optional. Ohne Quelle antwortet der Agent nur anhand des Modells und seiner Werkzeuge."
},
"toolsPopup": {
"title": "Werkzeuge auswählen",
+6 -2
View File
@@ -1093,7 +1093,8 @@
"text": "Choose Your Sources",
"link": "Source link",
"view_more": "{{count}} more sources",
"noSourcesAvailable": "No sources available"
"noSourcesAvailable": "No sources available",
"selectedCount": "{{count}} sources selected"
},
"attachments": {
"attach": "Attach",
@@ -1258,7 +1259,10 @@
"sourcePopup": {
"title": "Select Sources",
"searchPlaceholder": "Search sources...",
"noOptionsMessage": "No sources available"
"noOptionsMessage": "No sources available",
"groupOwn": "Your sources",
"groupTeam": "Shared with team",
"noSourceHint": "Optional. Without a source the agent answers from the model and its tools only."
},
"toolsPopup": {
"title": "Select Tools",
+6 -2
View File
@@ -1088,7 +1088,8 @@
"text": "Elegir tus fuentes",
"link": "Enlace fuente",
"view_more": "Ver {{count}} más fuentes",
"noSourcesAvailable": "No hay fuentes disponibles"
"noSourcesAvailable": "No hay fuentes disponibles",
"selectedCount": "{{count}} fuentes seleccionadas"
},
"attachments": {
"attach": "Adjuntar",
@@ -1248,7 +1249,10 @@
"sourcePopup": {
"title": "Seleccionar Fuentes",
"searchPlaceholder": "Buscar fuentes...",
"noOptionsMessage": "No hay fuentes disponibles"
"noOptionsMessage": "No hay fuentes disponibles",
"groupOwn": "Tus fuentes",
"groupTeam": "Compartidas con el equipo",
"noSourceHint": "Opcional. Sin una fuente, el agente responde solo con el modelo y sus herramientas."
},
"toolsPopup": {
"title": "Seleccionar Herramientas",
+6 -2
View File
@@ -1088,7 +1088,8 @@
"text": "ソーステキスト",
"link": "ソースリンク",
"view_more": "さらに{{count}}個のソース",
"noSourcesAvailable": "利用可能なソースがありません"
"noSourcesAvailable": "利用可能なソースがありません",
"selectedCount": "{{count}} 件のソースを選択中"
},
"attachments": {
"attach": "添付",
@@ -1248,7 +1249,10 @@
"sourcePopup": {
"title": "ソースを選択",
"searchPlaceholder": "ソースを検索...",
"noOptionsMessage": "利用可能なソースがありません"
"noOptionsMessage": "利用可能なソースがありません",
"groupOwn": "自分のソース",
"groupTeam": "チームと共有",
"noSourceHint": "任意。ソースがない場合、エージェントはモデルとツールのみで回答します。"
},
"toolsPopup": {
"title": "ツールを選択",
+6 -2
View File
@@ -1088,7 +1088,8 @@
"text": "Выберите ваши источники",
"link": "Ссылка на источник",
"view_more": "ещё {{count}} источников",
"noSourcesAvailable": "Нет доступных источников"
"noSourcesAvailable": "Нет доступных источников",
"selectedCount": "Выбрано источников: {{count}}"
},
"attachments": {
"attach": "Прикрепить",
@@ -1248,7 +1249,10 @@
"sourcePopup": {
"title": "Выберите Источники",
"searchPlaceholder": "Поиск источников...",
"noOptionsMessage": "Нет доступных источников"
"noOptionsMessage": "Нет доступных источников",
"groupOwn": "Ваши источники",
"groupTeam": "Общие с командой",
"noSourceHint": "Необязательно. Без источника агент отвечает только на основе модели и своих инструментов."
},
"toolsPopup": {
"title": "Выберите Инструменты",
+6 -2
View File
@@ -1088,7 +1088,8 @@
"text": "來源文字",
"link": "來源連結",
"view_more": "查看更多 {{count}} 個來源",
"noSourcesAvailable": "沒有可用的來源"
"noSourcesAvailable": "沒有可用的來源",
"selectedCount": "已選擇 {{count}} 個來源"
},
"attachments": {
"attach": "附件",
@@ -1248,7 +1249,10 @@
"sourcePopup": {
"title": "選擇來源",
"searchPlaceholder": "搜尋來源...",
"noOptionsMessage": "沒有可用的來源"
"noOptionsMessage": "沒有可用的來源",
"groupOwn": "我的來源",
"groupTeam": "團隊共享",
"noSourceHint": "可選。未選擇來源時,代理僅根據模型和工具作答。"
},
"toolsPopup": {
"title": "選擇工具",
+6 -2
View File
@@ -1088,7 +1088,8 @@
"text": "来源文本",
"link": "来源链接",
"view_more": "还有{{count}}个来源",
"noSourcesAvailable": "没有可用的来源"
"noSourcesAvailable": "没有可用的来源",
"selectedCount": "已选择 {{count}} 个来源"
},
"attachments": {
"attach": "附件",
@@ -1248,7 +1249,10 @@
"sourcePopup": {
"title": "选择来源",
"searchPlaceholder": "搜索来源...",
"noOptionsMessage": "没有可用的来源"
"noOptionsMessage": "没有可用的来源",
"groupOwn": "我的来源",
"groupTeam": "团队共享",
"noSourceHint": "可选。未选择来源时,智能体仅根据模型和工具作答。"
},
"toolsPopup": {
"title": "选择工具",
@@ -47,14 +47,12 @@ export const ShareConversationModal = ({
const selectedPrompt = useSelector(selectPrompt);
const selectedChunk = useSelector(selectChunks);
// Only ingested sources (rows with an id) can be shared.
const extractDocPaths = (docs: Doc[]) =>
docs
? docs.map((doc: Doc) => {
return {
label: doc.name,
value: doc.id ?? 'default',
};
})
? docs
.filter((doc: Doc) => Boolean(doc.id))
.map((doc: Doc) => ({ label: doc.name, value: doc.id as string }))
: [];
const [sourcePath, setSourcePath] = useState<{
+1 -10
View File
@@ -55,16 +55,7 @@ const initialState: Preference = {
{ name: 'strict', id: 'strict', type: 'public' },
],
chunks: '2',
selectedDocs: [
{
id: 'default',
name: 'default',
type: 'remote',
date: 'default',
model: 'openai_text-embedding-ada-002',
retriever: 'classic',
},
] as Doc[],
selectedDocs: [],
sourceDocs: null,
conversations: {
data: null,
+1 -10
View File
@@ -43,16 +43,7 @@ const preloadedState: { preference: Preference } = {
data: null,
loading: false,
},
sourceDocs: [
{
name: 'default',
date: '',
model: '1.0',
type: 'remote',
id: 'default',
retriever: 'clasic',
},
],
sourceDocs: null,
modalState: 'INACTIVE',
paginatedDocuments: null,
templateAgents: null,
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import type { Doc } from '../models/misc';
import {
selectedSourceIdsFromAgent,
serializeAgentSources,
sourceItemId,
toSourcePickerItems,
} from './sourceUtils';
const doc = (overrides: Partial<Doc>): Doc => ({
name: 'doc',
date: '2026-01-01',
model: 'm',
...overrides,
});
const labels = { own: 'Your sources', team: 'Shared with team' };
describe('sourceItemId', () => {
it('uses the source id when present', () => {
expect(sourceItemId(doc({ id: 'abc', date: 'd' }))).toBe('abc');
});
it('falls back to the date for legacy rows without an id', () => {
expect(sourceItemId(doc({ date: '2026-02-02' }))).toBe('2026-02-02');
});
});
describe('toSourcePickerItems', () => {
it('returns no items for a missing list', () => {
expect(toSourcePickerItems(null, labels)).toEqual([]);
expect(toSourcePickerItems(undefined, labels)).toEqual([]);
});
it('stays flat when every source is the caller’s own', () => {
const items = toSourcePickerItems(
[
doc({ id: 'a', name: 'A' }),
doc({ id: 'b', name: 'B', ownership: 'user' }),
],
labels,
'icon.svg',
);
expect(items).toEqual([
{ id: 'a', label: 'A', icon: 'icon.svg' },
{ id: 'b', label: 'B', icon: 'icon.svg' },
]);
expect(items.every((item) => item.group === undefined)).toBe(true);
});
it('groups own sources first, then team-shared ones', () => {
const items = toSourcePickerItems(
[
doc({ id: 't1', name: 'Team one', ownership: 'team' }),
doc({ id: 'o1', name: 'Own one', ownership: 'user' }),
doc({ id: 't2', name: 'Team two', ownership: 'team' }),
doc({ id: 'o2', name: 'Own two' }),
],
labels,
);
expect(items.map((item) => [item.id, item.group])).toEqual([
['o1', 'Your sources'],
['o2', 'Your sources'],
['t1', 'Shared with team'],
['t2', 'Shared with team'],
]);
});
});
describe('serializeAgentSources', () => {
const docs = [doc({ id: 'a' }), doc({ id: 'b' })];
it('sends both fields empty when nothing is selected', () => {
expect(serializeAgentSources([], docs)).toEqual({
source: '',
sources: [],
});
});
it('uses the legacy single source field for one selection', () => {
expect(serializeAgentSources(['a'], docs)).toEqual({
source: 'a',
sources: [],
});
});
it('uses the sources list for several selections', () => {
expect(serializeAgentSources(new Set(['a', 'b']), docs)).toEqual({
source: '',
sources: ['a', 'b'],
});
});
it('resolves picker ids to source ids and passes unknown ids through', () => {
const legacy = doc({ id: undefined, date: 'legacy-date' });
expect(
serializeAgentSources(['legacy-date', 'shared-by-owner'], [legacy]),
).toEqual({
source: '',
sources: ['legacy-date', 'shared-by-owner'],
});
expect(serializeAgentSources(['shared-by-owner'], null)).toEqual({
source: 'shared-by-owner',
sources: [],
});
});
it('drops duplicates and empty ids', () => {
expect(serializeAgentSources(['a', 'a', ''], docs)).toEqual({
source: 'a',
sources: [],
});
});
});
describe('selectedSourceIdsFromAgent', () => {
it('prefers the sources list over the single source', () => {
expect(
selectedSourceIdsFromAgent({ source: 'x', sources: ['a', 'b'] }),
).toEqual(['a', 'b']);
});
it('falls back to the single source', () => {
expect(selectedSourceIdsFromAgent({ source: 'a', sources: [] })).toEqual([
'a',
]);
});
it('treats the legacy default placeholder and empty values as no source', () => {
expect(selectedSourceIdsFromAgent({ source: 'default' })).toEqual([]);
expect(selectedSourceIdsFromAgent({ sources: ['default', ''] })).toEqual(
[],
);
expect(selectedSourceIdsFromAgent({ source: '' })).toEqual([]);
expect(selectedSourceIdsFromAgent({})).toEqual([]);
});
it('keeps real ids next to a legacy placeholder', () => {
expect(selectedSourceIdsFromAgent({ sources: ['default', 'a'] })).toEqual([
'a',
]);
});
});
+73
View File
@@ -0,0 +1,73 @@
import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover';
import type { Doc } from '../models/misc';
/** Stable picker id for a source: its id, or its date for legacy rows without one. */
export function sourceItemId(doc: Pick<Doc, 'id' | 'date'>): string {
return doc.id || doc.date;
}
export type SourceGroupLabels = { own: string; team: string };
/**
* Build picker items for a source list. When any source is shared through a
* team, items are grouped into the caller's own sources followed by the
* team-shared ones; otherwise the list stays flat.
*/
export function toSourcePickerItems(
docs: Doc[] | null | undefined,
labels: SourceGroupLabels,
icon?: MultiSelectPopoverItem['icon'],
): MultiSelectPopoverItem[] {
const list = docs ?? [];
const isTeam = (doc: Doc) => doc.ownership === 'team';
const hasTeam = list.some(isTeam);
const toItem = (doc: Doc): MultiSelectPopoverItem => ({
id: sourceItemId(doc),
label: doc.name,
icon,
...(hasTeam ? { group: isTeam(doc) ? labels.team : labels.own } : {}),
});
if (!hasTeam) return list.map(toItem);
return [
...list.filter((doc) => !isTeam(doc)).map(toItem),
...list.filter(isTeam).map(toItem),
];
}
export type AgentSourcePayload = { source: string; sources: string[] };
/**
* Serialise a picker selection into the agent payload: one selection goes in
* the legacy single ``source`` field, several go in ``sources``, and none
* leaves both empty (the agent then skips retrieval). Picker ids resolve to
* source ids; ids not in ``docs`` (e.g. an owner's source a team editor can't
* list) pass through unchanged.
*/
export function serializeAgentSources(
selectedIds: Iterable<string>,
docs: Doc[] | null | undefined,
): AgentSourcePayload {
const ids = Array.from(new Set(selectedIds))
.map((id) => docs?.find((doc) => sourceItemId(doc) === id)?.id ?? id)
.filter(Boolean);
if (ids.length > 1) return { source: '', sources: ids };
if (ids.length === 1) return { source: ids[0], sources: [] };
return { source: '', sources: [] };
}
/**
* Selected source ids for a stored agent. ``sources`` wins over the legacy
* single ``source``; the legacy ``"default"`` placeholder means no source.
*/
export function selectedSourceIdsFromAgent(agent: {
source?: string;
sources?: string[];
}): string[] {
const ids =
agent.sources && agent.sources.length > 0
? agent.sources
: agent.source
? [agent.source]
: [];
return ids.filter((id) => Boolean(id) && id !== 'default');
}
+66 -18
View File
@@ -782,12 +782,10 @@ class TestUpdateAgent:
response = UpdateAgent().put(str(agent["id"]))
assert response.status_code == 400
def test_publish_with_default_source_succeeds(self, app, pg_conn):
"""The frontend's auto-selected "Default" source has no UUID — it
sends ``source: ''`` and ``sources: []`` with ``retriever: 'classic'``.
Pre-fix, the publish gate rejected this with
``Missing or invalid required fields: Source``. The retriever
carries the runtime identity, so the gate now accepts it.
def test_publish_with_no_source_succeeds(self, app, pg_conn):
"""An agent with no source is a valid published state: it answers
from the model and its tools. The form sends ``source: ''`` and
``sources: []`` with the default ``retriever``.
"""
from docsgpt.api.user.agents.routes import UpdateAgent
from docsgpt.storage.db.repositories.agents import AgentsRepository
@@ -827,11 +825,11 @@ class TestUpdateAgent:
f"unexpected error: {response.json}"
)
def test_publish_without_source_or_retriever_returns_400(
def test_publish_without_source_or_retriever_succeeds(
self, app, pg_conn,
):
"""If neither a source nor a retriever is configured, the gate
still trips — the agent has no way to retrieve anything."""
"""Neither a source nor a retriever is required to publish; a
source-less agent simply skips retrieval at run time."""
from docsgpt.api.user.agents.routes import UpdateAgent
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
@@ -864,8 +862,12 @@ class TestUpdateAgent:
from flask import request
request.decoded_token = {"sub": user}
response = UpdateAgent().put(str(agent["id"]))
assert response.status_code == 400
assert "Source or retriever" in response.json.get("message", "")
assert response.status_code == 200, (
f"unexpected error: {response.json}"
)
stored = AgentsRepository(pg_conn).get_by_id(str(agent["id"]))
assert stored["status"] == "published"
assert stored["source_id"] is None
def test_publishing_generates_api_key(self, app, pg_conn):
from docsgpt.api.user.agents.routes import UpdateAgent
@@ -1075,25 +1077,71 @@ class TestCreateAgentMore:
)
assert status == 400
def test_publish_classic_without_source_returns_400(self, app):
def test_publish_classic_without_source_succeeds(self, app, pg_conn):
"""Publishing with ``source: ''`` and ``sources: []`` creates a
source-less agent (``source_id`` NULL) instead of a 400."""
from docsgpt.api.user.agents.routes import CreateAgent
from docsgpt.storage.db.repositories.agents import AgentsRepository
with app.test_request_context(
user = "u-create-no-source"
with _patch_db(pg_conn), app.test_request_context(
"/api/create_agent", method="POST",
json={
"name": "n",
"name": "no-source",
"description": "d",
"status": "published",
"agent_type": "classic",
"chunks": "2",
"retriever": "classic",
"prompt_id": "default",
"source": "",
"sources": [],
},
):
from flask import request
request.decoded_token = {"sub": "u"}
request.decoded_token = {"sub": user}
response = CreateAgent().post()
status = (
response[1] if isinstance(response, tuple) else response.status_code
assert response.status_code == 201, (
f"unexpected error: {response.json}"
)
assert status == 400
agents = AgentsRepository(pg_conn).list_for_user(user)
created = next(a for a in agents if a["name"] == "no-source")
assert created["status"] == "published"
assert created["source_id"] is None
assert not created.get("extra_source_ids")
assert created["key"]
def test_publish_classic_with_legacy_default_sentinel_succeeds(
self, app, pg_conn,
):
"""Older clients still send ``source: "default"``; it keeps mapping
to a NULL source rather than being rejected."""
from docsgpt.api.user.agents.routes import CreateAgent
from docsgpt.storage.db.repositories.agents import AgentsRepository
user = "u-create-legacy-default"
with _patch_db(pg_conn), app.test_request_context(
"/api/create_agent", method="POST",
json={
"name": "legacy-default",
"description": "d",
"status": "published",
"agent_type": "classic",
"chunks": "2",
"retriever": "classic",
"prompt_id": "default",
"source": "default",
},
):
from flask import request
request.decoded_token = {"sub": user}
response = CreateAgent().post()
assert response.status_code == 201, (
f"unexpected error: {response.json}"
)
agents = AgentsRepository(pg_conn).list_for_user(user)
created = next(a for a in agents if a["name"] == "legacy-default")
assert created["source_id"] is None
def test_unknown_agent_type_falls_back_classic(self, app, pg_conn):
from docsgpt.api.user.agents.routes import CreateAgent
+17 -3
View File
@@ -80,7 +80,9 @@ class TestCombinedJson:
response = CombinedJson().get()
assert response.status_code == 401
def test_returns_default_plus_user_sources(self, app, pg_conn):
def test_returns_only_real_user_sources(self, app, pg_conn):
"""No synthetic "Default" row: every entry is an ingested source
with an id, so clients never have to special-case a placeholder."""
from docsgpt.api.user.sources.routes import CombinedJson
user = "u-list-sources"
@@ -93,8 +95,20 @@ class TestCombinedJson:
assert response.status_code == 200
names = [d["name"] for d in response.json]
assert "Default" in names
assert "doc1" in names
assert "Default" not in names
assert names == ["doc1"]
assert all(d.get("id") for d in response.json)
def test_returns_empty_list_when_user_has_no_sources(self, app, pg_conn):
from docsgpt.api.user.sources.routes import CombinedJson
with _patch_db(pg_conn), app.test_request_context("/api/sources"):
from flask import request
request.decoded_token = {"sub": "u-no-sources"}
response = CombinedJson().get()
assert response.status_code == 200
assert response.json == []
def test_db_error_returns_400(self, app):
from docsgpt.api.user.sources.routes import CombinedJson