mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 22:14:05 +00:00
feat: better tags and dropdowns for sharing
This commit is contained in:
1 parent
b97a12870c
commit
d37e6bec51
8 files changed
+102
-4
No files matched your search
@@ -132,8 +132,13 @@ class PaginatedSources(Resource):
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
repo = SourcesRepository(conn)
|
||||
# Include sources shared with the caller's teams so the settings
|
||||
# list matches the /api/sources dropdown. Unioned into the query
|
||||
# by id so count/sort/search/pagination stay correct.
|
||||
team_shared = visible_with_access(conn, user, "source")
|
||||
extra_ids = list(team_shared.keys())
|
||||
total_documents = repo.count_for_user(
|
||||
user, search_term=search_term,
|
||||
user, search_term=search_term, extra_ids=extra_ids,
|
||||
)
|
||||
# Prior in-Python implementation returned ``totalPages = 1``
|
||||
# for empty result sets (``max(1, ceil(0/rows))``); we
|
||||
@@ -148,11 +153,15 @@ class PaginatedSources(Resource):
|
||||
search_term=search_term,
|
||||
sort_field=sort_field,
|
||||
sort_order=sort_order,
|
||||
extra_ids=extra_ids,
|
||||
)
|
||||
|
||||
paginated_docs = []
|
||||
for doc in window:
|
||||
provider = _get_provider_from_remote_data(doc.get("remote_data"))
|
||||
# Owner vs team-shared: a row in the window is the caller's own
|
||||
# when its user_id matches; otherwise it arrived via extra_ids.
|
||||
owned = str(doc.get("user_id")) == str(user)
|
||||
paginated_docs.append(
|
||||
{
|
||||
"id": str(doc["id"]),
|
||||
@@ -168,6 +177,10 @@ class PaginatedSources(Resource):
|
||||
"type": doc.get("type", "file"),
|
||||
# Derived in SourcesRepository.list_for_user.
|
||||
"ingestStatus": doc.get("ingest_status"),
|
||||
"ownership": "user" if owned else "team",
|
||||
"team_access": (
|
||||
None if owned else team_shared.get(str(doc["id"]))
|
||||
),
|
||||
}
|
||||
)
|
||||
response = {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import case, Connection, func, select, text
|
||||
from sqlalchemy import case, Connection, func, or_, select, text
|
||||
|
||||
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
|
||||
from application.storage.db.models import ingest_chunk_progress_table, sources_table
|
||||
@@ -24,6 +25,37 @@ _ALLOWED_COLUMNS = _SCALAR_COLUMNS | _JSONB_COLUMNS
|
||||
_SORTABLE_COLUMNS = {"date", "name", "tokens", "type", "created_at", "updated_at"}
|
||||
|
||||
|
||||
def _coerce_uuid_ids(extra_ids: Optional[list]) -> list:
|
||||
"""Coerce id strings to ``uuid.UUID`` for binding against UUID columns.
|
||||
|
||||
Non-UUID-looking ids (e.g. legacy/synthetic) are dropped — they can never
|
||||
match a ``sources.id`` value. Used to OR team-shared source ids into the
|
||||
owner-scoped queries.
|
||||
"""
|
||||
if not extra_ids:
|
||||
return []
|
||||
out: list = []
|
||||
for raw in extra_ids:
|
||||
s = str(raw)
|
||||
if looks_like_uuid(s):
|
||||
out.append(uuid.UUID(s))
|
||||
return out
|
||||
|
||||
|
||||
def _owned_or_shared_scope(user_id: str, extra_ids: Optional[list]):
|
||||
"""WHERE predicate matching the owner's rows plus any ``extra_ids``.
|
||||
|
||||
Lets the paginated/count queries include team-shared sources (passed by id)
|
||||
alongside owned ones in a single query, so count/sort/search/pagination
|
||||
stay correct across the union.
|
||||
"""
|
||||
scope = sources_table.c.user_id == user_id
|
||||
ids = _coerce_uuid_ids(extra_ids)
|
||||
if ids:
|
||||
scope = or_(scope, sources_table.c.id.in_(ids))
|
||||
return scope
|
||||
|
||||
|
||||
def _escape_like(pattern: str) -> str:
|
||||
"""Escape wildcards so a user-supplied substring is matched literally.
|
||||
|
||||
@@ -228,6 +260,7 @@ class SourcesRepository:
|
||||
search_term: Optional[str] = None,
|
||||
sort_field: str = "created_at",
|
||||
sort_order: str = "desc",
|
||||
extra_ids: Optional[list] = None,
|
||||
) -> list[dict]:
|
||||
"""Return sources owned by ``user_id``, paginated and optionally filtered.
|
||||
|
||||
@@ -269,7 +302,7 @@ class SourcesRepository:
|
||||
== sources_table.c.id,
|
||||
)
|
||||
)
|
||||
.where(sources_table.c.user_id == user_id)
|
||||
.where(_owned_or_shared_scope(user_id, extra_ids))
|
||||
)
|
||||
if search_term:
|
||||
stmt = stmt.where(
|
||||
@@ -298,6 +331,7 @@ class SourcesRepository:
|
||||
user_id: str,
|
||||
*,
|
||||
search_term: Optional[str] = None,
|
||||
extra_ids: Optional[list] = None,
|
||||
) -> int:
|
||||
"""Return the count of rows that ``list_for_user`` would produce.
|
||||
|
||||
@@ -315,7 +349,7 @@ class SourcesRepository:
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(sources_table)
|
||||
.where(sources_table.c.user_id == user_id)
|
||||
.where(_owned_or_shared_scope(user_id, extra_ids))
|
||||
)
|
||||
if search_term:
|
||||
stmt = stmt.where(
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
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 {
|
||||
@@ -117,6 +118,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
const [deleteConfirmation, setDeleteConfirmation] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [agentDetails, setAgentDetails] = useState<ActiveState>('INACTIVE');
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [addPromptModal, setAddPromptModal] = useState<ActiveState>('INACTIVE');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [draftLoading, setDraftLoading] = useState(false);
|
||||
@@ -863,6 +865,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<DropdownMenuItem onSelect={() => setAgentDetails('ACTIVE')}>
|
||||
{t('agents.form.buttons.accessDetails')}
|
||||
</DropdownMenuItem>
|
||||
{/* Sharing is owner-only — hidden for agents shared into the
|
||||
workspace by a team (ownership === 'team'). */}
|
||||
{agent.ownership !== 'team' && agent.id && (
|
||||
<DropdownMenuItem onSelect={() => setShareModalOpen(true)}>
|
||||
{t('agents.shareWithTeam')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -1465,6 +1474,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
modalState={agentDetails}
|
||||
setModalState={setAgentDetails}
|
||||
/>
|
||||
{shareModalOpen && agent.id && (
|
||||
<ShareToTeamModal
|
||||
resourceType="agent"
|
||||
resourceId={agent.id}
|
||||
resourceName={agent.name}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<AddPromptModal
|
||||
prompts={prompts}
|
||||
isOpen={addPromptModal}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"emailUs": "Email us",
|
||||
"documentation": "Documentation",
|
||||
"manageAgents": "Manage Agents",
|
||||
"teamAccess": {
|
||||
"editor": "Editor",
|
||||
"viewer": "Viewer"
|
||||
},
|
||||
"demo": [
|
||||
{
|
||||
"header": "Learn about DocsGPT",
|
||||
|
||||
@@ -19,6 +19,9 @@ export type Doc = {
|
||||
// Whether the current user owns this source ('user') or only has access to
|
||||
// it via a team share ('team'). Owner-only actions are gated on 'user'.
|
||||
ownership?: 'user' | 'team';
|
||||
// Access level when shared via a team: 'viewer' (read-only) or 'editor'
|
||||
// (full write). Null/absent for sources the caller owns.
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
};
|
||||
|
||||
export type GetDocsResponse = {
|
||||
|
||||
@@ -611,6 +611,18 @@ export default function Sources({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start justify-start gap-1">
|
||||
{document.ownership === 'team' && (
|
||||
<span className="bg-muted-foreground/10 text-muted-foreground flex items-center gap-1 rounded-full px-2 py-0.5 text-xs leading-[16px] font-medium">
|
||||
<Users
|
||||
size={11}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{document.team_access === 'editor'
|
||||
? t('teamAccess.editor')
|
||||
: t('teamAccess.viewer')}
|
||||
</span>
|
||||
)}
|
||||
{document.ingestStatus === 'failed' && (
|
||||
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs leading-[16px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{t('settings.sources.ingestFailed')}
|
||||
|
||||
@@ -451,6 +451,18 @@ export default function Tools() {
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{tool.ownership === 'team' && (
|
||||
<span className="bg-muted-foreground/10 text-muted-foreground inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs leading-none font-medium">
|
||||
<Users
|
||||
size={11}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{tool.team_access === 'editor'
|
||||
? t('teamAccess.editor')
|
||||
: t('teamAccess.viewer')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[9px]">
|
||||
<p
|
||||
|
||||
@@ -90,6 +90,9 @@ export type UserToolType = {
|
||||
// Whether the current user owns this tool ('user') or only has access to
|
||||
// it via a team share ('team'). Owner-only actions are gated on 'user'.
|
||||
ownership?: 'user' | 'team';
|
||||
// Access level when shared via a team: 'viewer' (use) or 'editor' (edit
|
||||
// actions; secrets stay owner-only). Null/absent for tools the caller owns.
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
config: {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user