Merge pull request #2753 from arc53/feat/optional-agent-sources

feat(agents): make sources optional and drop the synthetic "Default" source
This commit is contained in:
Alex authored and GitHub committed 2026-09-11 14:10:52 +01:00
commit 51cacb4bff
27 files changed
+970 -445

No files matched your search

+4
View File
@@ -211,3 +211,7 @@ bench/
# Python package build output (uv build)
dist/
# Personal agent exports/imports kept at the repo root (may hold chat ids,
# webhook targets and other per-user config)
/*.agent.yaml
@@ -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
+75 -62
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",
@@ -480,18 +481,11 @@ class GetAgents(Resource):
shared_ids = [aid for aid in team_shared if aid not in owned_ids]
shared_agents = agents_repo.list_by_ids(shared_ids)
def _is_runnable(agent: dict) -> bool:
return bool(
agent.get("source_id")
or (agent.get("extra_source_ids") or [])
or agent.get("retriever")
or agent.get("agent_type") == "workflow"
)
# Every agent is listed: one with no source skips retrieval and
# answers from the model and its tools, so it is still runnable.
list_agents = [
_format_agent_output(agent, pinned=str(agent["id"]) in pinned_ids)
for agent in agents
if _is_runnable(agent)
]
list_agents += [
_format_agent_output(
@@ -500,7 +494,6 @@ class GetAgents(Resource):
team_access=team_shared.get(str(agent["id"])),
)
for agent in shared_agents
if _is_runnable(agent)
]
except Exception as err:
current_app.logger.error(f"Error retrieving agents: {err}", exc_info=True)
@@ -661,20 +654,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 +1169,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 "
@@ -1497,6 +1456,33 @@ class DeleteAgent(Resource):
return make_response(jsonify({"id": pg_agent_id}), 200)
def _user_may_pin(conn, agent: dict, user_id: str, shared_with_me: set) -> bool:
"""Whether ``user_id`` may pin, and keep reading, ``agent``.
Pinning follows visibility: the owner, a team grantee, anyone who opened a
still-live share link, and the premade system templates. Without this gate a
pin is an unscoped read of any agent id.
Args:
conn: Open database connection.
agent: The agent row, needing at least ``id``, ``user_id`` and ``shared``.
user_id: The caller.
shared_with_me: Agent ids the caller reached through a share link.
Returns:
True if the caller is allowed to see the agent.
"""
owner_id = agent.get("user_id")
if owner_id == user_id:
return True
if owner_id in ("system", "__system__"):
return True
agent_id = str(agent["id"])
if agent.get("shared") and agent_id in shared_with_me:
return True
return can_access(conn, "agent", agent_id, user_id)
@agents_ns.route("/pinned_agents")
class PinnedAgents(Resource):
@api.doc(description="Get pinned agents for the user")
@@ -1510,11 +1496,13 @@ class PinnedAgents(Resource):
with db_session() as conn:
users_repo = UsersRepository(conn)
user_doc = users_repo.upsert(user_id)
pinned_ids = (
user_doc.get("agent_preferences", {}).get("pinned", [])
prefs = (
user_doc.get("agent_preferences", {})
if isinstance(user_doc.get("agent_preferences"), dict)
else []
else {}
)
pinned_ids = prefs.get("pinned", []) or []
shared_with_me = set(prefs.get("shared_with_me", []) or [])
if not pinned_ids:
return make_response(jsonify([]), 200)
@@ -1541,11 +1529,18 @@ class PinnedAgents(Resource):
if stale:
users_repo.remove_pinned_bulk(user_id, stale)
# A pin is not proof of access: a share or team grant can be
# revoked long after the agent was pinned, so re-check every
# row on read rather than trusting the stored id list.
pinned_agents = [
agent
for agent in pinned_agents
if _user_may_pin(conn, agent, user_id, shared_with_me)
]
list_pinned_agents = []
for agent in pinned_agents:
source_id = agent.get("source_id")
if not source_id and not agent.get("retriever"):
continue
list_pinned_agents.append(
{
"id": str(agent["id"]),
@@ -1571,7 +1566,7 @@ class PinnedAgents(Resource):
"last_used_at": agent.get("last_used_at", ""),
"key": (
f"{agent['key'][:4]}...{agent['key'][-4:]}"
if agent.get("key")
if agent.get("key") and agent.get("user_id") == user_id
else ""
),
"pinned": True,
@@ -1840,20 +1835,25 @@ class PinAgent(Resource):
)
try:
with db_session() as conn:
# Any user can pin any agent they can see — including
# shared ones. Use the non-user-scoped lookup so pins
# aren't restricted to owner-only.
# Any user can pin any agent they can *see* — owned, team-shared,
# reached through a share link, or a system template. The lookup
# is deliberately not owner-scoped, so visibility is checked
# separately below.
from sqlalchemy import text as _sql_text
if looks_like_uuid(agent_id):
agent_row = conn.execute(
_sql_text("SELECT id FROM agents WHERE id = CAST(:id AS uuid)"),
_sql_text(
"SELECT id, user_id, shared FROM agents "
"WHERE id = CAST(:id AS uuid)"
),
{"id": agent_id},
).fetchone()
else:
agent_row = conn.execute(
_sql_text(
"SELECT id FROM agents WHERE legacy_mongo_id = :id"
"SELECT id, user_id, shared FROM agents "
"WHERE legacy_mongo_id = :id"
),
{"id": agent_id},
).fetchone()
@@ -1866,15 +1866,28 @@ class PinAgent(Resource):
users_repo = UsersRepository(conn)
user_doc = users_repo.upsert(user_id)
pinned_list = (
user_doc.get("agent_preferences", {}).get("pinned", [])
prefs = (
user_doc.get("agent_preferences", {})
if isinstance(user_doc.get("agent_preferences"), dict)
else []
else {}
)
pinned_list = prefs.get("pinned", []) or []
if pg_agent_id in pinned_list:
# Unpinning stays open regardless of current access, or a
# revoked share would leave a pin the user can't clear.
users_repo.remove_pinned(user_id, pg_agent_id)
action = "unpinned"
else:
if not _user_may_pin(
conn,
dict(agent_row._mapping),
user_id,
set(prefs.get("shared_with_me", []) or []),
):
return make_response(
jsonify({"success": False, "message": "Agent not found"}),
404,
)
users_repo.add_pinned(user_id, pg_agent_id)
action = "pinned"
except Exception as err:
+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:
+142 -202
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,8 @@ 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');
const [selectedSourceIds, setSelectedSourceIds] = useState<Set<string>>(
new Set(),
);
@@ -199,11 +215,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 = () => {
@@ -220,15 +235,76 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
// source list; fall back to the owner-resolved name embedded in the agent
// payload (source_details) so a team member viewing a shared agent sees the
// 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,
const resolveSourceLabel = useCallback(
(id: string): string => {
const matchedDoc = sourceDocs?.find(
(source) => sourceItemId(source) === id,
);
if (matchedDoc?.name) return matchedDoc.name;
const detail = agent.source_details?.find((d) => d.id === id);
if (detail?.name) return detail.name;
return t('agents.form.externalKb');
},
[agent.source_details, sourceDocs, t],
);
const sourceItems = useMemo(() => {
const items = toSourcePickerItems(
sourceDocs,
{
own: t('agents.form.sourcePopup.groupOwn'),
team: t('agents.form.sourcePopup.groupTeam'),
},
SourceIcon,
);
if (matchedDoc?.name) return matchedDoc.name;
const detail = agent.source_details?.find((d) => d.id === id);
if (detail?.name) return detail.name;
return t('agents.form.externalKb');
// An attached source the caller can't list — an owner's private source on
// a team-shared agent — still needs a row of its own, or it reads as
// selected with nothing to switch it off.
const listed = new Set(items.map((item) => item.id));
const unlisted = Array.from(selectedSourceIds)
.filter((id) => !listed.has(id))
.map((id) => ({ id, label: resolveSourceLabel(id), icon: SourceIcon }));
return [...items, ...unlisted];
}, [resolveSourceLabel, selectedSourceIds, sourceDocs, t]);
const selectedSourceNames = useMemo(
() =>
Array.from(selectedSourceIds)
.map((id) => resolveSourceLabel(id))
.filter(Boolean),
[resolveSourceLabel, selectedSourceIds],
);
const sourceTriggerLabel =
selectedSourceIds.size === 0
? t('agents.form.placeholders.selectSources')
: selectedSourceIds.size === 1
? selectedSourceNames[0]
: t('conversation.sources.selectedCount', {
count: selectedSourceIds.size,
});
const handleUploadClick = () => {
setIsSourcePopupOpen(false);
setUploadModalState('ACTIVE');
};
// Select the source an upload started from this form created. The id comes
// from the upload itself, so a source that appeared meanwhile for another
// reason is never picked up by accident.
const handleUploadedSource = (sourceId?: string) => {
if (!sourceId) return;
setSelectedSourceIds((prev) => new Set([...prev, sourceId]));
};
// 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[]) => {
@@ -261,39 +337,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 +430,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,17 +670,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
validateAndSetFolder();
}, [folderIdFromUrl, agentFolders, token, dispatch]);
// Auto-select default source if none selected
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]);
useEffect(() => {
if ((mode === 'edit' || mode === 'draft') && agentId) {
const getAgent = async () => {
@@ -679,31 +680,8 @@ 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]));
}
const agentSourceIds = selectedSourceIdsFromAgent(data);
setSelectedSourceIds(new Set(agentSourceIds));
if (data.tool_details) setSelectedTools(data.tool_details);
if (data.status === 'draft') setEffectiveMode('draft');
@@ -715,16 +693,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
// Backfill required fields so older agents (created before
// agent_type / prompt_id / models existed) don't fail
// ``isPublishable()`` and leave Save permanently disabled.
// Normalise exactly as the source and model effects below will, or the
// form compares unequal to this snapshot and reports unsaved changes
// before the user has touched anything.
const agentModels: string[] = data.models || [];
const normalized = {
...data,
agent_type: data.agent_type || 'classic',
prompt_id: data.prompt_id || 'default',
retriever: data.retriever || 'classic',
retriever: agentSourceIds.length === 0 ? 'classic' : '',
chunks: data.chunks || '2',
tools: data.tools || [],
sources: data.sources || [],
models: data.models || [],
default_model_id: data.default_model_id || '',
...serializeAgentSources(agentSourceIds, sourceDocs),
models: agentModels,
default_model_id: agentModels.includes(data.default_model_id || '')
? data.default_model_id
: agentModels[0] || '',
config: data.config || {},
};
setAgent(normalized);
@@ -763,56 +747,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 +945,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 +1480,17 @@ 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={handleUploadedSource}
selectUploadedDoc={false}
/>
)}
<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:
+13 -9
View File
@@ -3,6 +3,7 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -55,6 +56,7 @@ import {
partitionAttachmentFiles,
} from '../constants/fileUpload';
import { UserToolType } from '../settings/types';
import { sourceItemId, toSourcePickerItems } from '../utils/sourceUtils';
import { isChatToolVisible } from '../utils/toolUtils';
const generateId = (): string =>
@@ -1459,15 +1461,17 @@ 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),
label: doc.name,
icon: SourceIcon,
}),
const sourceItems: MultiSelectPopoverItem[] = useMemo(
() =>
toSourcePickerItems(
sourceDocs,
{
own: t('conversation.sources.groupOwn'),
team: t('conversation.sources.groupTeam'),
},
SourceIcon,
),
[sourceDocs, t],
);
const selectedSourceIds = (
@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
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">
<Link
to="/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" />
</Link>
<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);
}
+8 -2
View File
@@ -1088,7 +1088,10 @@
"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",
"groupOwn": "Deine Quellen",
"groupTeam": "Mit dem Team geteilt"
},
"attachments": {
"attach": "Anhängen",
@@ -1248,7 +1251,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",
+8 -2
View File
@@ -1093,7 +1093,10 @@
"text": "Choose Your Sources",
"link": "Source link",
"view_more": "{{count}} more sources",
"noSourcesAvailable": "No sources available"
"noSourcesAvailable": "No sources available",
"selectedCount": "{{count}} sources selected",
"groupOwn": "Your sources",
"groupTeam": "Shared with team"
},
"attachments": {
"attach": "Attach",
@@ -1258,7 +1261,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",
+8 -2
View File
@@ -1088,7 +1088,10 @@
"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",
"groupOwn": "Tus fuentes",
"groupTeam": "Compartidas con el equipo"
},
"attachments": {
"attach": "Adjuntar",
@@ -1248,7 +1251,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",
+8 -2
View File
@@ -1088,7 +1088,10 @@
"text": "ソーステキスト",
"link": "ソースリンク",
"view_more": "さらに{{count}}個のソース",
"noSourcesAvailable": "利用可能なソースがありません"
"noSourcesAvailable": "利用可能なソースがありません",
"selectedCount": "{{count}} 件のソースを選択中",
"groupOwn": "自分のソース",
"groupTeam": "チームと共有"
},
"attachments": {
"attach": "添付",
@@ -1248,7 +1251,10 @@
"sourcePopup": {
"title": "ソースを選択",
"searchPlaceholder": "ソースを検索...",
"noOptionsMessage": "利用可能なソースがありません"
"noOptionsMessage": "利用可能なソースがありません",
"groupOwn": "自分のソース",
"groupTeam": "チームと共有",
"noSourceHint": "任意。ソースがない場合、エージェントはモデルとツールのみで回答します。"
},
"toolsPopup": {
"title": "ツールを選択",
+8 -2
View File
@@ -1088,7 +1088,10 @@
"text": "Выберите ваши источники",
"link": "Ссылка на источник",
"view_more": "ещё {{count}} источников",
"noSourcesAvailable": "Нет доступных источников"
"noSourcesAvailable": "Нет доступных источников",
"selectedCount": "Выбрано источников: {{count}}",
"groupOwn": "Ваши источники",
"groupTeam": "Общие с командой"
},
"attachments": {
"attach": "Прикрепить",
@@ -1248,7 +1251,10 @@
"sourcePopup": {
"title": "Выберите Источники",
"searchPlaceholder": "Поиск источников...",
"noOptionsMessage": "Нет доступных источников"
"noOptionsMessage": "Нет доступных источников",
"groupOwn": "Ваши источники",
"groupTeam": "Общие с командой",
"noSourceHint": "Необязательно. Без источника агент отвечает только на основе модели и своих инструментов."
},
"toolsPopup": {
"title": "Выберите Инструменты",
+8 -2
View File
@@ -1088,7 +1088,10 @@
"text": "來源文字",
"link": "來源連結",
"view_more": "查看更多 {{count}} 個來源",
"noSourcesAvailable": "沒有可用的來源"
"noSourcesAvailable": "沒有可用的來源",
"selectedCount": "已選擇 {{count}} 個來源",
"groupOwn": "我的來源",
"groupTeam": "團隊共享"
},
"attachments": {
"attach": "附件",
@@ -1248,7 +1251,10 @@
"sourcePopup": {
"title": "選擇來源",
"searchPlaceholder": "搜尋來源...",
"noOptionsMessage": "沒有可用的來源"
"noOptionsMessage": "沒有可用的來源",
"groupOwn": "我的來源",
"groupTeam": "團隊共享",
"noSourceHint": "可選。未選擇來源時,代理僅根據模型和工具作答。"
},
"toolsPopup": {
"title": "選擇工具",
+8 -2
View File
@@ -1088,7 +1088,10 @@
"text": "来源文本",
"link": "来源链接",
"view_more": "还有{{count}}个来源",
"noSourcesAvailable": "没有可用的来源"
"noSourcesAvailable": "没有可用的来源",
"selectedCount": "已选择 {{count}} 个来源",
"groupOwn": "我的来源",
"groupTeam": "团队共享"
},
"attachments": {
"attach": "附件",
@@ -1248,7 +1251,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,12 +1,16 @@
import { describe, expect, it } from 'vitest';
import { configureStore } from '@reduxjs/toolkit';
import { beforeEach, describe, expect, it } from 'vitest';
import type { Doc } from '../models/misc';
import type { RootState } from '../store';
import reducer, {
clearRoles,
prefListenerMiddleware,
selectIsAdmin,
selectRoles,
selectRolesResolved,
setRoles,
setSourceDocs,
} from './preferenceSlice';
const baseState = () => reducer(undefined, { type: '@@INIT' });
@@ -47,3 +51,54 @@ describe('roles selectors', () => {
expect(selectRolesResolved(stateWith(['user'], false))).toBe(false);
});
});
const sourceDoc = (overrides: Partial<Doc>): Doc => ({
name: 'doc',
date: '2026-01-01',
model: 'm',
...overrides,
});
const storeWithListener = () =>
configureStore({
reducer: { preference: reducer },
middleware: (getDefault) =>
getDefault().prepend(prefListenerMiddleware.middleware),
});
describe('setSourceDocs reconciler', () => {
beforeEach(() => {
localStorage.clear();
});
it('prunes a stored selection when the source list comes back empty', () => {
const stored = [sourceDoc({ id: 'gone', name: 'Deleted' })];
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify(stored));
const store = storeWithListener();
store.dispatch(setSourceDocs([]));
expect(store.getState().preference.selectedDocs).toEqual([]);
expect(localStorage.getItem('DocsGPTRecentDocs')).toBeNull();
});
it('keeps a stored selection that is still in the list', () => {
const kept = sourceDoc({ id: 'kept', name: 'Kept' });
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify([kept]));
const store = storeWithListener();
store.dispatch(setSourceDocs([kept, sourceDoc({ id: 'other' })]));
expect(store.getState().preference.selectedDocs).toEqual([kept]);
});
it('leaves the selection alone while the list has not loaded', () => {
const stored = [sourceDoc({ id: 'pending' })];
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify(stored));
const store = storeWithListener();
store.dispatch(setSourceDocs(null));
expect(localStorage.getItem('DocsGPTRecentDocs')).not.toBeNull();
});
});
+7 -18
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,
@@ -223,14 +214,12 @@ prefListenerMiddleware.startListening({
effect: (_action, listenerApi) => {
const state = listenerApi.getState() as RootState;
const sourceDocs = state.preference.sourceDocs;
if (sourceDocs && sourceDocs.length > 0) {
const validatedDocs = getLocalRecentDocs(sourceDocs);
if (validatedDocs !== null) {
listenerApi.dispatch(setSelectedDocs(validatedDocs));
} else {
listenerApi.dispatch(setSelectedDocs([]));
}
}
// ``null`` means the list hasn't loaded; an empty array is a real answer
// and still has to prune a stored selection, or a deleted last source
// stays checked forever.
if (!sourceDocs) return;
const validatedDocs = getLocalRecentDocs(sourceDocs);
listenerApi.dispatch(setSelectedDocs(validatedDocs ?? []));
},
});
+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,
+59 -23
View File
@@ -27,7 +27,6 @@ import { ActiveState, Doc } from '../models/misc';
import { getDocs } from '../preferences/preferenceApi';
import {
selectSelectedDocs,
selectSourceDocs,
selectToken,
setSelectedDocs,
setSourceDocs,
@@ -61,13 +60,21 @@ function Upload({
renderTab = null,
close,
onSuccessfulUpload = () => undefined,
selectUploadedDoc = true,
}: {
receivedFile: File[];
setModalState: (state: ActiveState) => void;
isOnboarding: boolean;
renderTab: string | null;
close: () => void;
onSuccessfulUpload?: () => void;
/** Fires once the upload is ingested, with the id of the source it created. */
onSuccessfulUpload?: (sourceId?: string) => void;
/**
* Whether a finished upload also becomes the chat's selected source. Callers
* that only need the new source's id (the agent builder) pass ``false`` so
* uploading never repoints the conversation the user left open.
*/
selectUploadedDoc?: boolean;
}) {
const token = useSelector(selectToken);
const selectedDocs = useSelector(selectSelectedDocs);
@@ -403,8 +410,6 @@ function Upload({
heading: schema.heading,
}));
const sourceDocs = useSelector(selectSourceDocs);
const resetUploaderState = useCallback(() => {
setIngestor({ type: null, name: '', config: {} });
setfiles([]);
@@ -430,6 +435,32 @@ function Upload({
[dispatch],
);
/** Re-read the source list into the store so a new source is pickable. */
const refreshSourceDocs = useCallback(
() =>
getDocs(token).then((docs) => {
dispatch(setSourceDocs(docs));
return docs;
}),
[dispatch, token],
);
/**
* Finish an upload that never got an ingest task (the wiki source is created
* synchronously). The list still has to be refreshed, or the caller selects
* an id no picker can render.
*/
const finishUntrackedUpload = useCallback(
(sourceId?: string) => {
refreshSourceDocs()
.catch((err) => {
console.error('Post-upload source-list refresh failed:', err);
})
.finally(() => onSuccessfulUpload?.(sourceId));
},
[onSuccessfulUpload, refreshSourceDocs],
);
/**
* Wait for the source.ingest.* SSE pipeline to flip this task into a
* terminal state, then run the post-completion side effects: refresh
@@ -442,22 +473,20 @@ function Upload({
(clientTaskId: string) => {
let handled = false;
const handleTerminal = (status: 'completed' | 'failed') => {
const handleTerminal = (
status: 'completed' | 'failed',
sourceId?: string,
) => {
if (handled) return;
handled = true;
if (status !== 'completed') return;
getDocs(token)
refreshSourceDocs()
.then((docs) => {
dispatch(setSourceDocs(docs));
if (Array.isArray(docs)) {
const existingDocIds = new Set(
(Array.isArray(sourceDocs) ? sourceDocs : [])
.map((doc: Doc) => doc?.id)
.filter((id): id is string => Boolean(id)),
);
const newDoc = docs.find(
(doc: Doc) => doc.id && !existingDocIds.has(doc.id),
);
if (selectUploadedDoc && Array.isArray(docs) && sourceId) {
// Match the id this upload returned. Diffing against the list as
// it looked before would pick up any source that appeared
// meanwhile — another upload finishing, or a new team share.
const newDoc = docs.find((doc: Doc) => doc.id === sourceId);
if (newDoc) {
// If only one doc is selected, replace it completely
// If multiple docs are selected, append the new doc
@@ -468,7 +497,7 @@ function Upload({
}
}
}
onSuccessfulUpload?.();
onSuccessfulUpload?.(sourceId);
})
.catch((err) => {
console.error(
@@ -483,7 +512,7 @@ function Upload({
const task = state.upload.tasks.find((t) => t.id === clientTaskId);
if (!task) return false;
if (task.status === 'completed' || task.status === 'failed') {
handleTerminal(task.status);
handleTerminal(task.status, task.sourceId);
return true;
}
// Recover from the race where the terminal SSE landed before
@@ -494,7 +523,7 @@ function Upload({
for (const event of state.notifications.recentEvents) {
if (event.scope?.id !== task.sourceId) continue;
if (event.type === 'source.ingest.completed') {
handleTerminal('completed');
handleTerminal('completed', task.sourceId);
return true;
}
if (event.type === 'source.ingest.failed') {
@@ -536,7 +565,14 @@ function Upload({
}
});
},
[dispatch, onSuccessfulUpload, selectedDocs, sourceDocs, store, token],
[
dispatch,
onSuccessfulUpload,
refreshSourceDocs,
selectedDocs,
selectUploadedDoc,
store,
],
);
const onDrop = useCallback(
@@ -626,7 +662,7 @@ function Upload({
updates: { status: 'completed', progress: 100 },
}),
);
onSuccessfulUpload?.();
finishUntrackedUpload(parsed.source_id);
}
} catch (error) {
handleTaskFailure(clientTaskId);
@@ -762,7 +798,7 @@ function Upload({
updates: { status: 'completed', progress: 100 },
}),
);
onSuccessfulUpload?.();
finishUntrackedUpload(response.source_id);
}
} catch (error) {
handleTaskFailure(clientTaskId);
@@ -813,7 +849,7 @@ function Upload({
},
}),
);
onSuccessfulUpload?.();
finishUntrackedUpload(data.source_id);
})
.catch(() => handleTaskFailure(clientTaskId));
};
+150
View File
@@ -0,0 +1,150 @@
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('merges the single source with the sources list', () => {
expect(
selectedSourceIdsFromAgent({ source: 'x', sources: ['a', 'b'] }),
).toEqual(['x', 'a', 'b']);
});
it('keeps a source named in both fields once', () => {
expect(
selectedSourceIdsFromAgent({ source: 'a', 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',
]);
});
});
+75
View File
@@ -0,0 +1,75 @@
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: the legacy single ``source`` and the
* ``sources`` list merged, because retrieval reads both. Preferring one over
* the other would drop the omitted field on the next save. The legacy
* ``"default"`` placeholder means no source.
*/
export function selectedSourceIdsFromAgent(agent: {
source?: string;
sources?: string[];
}): string[] {
const ids = [
...(agent.source ? [agent.source] : []),
...(agent.sources ?? []),
];
return Array.from(new Set(ids)).filter(
(id) => Boolean(id) && id !== 'default',
);
}
+244 -18
View File
@@ -128,6 +128,23 @@ class TestGetAgents:
names = [a["name"] for a in response.json]
assert "B1" in names and "B2" in names
def test_lists_source_less_agent_without_retriever(self, app, pg_conn):
"""A published agent with no source and no retriever skips retrieval
at run time, so the list must not hide it."""
from docsgpt.api.user.agents.routes import GetAgents
user = "u-list-no-source"
_seed_agent(
pg_conn, user=user, name="bare", with_source=False, retriever="",
)
with _patch_db(pg_conn), app.test_request_context("/api/get_agents"):
from flask import request
request.decoded_token = {"sub": user}
response = GetAgents().get()
assert response.status_code == 200
assert "bare" in [a["name"] for a in response.json]
def test_db_error_returns_400(self, app):
from docsgpt.api.user.agents.routes import GetAgents
@@ -782,12 +799,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 +842,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 +879,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 +1094,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
@@ -1438,6 +1503,138 @@ class TestPinAgentMore:
assert response.status_code == 500
class TestPinAccessControl:
"""Pinning must follow visibility — it is a read of the agent."""
def _pin(self, app, pg_conn, agent_id, user):
from docsgpt.api.user.agents.routes import PinAgent
with _patch_db(pg_conn), app.test_request_context(
f"/api/pin_agent?id={agent_id}", method="POST"
):
from flask import request
request.decoded_token = {"sub": user}
return PinAgent().post()
def _pinned(self, app, pg_conn, user):
from docsgpt.api.user.agents.routes import PinnedAgents
with _patch_db(pg_conn), app.test_request_context("/api/pinned_agents"):
from flask import request
request.decoded_token = {"sub": user}
return PinnedAgents().get()
def test_stranger_cannot_pin_another_users_agent(self, app, pg_conn):
from docsgpt.storage.db.repositories.users import UsersRepository
owner, stranger = "u-pin-owner", "u-pin-stranger"
agent = _seed_agent(pg_conn, user=owner, name="Private")
response = self._pin(app, pg_conn, str(agent["id"]), stranger)
assert response.status_code == 404
prefs = UsersRepository(pg_conn).upsert(stranger).get(
"agent_preferences"
) or {}
assert prefs.get("pinned", []) == []
def test_stranger_pinned_list_stays_empty(self, app, pg_conn):
"""Even a pin forced into the prefs must not leak the agent."""
from docsgpt.storage.db.repositories.users import UsersRepository
owner, stranger = "u-pin-owner2", "u-pin-stranger2"
agent = _seed_agent(pg_conn, user=owner, name="Private")
users_repo = UsersRepository(pg_conn)
users_repo.upsert(stranger)
users_repo.add_pinned(stranger, str(agent["id"]))
response = self._pinned(app, pg_conn, stranger)
assert response.status_code == 200
assert response.json == []
def test_share_link_recipient_can_pin(self, app, pg_conn):
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.users import UsersRepository
owner, recipient = "u-pin-sharer", "u-pin-recipient"
agent = _seed_agent(pg_conn, user=owner, name="Shared")
agent_id = str(agent["id"])
AgentsRepository(pg_conn).update(agent_id, owner, {"shared": True})
users_repo = UsersRepository(pg_conn)
users_repo.upsert(recipient)
users_repo.add_shared(recipient, agent_id)
assert self._pin(app, pg_conn, agent_id, recipient).status_code == 200
listed = self._pinned(app, pg_conn, recipient)
assert [a["id"] for a in listed.json] == [agent_id]
def test_revoked_share_drops_out_of_the_pinned_list(self, app, pg_conn):
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.users import UsersRepository
owner, recipient = "u-pin-revoke-owner", "u-pin-revoke-recipient"
agent = _seed_agent(pg_conn, user=owner, name="Shared")
agent_id = str(agent["id"])
repo = AgentsRepository(pg_conn)
repo.update(agent_id, owner, {"shared": True})
users_repo = UsersRepository(pg_conn)
users_repo.upsert(recipient)
users_repo.add_shared(recipient, agent_id)
self._pin(app, pg_conn, agent_id, recipient)
repo.update(agent_id, owner, {"shared": False})
assert self._pinned(app, pg_conn, recipient).json == []
def test_team_grantee_can_pin_but_gets_no_key(self, app, pg_conn):
from docsgpt.storage.db.repositories.agents import AgentsRepository
owner, member = "u-pin-team-owner", "u-pin-team-member"
agent = _seed_agent(pg_conn, user=owner, name="Team")
agent_id = str(agent["id"])
AgentsRepository(pg_conn).update(agent_id, owner, {"key": "abcd1234wxyz"})
with patch(
"docsgpt.api.user.agents.routes.can_access", return_value=True
):
assert self._pin(app, pg_conn, agent_id, member).status_code == 200
listed = self._pinned(app, pg_conn, member)
assert [a["id"] for a in listed.json] == [agent_id]
assert listed.json[0]["key"] == ""
def test_owner_still_sees_the_masked_key(self, app, pg_conn):
from docsgpt.storage.db.repositories.agents import AgentsRepository
owner = "u-pin-key-owner"
agent = _seed_agent(pg_conn, user=owner, name="Mine")
agent_id = str(agent["id"])
AgentsRepository(pg_conn).update(agent_id, owner, {"key": "abcd1234wxyz"})
self._pin(app, pg_conn, agent_id, owner)
assert self._pinned(app, pg_conn, owner).json[0]["key"] == "abcd...wxyz"
def test_unpin_survives_lost_access(self, app, pg_conn):
"""A revoked share must not strand a pin the user can't remove."""
from docsgpt.storage.db.repositories.users import UsersRepository
owner, recipient = "u-unpin-owner", "u-unpin-recipient"
agent = _seed_agent(pg_conn, user=owner, name="Gone")
agent_id = str(agent["id"])
users_repo = UsersRepository(pg_conn)
users_repo.upsert(recipient)
users_repo.add_pinned(recipient, agent_id)
response = self._pin(app, pg_conn, agent_id, recipient)
assert response.status_code == 200
assert response.json["action"] == "unpinned"
prefs = users_repo.upsert(recipient).get("agent_preferences") or {}
assert agent_id not in prefs.get("pinned", [])
class TestRegenerateAgentKey:
def _seed_published_with_key(self, pg_conn, user, key):
from docsgpt.storage.db.repositories.agents import AgentsRepository
@@ -1645,6 +1842,35 @@ class TestPinnedAgentsListing:
assert len(response.json) == 1
assert response.json[0]["pinned"] is True
def test_lists_pinned_source_less_agent_without_retriever(
self, app, pg_conn,
):
"""Pinning follows the main list: no source and no retriever is
still a runnable agent and must not be dropped."""
from docsgpt.api.user.agents.routes import PinAgent, PinnedAgents
user = "u-pinned-no-source"
agent = _seed_agent(
pg_conn, user=user, with_source=False, retriever="",
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/pin_agent?id={agent['id']}", method="POST"
):
from flask import request
request.decoded_token = {"sub": user}
PinAgent().post()
with _patch_db(pg_conn), app.test_request_context(
"/api/pinned_agents"
):
from flask import request
request.decoded_token = {"sub": user}
response = PinnedAgents().get()
assert response.status_code == 200
assert [a["id"] for a in response.json] == [str(agent["id"])]
def test_pinned_db_error_returns_400(self, app):
from docsgpt.api.user.agents.routes import PinnedAgents
+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
+6 -6
View File
@@ -341,8 +341,9 @@ class TestSeederExtra:
class TestAgentsRoutesRemainingGaps:
def test_get_agents_filters_out_incomplete(self, app, pg_conn):
"""Agents missing both source and retriever are filtered from the list."""
def test_get_agents_lists_source_less_agents(self, app, pg_conn):
"""An agent with neither source nor retriever is still listed: it
skips retrieval at run time and answers from the model and tools."""
from docsgpt.api.user.agents.routes import GetAgents
from docsgpt.storage.db.repositories.agents import AgentsRepository
@@ -351,12 +352,11 @@ class TestAgentsRoutesRemainingGaps:
yield pg_conn
user = "u-filter"
# Complete agent: has retriever
AgentsRepository(pg_conn).create(
user, "ok", "published", retriever="classic",
)
# Incomplete agent: no source, no retriever, not workflow type
AgentsRepository(pg_conn).create(user, "bad", "published")
# No source, no retriever, not a workflow agent.
AgentsRepository(pg_conn).create(user, "bare", "published")
with patch(
"docsgpt.api.user.agents.routes.db_session", _yield
@@ -369,4 +369,4 @@ class TestAgentsRoutesRemainingGaps:
assert response.status_code == 200
names = [a["name"] for a in response.json]
assert "ok" in names
assert "bad" not in names
assert "bare" in names