mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 18:15:02 +00:00
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:
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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ツールを選択",
|
||||
|
||||
@@ -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": "Выберите Инструменты",
|
||||
|
||||
@@ -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": "選擇工具",
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user