mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 12:11:45 +00:00
158 files changed
+16504
-1895
No files matched your search
@@ -158,6 +158,10 @@ frontend/yarn-debug.log*
|
||||
frontend/yarn-error.log*
|
||||
frontend/pnpm-debug.log*
|
||||
frontend/lerna-debug.log*
|
||||
# tsc build output (composite tsconfig.node.json); Vite would load vite.config.js before .ts
|
||||
frontend/*.tsbuildinfo
|
||||
frontend/vite.config.js
|
||||
frontend/vite.config.d.ts
|
||||
|
||||
# Keep frontend utility helpers tracked (overrides global lib/ ignore)
|
||||
!frontend/src/lib/
|
||||
|
||||
@@ -369,6 +369,11 @@ def resolve_tool_by_id(
|
||||
|
||||
Dual-registered tools (e.g. ``scheduler``) get both flags on the resolved
|
||||
row so callers can branch on either path without losing the discriminator.
|
||||
|
||||
A ``user_tools`` row resolves when ``user`` owns it or a team grant gives
|
||||
``user`` the ``use_in_own`` action on it (checked live, so a revoked grant
|
||||
drops the tool on the next run). The returned row is the owner's, so its
|
||||
``user_id`` is the credential owner.
|
||||
"""
|
||||
default_name = default_tool_name_for_id(tool_id)
|
||||
builtin_name = builtin_agent_tool_name_for_id(tool_id)
|
||||
@@ -382,4 +387,24 @@ def resolve_tool_by_id(
|
||||
return synthesize_builtin_agent_tool(builtin_name)
|
||||
if user_tools_repo is None or not user:
|
||||
return None
|
||||
return user_tools_repo.get_any(str(tool_id), user)
|
||||
row = user_tools_repo.get_any(str(tool_id), user)
|
||||
if row is not None:
|
||||
return row
|
||||
return _resolve_shared_tool(str(tool_id), user, user_tools_repo)
|
||||
|
||||
|
||||
def _resolve_shared_tool(tool_id: str, user: str, user_tools_repo: Any) -> Optional[Dict[str, Any]]:
|
||||
"""The owner's row for a team-shared tool ``user`` may use in their own agents."""
|
||||
conn = getattr(user_tools_repo, "_conn", None)
|
||||
if conn is None:
|
||||
return None
|
||||
# Lazy: resource_access lives under docsgpt.api, whose package import
|
||||
# pulls in every route module (and those import this module).
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
|
||||
ra = resolve(conn, "tool", tool_id, user)
|
||||
if ra is None or ra.access == "owner" or not ra.can("use_in_own"):
|
||||
if ra is not None:
|
||||
logger.info("shared tool %s not usable by %s (access=%s); dropped", tool_id, user, ra.access)
|
||||
return None
|
||||
return user_tools_repo.get_any(ra.resource_id, ra.owner_id)
|
||||
@@ -14,7 +14,11 @@ from docsgpt.api.answer.services.prompt_renderer import (
|
||||
prompt_embeds_documents,
|
||||
resolve_prompt_skeleton,
|
||||
)
|
||||
from docsgpt.api.answer.services.stream_processor import get_prompt
|
||||
from docsgpt.api.answer.services.stream_processor import (
|
||||
authorized_prompt_id,
|
||||
get_prompt,
|
||||
)
|
||||
from docsgpt.api.user.resource_access import ref_principal
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.quotas.service import QuotaExceededError, QuotaService
|
||||
from docsgpt.retriever.retriever_creator import RetrieverCreator
|
||||
@@ -148,7 +152,13 @@ def _run_agent_headless(
|
||||
source_active: Any = {}
|
||||
if source_id:
|
||||
with db_readonly() as conn:
|
||||
src_row = SourcesRepository(conn).get(str(source_id), owner)
|
||||
# Owned or team-shared to the owner, else attached by an editor
|
||||
# who still qualifies; read unscoped once authorized.
|
||||
src_row = (
|
||||
SourcesRepository(conn).get_by_id(str(source_id))
|
||||
if ref_principal(conn, "agent", agent_config, "source", str(source_id))
|
||||
else None
|
||||
)
|
||||
if src_row:
|
||||
source_active = str(src_row["id"])
|
||||
retriever_kind = src_row.get("retriever", retriever_kind)
|
||||
@@ -156,7 +166,9 @@ def _run_agent_headless(
|
||||
# ``chunks=0`` switches retrieval off; only a missing value takes the default.
|
||||
raw_chunks = agent_config.get("chunks")
|
||||
chunks = 6 if raw_chunks in (None, "") else int(raw_chunks)
|
||||
prompt_id = agent_config.get("prompt_id", "default")
|
||||
# Runs as the owner: a prompt they can no longer use (revoked grant,
|
||||
# deleted) falls back to the default instead of rendering anyway.
|
||||
prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner, agent_config)
|
||||
user_api_key = agent_config.get("key")
|
||||
agent_id = _resolve_agent_id(agent_config)
|
||||
agent_type = agent_config.get("agent_type", "classic")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
@@ -29,6 +30,45 @@ from docsgpt.storage.db.session import db_readonly, db_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: ``user_tools.config`` key holding an api_tool's encrypted header /
|
||||
#: query-param values: ``{action: {section: {param: value}}}``, keyed by the
|
||||
#: tool owner's id. The plaintext ``value`` of those entries is kept empty.
|
||||
API_TOOL_SECRETS_KEY = "encrypted_action_secrets"
|
||||
API_TOOL_SECRET_SECTIONS = ("headers", "query_params")
|
||||
|
||||
|
||||
def api_tool_action_with_secrets(tool_data: Dict, action_name: str, fallback_owner: Optional[str] = None) -> Dict:
|
||||
"""An api_tool action definition with its stored secret values merged back.
|
||||
|
||||
Secrets are decrypted with the tool row's ``user_id`` (the owner), never
|
||||
the invoker's id. Legacy rows that still hold plaintext values need no
|
||||
merge and are returned as stored.
|
||||
|
||||
Args:
|
||||
tool_data: The ``user_tools`` row.
|
||||
action_name: The action key in ``config["actions"]``.
|
||||
fallback_owner: Used only when the row carries no ``user_id``.
|
||||
|
||||
Returns:
|
||||
A deep copy of the action with header / query-param values filled in.
|
||||
"""
|
||||
config = tool_data.get("config") or {}
|
||||
action = copy.deepcopy(config["actions"][action_name])
|
||||
blob = config.get(API_TOOL_SECRETS_KEY)
|
||||
owner = tool_data.get("user_id") or fallback_owner
|
||||
if not blob or not owner:
|
||||
return action
|
||||
secrets = decrypt_credentials(blob, owner).get(action_name) or {}
|
||||
for section in API_TOOL_SECRET_SECTIONS:
|
||||
block = action.get(section)
|
||||
props = block.get("properties") if isinstance(block, dict) else None
|
||||
if not isinstance(props, dict):
|
||||
continue
|
||||
for param, value in (secrets.get(section) or {}).items():
|
||||
if isinstance(props.get(param), dict):
|
||||
props[param]["value"] = value
|
||||
return action
|
||||
|
||||
|
||||
def record_tool_span_start(call: Any, **attributes: Any) -> Any:
|
||||
"""Open an ``execute_tool`` span for ``call`` (no-op without an active trace)."""
|
||||
@@ -465,6 +505,9 @@ class ToolExecutor:
|
||||
# get_tools() resolves EXACTLY these ids — builtin synthetic ids and
|
||||
# user_tools rows alike — with no defaults mixed in. None = unscoped.
|
||||
self.allowed_tool_ids: Optional[List[str]] = None
|
||||
# Tool id -> the user to resolve it as, for a workflow node's tools
|
||||
# sponsored by an editor (see resource_access.active_sponsor).
|
||||
self.tool_principals: Dict[str, str] = {}
|
||||
self.conversation_id: Optional[str] = None
|
||||
# Set by the workflow engine for agent nodes so run-scoped tools
|
||||
# (artifact_generator / code_executor) address artifacts by the
|
||||
@@ -527,6 +570,8 @@ class ToolExecutor:
|
||||
tools: List[Dict] = []
|
||||
for tid in tool_ids:
|
||||
row = resolve_tool_by_id(tid, self.user, user_tools_repo=tools_repo)
|
||||
if row is None and str(tid) in self.tool_principals:
|
||||
row = resolve_tool_by_id(tid, self.tool_principals[str(tid)], user_tools_repo=tools_repo)
|
||||
if row is None:
|
||||
logger.warning("tool id %s did not resolve; dropped from scoped toolset", tid)
|
||||
continue
|
||||
@@ -548,6 +593,15 @@ class ToolExecutor:
|
||||
tools: List[Dict] = []
|
||||
for tid in tool_ids:
|
||||
row = resolve_tool_by_id(tid, owner, user_tools_repo=tools_repo)
|
||||
if row is None:
|
||||
# A tool the owner can't use runs as the editor who
|
||||
# attached it, while they still qualify.
|
||||
# Lazy: docsgpt.api's package import pulls in every route module.
|
||||
from docsgpt.api.user.resource_access import active_sponsor
|
||||
|
||||
sponsor = active_sponsor(conn, "agent", agent_data, "tool", str(tid))
|
||||
if sponsor:
|
||||
row = resolve_tool_by_id(tid, sponsor, user_tools_repo=tools_repo)
|
||||
if row is None:
|
||||
continue
|
||||
# Workflow-only builtins (read_document) never resolve for a
|
||||
@@ -565,6 +619,7 @@ class ToolExecutor:
|
||||
"""Resolve an agentless chat's toolset: explicit user tools plus defaults."""
|
||||
with db_readonly() as conn:
|
||||
user_tools = UserToolsRepository(conn).list_active_for_user(user)
|
||||
user_tools.extend(self._shared_in_chat_tools(conn, user))
|
||||
user_doc = UsersRepository(conn).get(user) if self.agent_id is None else None
|
||||
# Headless agentless runs (e.g. scheduled fire) drop chat-only
|
||||
# tools (``scheduler``) from explicit user_tools too.
|
||||
@@ -581,6 +636,32 @@ class ToolExecutor:
|
||||
tools[str(default_row["id"])] = default_row
|
||||
return tools
|
||||
|
||||
@staticmethod
|
||||
def _shared_in_chat_tools(conn, user: str) -> List[Dict]:
|
||||
"""Team-shared tools the user switched into their chats and may still use.
|
||||
|
||||
The grant (and the ``use_in_own`` switch) is re-checked on every call;
|
||||
a revoked tool is dropped silently. Rows are the owner's, so their
|
||||
credentials decrypt with the owner's id.
|
||||
"""
|
||||
from docsgpt.storage.db.repositories.user_tool_preferences import (
|
||||
UserToolPreferencesRepository,
|
||||
)
|
||||
|
||||
tool_ids = UserToolPreferencesRepository(conn).list_in_chat_tool_ids(user)
|
||||
if not tool_ids:
|
||||
return []
|
||||
repo = UserToolsRepository(conn)
|
||||
rows: List[Dict] = []
|
||||
for tid in tool_ids:
|
||||
row = resolve_tool_by_id(tid, user, user_tools_repo=repo)
|
||||
if row is None or row.get("user_id") == user:
|
||||
if row is None:
|
||||
logger.info("in-chat shared tool %s no longer usable by %s; dropped", tid, user)
|
||||
continue
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def merge_client_tools(self, tools_dict: Dict, client_tools: List[Dict]) -> Dict:
|
||||
"""Merge client-provided tool definitions into tools_dict.
|
||||
|
||||
@@ -1261,7 +1342,7 @@ class ToolExecutor:
|
||||
return error_message, call_id
|
||||
yield {"type": "tool_call", "data": {**tool_call_data, "status": "pending"}}
|
||||
action_data = (
|
||||
tool_data["config"]["actions"][action_name]
|
||||
api_tool_action_with_secrets(tool_data, action_name, self.user)
|
||||
if tool_data["name"] == "api_tool"
|
||||
else next(action for action in tool_data["actions"] if action["name"] == action_name)
|
||||
)
|
||||
@@ -1528,10 +1609,13 @@ class ToolExecutor:
|
||||
if tool_data["name"] == "mcp_tool":
|
||||
tool_config["query_mode"] = True
|
||||
|
||||
# MCP OAuth tokens are looked up by user id: a shared server runs on
|
||||
# the owner's connection, like every other credential.
|
||||
load_user = (tool_data.get("user_id") or self.user) if tool_data["name"] == "mcp_tool" else self.user
|
||||
tool = tm.load_tool(
|
||||
tool_data["name"],
|
||||
tool_config=tool_config,
|
||||
user_id=self.user,
|
||||
user_id=load_user,
|
||||
)
|
||||
|
||||
# Don't cache api_tool since config varies by action
|
||||
|
||||
@@ -19,6 +19,8 @@ WIKI_UPDATED_VIA_AGENT = "agent"
|
||||
|
||||
MAX_WIKI_PAGE_BYTES = 1_000_000
|
||||
|
||||
_WRITE_ACTIONS = frozenset({"create", "str_replace", "insert", "delete", "rename"})
|
||||
|
||||
|
||||
class WikiTool(Tool):
|
||||
"""Wiki
|
||||
@@ -49,6 +51,11 @@ class WikiTool(Tool):
|
||||
if not self.source_id:
|
||||
return "Error: WikiTool requires a source_id."
|
||||
|
||||
if action_name in _WRITE_ACTIONS:
|
||||
denied = self._write_denied()
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
if action_name == "view":
|
||||
return self._view(kwargs.get("path", "/"), kwargs.get("view_range"))
|
||||
if action_name == "create":
|
||||
@@ -192,6 +199,37 @@ class WikiTool(Tool):
|
||||
},
|
||||
]
|
||||
|
||||
def _write_denied(self) -> Optional[str]:
|
||||
"""Re-check the caller's live ``edit`` right before a write.
|
||||
|
||||
The tool is attached for a writable source when the conversation
|
||||
starts, but a grant can be revoked (or downgraded to viewer) mid-run.
|
||||
Resolving access on every write stops the agent from editing once the
|
||||
caller no longer may. Fails closed on an unknown caller or a failed
|
||||
lookup. Re-embeds still run as the owner.
|
||||
|
||||
Returns:
|
||||
Optional[str]: An error message for the LLM, or None when allowed.
|
||||
"""
|
||||
from docsgpt.api.user import resource_access
|
||||
|
||||
message = "Error: You no longer have edit access to this wiki, so it can't be changed."
|
||||
if not self.updated_by:
|
||||
return message
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
access = resource_access.resolve(
|
||||
conn, "source", str(self.source_id), self.updated_by
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Wiki write access check failed for source %s", self.source_id
|
||||
)
|
||||
return message
|
||||
if access is None or not access.can("edit"):
|
||||
return message
|
||||
return None
|
||||
|
||||
def get_config_requirements(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class WorkflowAgent(BaseAgent):
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.workflow_id = workflow_id
|
||||
self.workflow_row: Optional[Dict[str, Any]] = None
|
||||
self.workflow_owner = workflow_owner
|
||||
self._workflow_data = workflow
|
||||
self._engine: Optional[WorkflowEngine] = None
|
||||
@@ -195,6 +196,9 @@ class WorkflowAgent(BaseAgent):
|
||||
if workflow_row is None:
|
||||
logger.error(f"Workflow {self.workflow_id} not found or inaccessible for user {owner_id}")
|
||||
return None
|
||||
# Node tools/sources the owner can't use resolve through its
|
||||
# ``resource_sponsors`` (see WorkflowEngine).
|
||||
self.workflow_row = workflow_row
|
||||
pg_workflow_id = str(workflow_row["id"])
|
||||
graph_version = workflow_row.get("current_graph_version", 1)
|
||||
try:
|
||||
|
||||
@@ -19,6 +19,7 @@ class _WorkflowNodeMixin:
|
||||
model_id: str,
|
||||
api_key: str,
|
||||
tool_ids: Optional[List[str]] = None,
|
||||
tool_principals: Optional[Dict[str, str]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
@@ -34,6 +35,8 @@ class _WorkflowNodeMixin:
|
||||
# (Artifact / Code Executor / Read Document) and ``user_tools`` rows
|
||||
# alike, and an empty list means the node's LLM gets no tools.
|
||||
self.tool_executor.allowed_tool_ids = [str(t) for t in (tool_ids or [])]
|
||||
# Tools the owner can't use resolve as the editor who attached them.
|
||||
self.tool_executor.tool_principals = dict(tool_principals or {})
|
||||
|
||||
|
||||
class WorkflowNodeClassicAgent(_WorkflowNodeMixin, ClassicAgent):
|
||||
|
||||
@@ -405,6 +405,7 @@ class WorkflowEngine:
|
||||
"model_user_id": getattr(self.agent, "model_user_id", None),
|
||||
"api_key": node_api_key,
|
||||
"tool_ids": node_config.tools,
|
||||
"tool_principals": self._node_tool_principals(node_config.tools),
|
||||
"prompt": node_prompt,
|
||||
"chat_history": self.agent.chat_history,
|
||||
"decoded_token": self.agent.decoded_token,
|
||||
@@ -1321,6 +1322,36 @@ class WorkflowEngine:
|
||||
docs_together = "\n\n".join(docs_together_parts) if docs_together_parts else None
|
||||
return docs, docs_together
|
||||
|
||||
def _node_tool_principals(self, tool_ids) -> Dict[str, str]:
|
||||
"""Node tool id -> the editor to resolve it as, for sponsored tools.
|
||||
|
||||
Only tools with a live sponsor on the workflow appear; the executor
|
||||
still tries the owner first.
|
||||
|
||||
Args:
|
||||
tool_ids: The node's configured tool ids.
|
||||
|
||||
Returns:
|
||||
dict: ``tool_id -> sponsor`` user id.
|
||||
"""
|
||||
workflow_row = getattr(self.agent, "workflow_row", None)
|
||||
if not tool_ids or not workflow_row or not workflow_row.get("resource_sponsors"):
|
||||
return {}
|
||||
from docsgpt.api.user.resource_access import active_sponsor
|
||||
from docsgpt.storage.db.session import db_readonly
|
||||
|
||||
principals: Dict[str, str] = {}
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
for tid in tool_ids:
|
||||
sponsor = active_sponsor(conn, "workflow", workflow_row, "tool", str(tid))
|
||||
if sponsor:
|
||||
principals[str(tid)] = sponsor
|
||||
except Exception:
|
||||
logger.exception("Workflow node tool sponsor lookup failed; using the owner only.")
|
||||
return {}
|
||||
return principals
|
||||
|
||||
def _authorized_node_sources(self, sources) -> list:
|
||||
"""Filter a node's configured source ids to those its owner may read.
|
||||
|
||||
@@ -1329,7 +1360,9 @@ class WorkflowEngine:
|
||||
tenant's source id and the retriever — which filters only on
|
||||
``source_id`` — handed the documents back. Gate on the workflow owner
|
||||
(not the runner): a shared workflow legitimately reads its owner's
|
||||
sources, exactly like a shared agent does.
|
||||
sources, exactly like a shared agent does. A source the owner can't
|
||||
read still passes while the editor who attached it (its sponsor)
|
||||
qualifies.
|
||||
|
||||
Args:
|
||||
sources: Source ids from the stored node config.
|
||||
@@ -1348,14 +1381,19 @@ class WorkflowEngine:
|
||||
logger.warning("Workflow node sources dropped: no owner to authorize.")
|
||||
return []
|
||||
|
||||
from docsgpt.api.user.resource_access import active_sponsor
|
||||
from docsgpt.api.user.team_sharing import can_access
|
||||
from docsgpt.storage.db.session import db_readonly
|
||||
|
||||
workflow_row = getattr(self.agent, "workflow_row", None)
|
||||
allowed = []
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
for sid in ids:
|
||||
if sid and can_access(conn, "source", str(sid), owner):
|
||||
if sid and (
|
||||
can_access(conn, "source", str(sid), owner)
|
||||
or active_sponsor(conn, "workflow", workflow_row, "source", str(sid))
|
||||
):
|
||||
allowed.append(sid)
|
||||
else:
|
||||
logger.warning(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""0038 resource access settings — per-asset sharing switches and tool chat prefs.
|
||||
|
||||
``resource_share_settings`` holds the owner's per-asset switches that adjust
|
||||
what the fixed roles may do on one shared resource ("Editors can share",
|
||||
"Viewers can see logs", ...). One row per ``(resource_type, resource_id)``;
|
||||
a missing row means every switch is at its default. The table is polymorphic
|
||||
like ``team_resource_grants``, so it has no FK and the switch keys are
|
||||
validated in code (``docsgpt/api/user/resource_access.py``), which keeps new
|
||||
switches migration-free.
|
||||
|
||||
``user_tool_preferences`` is the personal "In my chats" switch for a tool
|
||||
shared with the caller. A grantee can't write the owner's ``user_tools.status``
|
||||
(that is the owner's own chat setting), so each grantee gets a row here.
|
||||
A missing row means off: sharing a tool never adds it to anyone's chats.
|
||||
|
||||
Idempotent both ways.
|
||||
|
||||
Revision ID: 0038_resource_access_settings
|
||||
Revises: 0037_request_traces
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0038_resource_access_settings"
|
||||
down_revision: Union[str, None] = "0037_request_traces"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS resource_share_settings (
|
||||
resource_type TEXT NOT NULL
|
||||
CONSTRAINT resource_share_settings_type_check
|
||||
CHECK (resource_type IN ('agent', 'source', 'prompt', 'tool')),
|
||||
resource_id UUID NOT NULL,
|
||||
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_by TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (resource_type, resource_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_tool_preferences (
|
||||
user_id TEXT NOT NULL,
|
||||
tool_id UUID NOT NULL REFERENCES user_tools(id) ON DELETE CASCADE,
|
||||
in_chat BOOLEAN NOT NULL DEFAULT false,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, tool_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS user_tool_preferences_tool_idx
|
||||
ON user_tool_preferences (tool_id);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS user_tool_preferences;")
|
||||
op.execute("DROP TABLE IF EXISTS resource_share_settings;")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""0039 resource sponsors — who vouches for a saved resource the owner can't use.
|
||||
|
||||
An agent (and a workflow) runs as its owner, so every source, prompt and tool
|
||||
it references is authorized against the owner. A team editor who attaches
|
||||
their own private tool, prompt or source would have it dropped at run time.
|
||||
``resource_sponsors`` records the editor who attached such a resource, keyed
|
||||
``"<type>:<id>"`` (e.g. ``{"tool:<uuid>": "bob"}``). At run time the resource
|
||||
is authorized as that sponsor, provided they can still edit the agent and
|
||||
still use the resource (``docsgpt/api/user/resource_access.py``).
|
||||
|
||||
An empty map means today's behaviour: owner-only authorization.
|
||||
|
||||
Idempotent both ways.
|
||||
|
||||
Revision ID: 0039_resource_sponsors
|
||||
Revises: 0038_resource_access_settings
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0039_resource_sponsors"
|
||||
down_revision: Union[str, None] = "0038_resource_access_settings"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in ("agents", "workflows"):
|
||||
op.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS "
|
||||
"resource_sponsors JSONB NOT NULL DEFAULT '{}'::jsonb;"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ("agents", "workflows"):
|
||||
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS resource_sponsors;")
|
||||
@@ -109,6 +109,71 @@ def get_prompt(prompt_id: str, prompts_collection=None) -> str:
|
||||
raise ValueError(f"Invalid prompt ID: {prompt_id}") from e
|
||||
|
||||
|
||||
_PROMPT_PRESETS_WITHOUT_ROW = ("reduce",)
|
||||
|
||||
|
||||
def authorized_prompt_id(prompt_id: Any, principal: Optional[str], agent: Optional[dict] = None) -> Any:
|
||||
"""``prompt_id`` if ``principal`` (or the agent's sponsor) may use it, else ``"default"``.
|
||||
|
||||
Presets pass through. A custom prompt must be owned by ``principal`` or
|
||||
reach them through a team grant with ``use`` (checked live). On an agent
|
||||
run, a prompt the owner can't use still renders while the editor who
|
||||
attached it (its sponsor) qualifies. A revoked, deleted or foreign prompt
|
||||
falls back to the default prompt.
|
||||
|
||||
Args:
|
||||
prompt_id: The configured prompt (preset name, UUID or legacy id).
|
||||
principal: The agent owner for an agent run, else the caller.
|
||||
agent: The agent row on an agent run, for its ``resource_sponsors``.
|
||||
|
||||
Returns:
|
||||
The prompt id to render.
|
||||
"""
|
||||
if prompt_id is None or prompt_id == "":
|
||||
return prompt_id
|
||||
pid = str(prompt_id)
|
||||
if is_composed_preset(pid) or pid in _PROMPT_PRESETS_WITHOUT_ROW:
|
||||
return prompt_id
|
||||
from docsgpt.api.user.resource_access import active_sponsor, resolve
|
||||
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
ra = resolve(conn, "prompt", pid, principal) if principal else None
|
||||
usable = ra is not None and ra.can("use")
|
||||
if not usable and agent and agent.get("id"):
|
||||
usable = active_sponsor(conn, "agent", agent, "prompt", pid) is not None
|
||||
except Exception:
|
||||
logger.exception("Prompt access check failed for %s", pid)
|
||||
usable = False
|
||||
if usable:
|
||||
return prompt_id
|
||||
logger.info("prompt %s not usable by %s; using the default prompt", pid, principal)
|
||||
return "default"
|
||||
|
||||
|
||||
def _agent_source_doc(conn: Any, sources_repo: Any, agent: dict, source_id: Any) -> Optional[dict]:
|
||||
"""The source row an agent may retrieve from, or None.
|
||||
|
||||
Authorized as the owner (owned or team-shared to them), else as the
|
||||
editor who attached it while they still qualify. Read unscoped once
|
||||
authorized: an owner-scoped read misses a team-shared source.
|
||||
"""
|
||||
from docsgpt.api.user.resource_access import ref_principal
|
||||
|
||||
if not ref_principal(conn, "agent", agent, "source", str(source_id)):
|
||||
logger.info("agent %s source %s not usable; skipped", agent.get("id"), source_id)
|
||||
return None
|
||||
return sources_repo.get_by_id(str(source_id))
|
||||
|
||||
|
||||
def _wiki_write_owner(conn: Any, source_id: str, caller: str) -> Optional[str]:
|
||||
"""The owner id to write a wiki source as, when ``caller`` may edit it."""
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
|
||||
ra = resolve(conn, "source", source_id, caller)
|
||||
return ra.owner_id if ra is not None and ra.can("edit") else None
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -676,13 +741,12 @@ class StreamProcessor:
|
||||
# the legacy ``data["source"]`` slot.
|
||||
sources_list: list = []
|
||||
seen: set = set()
|
||||
owner = agent.get("user_id")
|
||||
primary_id = agent.get("source_id")
|
||||
# ``sources`` row may have NULL ``retriever``/``chunks`` —
|
||||
# fall back to the agent's value (``dict.get`` returns None
|
||||
# even when the key exists with value None).
|
||||
if primary_id:
|
||||
source_doc = sources_repo.get(str(primary_id), owner)
|
||||
source_doc = _agent_source_doc(conn, sources_repo, agent, primary_id)
|
||||
if source_doc:
|
||||
sid = str(source_doc["id"])
|
||||
data["source"] = sid
|
||||
@@ -715,7 +779,7 @@ class StreamProcessor:
|
||||
for sid_raw in agent.get("extra_source_ids") or []:
|
||||
if not sid_raw:
|
||||
continue
|
||||
source_doc = sources_repo.get(str(sid_raw), owner)
|
||||
source_doc = _agent_source_doc(conn, sources_repo, agent, sid_raw)
|
||||
if not source_doc:
|
||||
continue
|
||||
sid = str(source_doc["id"])
|
||||
@@ -934,7 +998,13 @@ class StreamProcessor:
|
||||
|
||||
self.agent_config.update(
|
||||
{
|
||||
"prompt_id": self._agent_data.get("prompt_id", "default"),
|
||||
# The agent runs in its owner's context: its prompt must
|
||||
# be one the owner may use (re-checked on every run).
|
||||
"prompt_id": authorized_prompt_id(
|
||||
self._agent_data.get("prompt_id", "default"),
|
||||
self._agent_data.get("user"),
|
||||
self._agent_data,
|
||||
),
|
||||
"agent_type": self._agent_data.get("agent_type", settings.AGENT_NAME),
|
||||
"user_api_key": effective_key,
|
||||
"json_schema": self._agent_data.get("json_schema"),
|
||||
@@ -998,9 +1068,10 @@ class StreamProcessor:
|
||||
if preview_workflow_id:
|
||||
self.agent_config["workflow_id"] = str(preview_workflow_id)
|
||||
|
||||
caller = self.decoded_token.get("sub") if isinstance(self.decoded_token, dict) else None
|
||||
self.agent_config.update(
|
||||
{
|
||||
"prompt_id": self.data.get("prompt_id", "default"),
|
||||
"prompt_id": authorized_prompt_id(self.data.get("prompt_id", "default"), caller),
|
||||
"agent_type": agent_type,
|
||||
"user_api_key": None,
|
||||
"json_schema": None,
|
||||
@@ -1134,14 +1205,12 @@ class StreamProcessor:
|
||||
"""Resolve the WikiTool config for the first writable wiki source.
|
||||
|
||||
A source qualifies when ``SourceConfig.parse(config).kind == "wiki"`` and
|
||||
the principal can write it (``effective_write_owner`` returns an owner —
|
||||
owner or team editor; viewers get None and no tool). v1 supports one
|
||||
the principal may ``edit`` it (owner or team editor; viewers get no
|
||||
tool) — resolved live through ``resource_access``. v1 supports one
|
||||
writable wiki source; the first match wins and the scan stops there so
|
||||
this runs at most one owner+source lookup per chat on the hot path.
|
||||
Returns None when no writable wiki source is present.
|
||||
"""
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner
|
||||
|
||||
caller = self.decoded_token.get("sub") if self.decoded_token else None
|
||||
if not caller:
|
||||
return None
|
||||
@@ -1155,7 +1224,7 @@ class StreamProcessor:
|
||||
if not sid or sid == "default":
|
||||
continue
|
||||
sid = str(sid)
|
||||
owner = effective_write_owner(conn, "source", sid, caller)
|
||||
owner = _wiki_write_owner(conn, sid, caller)
|
||||
if not owner:
|
||||
continue
|
||||
source_doc = repo.get_any(sid, owner)
|
||||
|
||||
@@ -17,6 +17,8 @@ from flask_restx import fields, Namespace, Resource
|
||||
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import load_source
|
||||
from docsgpt.api.user.tasks import (
|
||||
ingest_connector_task,
|
||||
)
|
||||
@@ -26,7 +28,6 @@ from docsgpt.storage.db.repositories.connector_sessions import (
|
||||
ConnectorSessionsRepository,
|
||||
owns_connector_session,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
|
||||
|
||||
@@ -499,6 +500,40 @@ class ConnectorDisconnect(Resource):
|
||||
return make_response(jsonify({"success": False, "error": "Failed to disconnect session"}), 500)
|
||||
|
||||
|
||||
def _owner_connector_session(conn, owner_id: str, provider: str) -> Optional[dict]:
|
||||
"""The owner's usable connector session for ``provider``, or None.
|
||||
|
||||
Used when a team editor syncs a shared connector source: the sync runs
|
||||
with the owner's account. A session with no token, no stored credentials,
|
||||
or an expired access token that can't be refreshed counts as missing.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
owner_id: The source owner's ``sub``.
|
||||
provider: The source's connector provider.
|
||||
|
||||
Returns:
|
||||
Optional[dict]: The session row, or None when the owner must reconnect.
|
||||
"""
|
||||
candidates = [
|
||||
s for s in ConnectorSessionsRepository(conn).list_for_user(owner_id)
|
||||
if owns_connector_session(s, owner_id, provider)
|
||||
and s.get("session_token") and s.get("token_info")
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
session = candidates[0]
|
||||
token_info = session["token_info"]
|
||||
if not token_info.get("refresh_token"):
|
||||
try:
|
||||
if ConnectorCreator.create_auth(provider).is_token_expired(token_info):
|
||||
return None
|
||||
except Exception:
|
||||
# Providers without an expiry check leave the verdict to the sync.
|
||||
pass
|
||||
return session
|
||||
|
||||
|
||||
@connectors_ns.route("/api/connectors/sync")
|
||||
class ConnectorSync(Resource):
|
||||
@api.expect(
|
||||
@@ -506,7 +541,11 @@ class ConnectorSync(Resource):
|
||||
"ConnectorSyncModel",
|
||||
{
|
||||
"source_id": fields.String(required=True, description="Source ID to sync"),
|
||||
"session_token": fields.String(required=True, description="Authentication token")
|
||||
"session_token": fields.String(
|
||||
required=False,
|
||||
description="The owner's connector session token (ignored for team editors, "
|
||||
"whose sync uses the owner's session)",
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -517,33 +556,41 @@ class ConnectorSync(Resource):
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
data = request.get_json() or {}
|
||||
source_id = data.get('source_id')
|
||||
session_token = data.get('session_token')
|
||||
|
||||
if not all([source_id, session_token]):
|
||||
if not source_id:
|
||||
return make_response(
|
||||
jsonify({
|
||||
"success": False,
|
||||
"error": "source_id and session_token are required"
|
||||
}),
|
||||
}),
|
||||
400
|
||||
)
|
||||
user_id = decoded_token.get('sub')
|
||||
with db_readonly() as conn:
|
||||
source = SourcesRepository(conn).get_any(source_id, user_id)
|
||||
if not source:
|
||||
# Owner or team editor. The sync always runs AS the owner, with the
|
||||
# owner's connector account: a grantee can't point the source at
|
||||
# their own account (that is ``reconnect``, owner-only).
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
source, ra = load_source(conn, source_id, user_id, "edit")
|
||||
except AccessDenied as err:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": err.message, "message": err.message}),
|
||||
err.status,
|
||||
)
|
||||
owner_id = ra.owner_id
|
||||
is_owner = ra.access == "owner"
|
||||
if is_owner and not session_token:
|
||||
return make_response(
|
||||
jsonify({
|
||||
"success": False,
|
||||
"error": "Source not found"
|
||||
"error": "source_id and session_token are required"
|
||||
}),
|
||||
404
|
||||
400
|
||||
)
|
||||
|
||||
# ``get_any`` already scopes by ``user_id``; an extra guard
|
||||
# here would be dead code.
|
||||
|
||||
remote_data = source.get('remote_data') or {}
|
||||
if isinstance(remote_data, str):
|
||||
try:
|
||||
@@ -558,17 +605,31 @@ class ConnectorSync(Resource):
|
||||
jsonify({
|
||||
"success": False,
|
||||
"error": "Source provider not found in remote_data"
|
||||
}),
|
||||
}),
|
||||
400
|
||||
)
|
||||
|
||||
with db_readonly() as conn:
|
||||
session = ConnectorSessionsRepository(conn).get_by_session_token(session_token)
|
||||
if not owns_connector_session(session, user_id, source_type):
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "Invalid or unauthorized session"}),
|
||||
401,
|
||||
)
|
||||
if is_owner:
|
||||
with db_readonly() as conn:
|
||||
session = ConnectorSessionsRepository(conn).get_by_session_token(session_token)
|
||||
if not owns_connector_session(session, user_id, source_type):
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "Invalid or unauthorized session"}),
|
||||
401,
|
||||
)
|
||||
else:
|
||||
with db_readonly() as conn:
|
||||
session = _owner_connector_session(conn, owner_id, source_type)
|
||||
if session is None:
|
||||
message = (
|
||||
"The owner needs to reconnect this source's account "
|
||||
"before it can be synced."
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": message, "message": message}),
|
||||
409,
|
||||
)
|
||||
session_token = session["session_token"]
|
||||
|
||||
# Extract configuration from remote_data
|
||||
file_ids = remote_data.get('file_ids', [])
|
||||
@@ -578,7 +639,7 @@ class ConnectorSync(Resource):
|
||||
# Start the sync task
|
||||
task = ingest_connector_task.delay(
|
||||
job_name=source.get('name'),
|
||||
user=decoded_token.get('sub'),
|
||||
user=owner_id,
|
||||
source_type=source_type,
|
||||
session_token=session_token,
|
||||
file_ids=file_ids,
|
||||
|
||||
@@ -309,6 +309,7 @@ RULES: dict[tuple[str, str], Rule] = {
|
||||
("/api/teams/<string:team_id>/members", "GET"): _rule("teams:read"),
|
||||
("/api/teams/<string:team_id>/grants", "GET"): _rule("teams:read"),
|
||||
("/api/resource_shares", "GET"): _rule("teams:read"),
|
||||
("/api/resource_settings", "GET"): _rule("teams:read"),
|
||||
# Chat
|
||||
("/api/answer", "POST"): _rule("chat:run", **_CHAT),
|
||||
("/stream", "POST"): _rule("chat:run", **_CHAT),
|
||||
@@ -355,6 +356,7 @@ DENIED: dict[str, tuple[str, ...]] = {
|
||||
"/api/teams/<string:team_id>/members/<string:member_id>": ("*",),
|
||||
"/api/teams/<string:team_id>/grants": ("POST", "DELETE"),
|
||||
"/api/teams/<string:team_id>/transfer_owner": ("*",),
|
||||
"/api/resource_settings": ("PUT",),
|
||||
"/swagger.json": ("*",),
|
||||
}
|
||||
DENIED_PREFIXES = (
|
||||
|
||||
@@ -8,6 +8,7 @@ from flask_restx import Namespace, Resource, fields
|
||||
from sqlalchemy import text as _sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied, payload_for, require, settings_many
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
@@ -143,11 +144,16 @@ class AgentFolder(Resource):
|
||||
),
|
||||
{"user_id": user, "fid": pg_folder_id},
|
||||
).fetchall()
|
||||
switches = settings_many(
|
||||
conn, "agent", [str(row._mapping["id"]) for row in agents_rows]
|
||||
)
|
||||
# Folder contents are the caller's own agents.
|
||||
agents_list = [
|
||||
{
|
||||
"id": str(row._mapping["id"]),
|
||||
"name": row._mapping["name"],
|
||||
"description": row._mapping.get("description", "") or "",
|
||||
**payload_for("agent", "owner", switches[str(row._mapping["id"])]),
|
||||
}
|
||||
for row in agents_rows
|
||||
]
|
||||
@@ -298,7 +304,14 @@ class MoveAgentToFolder(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
agent = agents_repo.get_any(agent_id_input, user)
|
||||
# Folders are the owner's own organisation of their agents.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id_input, user, "move_folder")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}),
|
||||
@@ -357,10 +370,18 @@ class BulkMoveAgents(Resource):
|
||||
404,
|
||||
)
|
||||
pg_folder_id = str(folder["id"])
|
||||
# Only the caller's own agents move (``move_folder`` is
|
||||
# owner-only); anything else is reported back, not dropped.
|
||||
moved, skipped = [], []
|
||||
for agent_id_input in agent_ids:
|
||||
agent = agents_repo.get_any(agent_id_input, user)
|
||||
if agent is not None:
|
||||
agents_repo.set_folder(str(agent["id"]), user, pg_folder_id)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
moved.append(str(agent_id_input))
|
||||
else:
|
||||
skipped.append(str(agent_id_input))
|
||||
return make_response(
|
||||
jsonify({"success": True, "moved": moved, "skipped": skipped}), 200
|
||||
)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to move agents", err)
|
||||
@@ -4,7 +4,7 @@ from flask import jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.team_sharing import team_access_for
|
||||
from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.guardrails.checks.patterns import DEFAULT_PII_ENTITIES, PII_PATTERNS
|
||||
from docsgpt.guardrails.config import DEFAULT_BLOCK_MESSAGE, MODES
|
||||
@@ -70,15 +70,30 @@ class GuardrailCatalog(Resource):
|
||||
)
|
||||
|
||||
|
||||
def _readable_agent(conn, agent_id: str, user: str):
|
||||
"""Return the agent row when the caller may read it, else None."""
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
if agent:
|
||||
return agent
|
||||
if team_access_for(conn, user, "agent", agent_id):
|
||||
return repo.get_by_id(agent_id)
|
||||
return None
|
||||
def _logs_access(conn, agent_id: str, user: str) -> tuple[dict, ResourceAccess]:
|
||||
"""The agent row and the caller's access, for reading its guardrail journal.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
agent_id: The agent's id (UUID or legacy).
|
||||
user: The caller.
|
||||
|
||||
Returns:
|
||||
``(agent, access)``; rows are read as ``access.owner_id``, so a team
|
||||
member with ``view_logs`` sees exactly what the owner sees.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when the agent isn't visible, 403 without ``view_logs``.
|
||||
"""
|
||||
ra = require(conn, "agent", agent_id, user, "view_logs")
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if agent is None:
|
||||
raise AccessDenied(404, "Agent not found")
|
||||
return agent, ra
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
@agents_guardrails_ns.route("/guardrails/events")
|
||||
@@ -106,17 +121,16 @@ class GuardrailEvents(Resource):
|
||||
400,
|
||||
)
|
||||
with db_readonly() as conn:
|
||||
agent = _readable_agent(conn, agent_id, user)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
try:
|
||||
agent, ra = _logs_access(conn, agent_id, user)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# Query on the row's UUID, not the caller's argument: a legacy
|
||||
# 24-hex Mongo id resolves fine above but would blow up the cast.
|
||||
# Rows stay scoped to the requesting user even on a shared agent —
|
||||
# another member's blocked prompts are not this caller's to read.
|
||||
# Rows are the owner's view: ``view_logs`` shows a team member
|
||||
# what the owner sees, never other members' own chats.
|
||||
events = GuardrailEventsRepository(conn).list_for_agent(
|
||||
str(agent["id"]), user, limit=limit, offset=offset
|
||||
str(agent["id"]), ra.owner_id, limit=limit, offset=offset
|
||||
)
|
||||
return make_response(jsonify({"success": True, "events": events}), 200)
|
||||
|
||||
@@ -143,14 +157,15 @@ class GuardrailSummary(Resource):
|
||||
agent_id = request.args.get("agent_id")
|
||||
with db_readonly() as conn:
|
||||
scoped_id = None
|
||||
scope_user = user
|
||||
if agent_id:
|
||||
agent = _readable_agent(conn, agent_id, user)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
try:
|
||||
agent, ra = _logs_access(conn, agent_id, user)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
scoped_id = str(agent["id"])
|
||||
scope_user = ra.owner_id
|
||||
summary = GuardrailEventsRepository(conn).summary_for_user(
|
||||
user, days=days, agent_id=scoped_id
|
||||
scope_user, days=days, agent_id=scoped_id
|
||||
)
|
||||
return make_response(jsonify({"success": True, **summary}), 200)
|
||||
@@ -37,6 +37,7 @@ from docsgpt.agents.default_tools import (
|
||||
)
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import allowed_ids
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.core.model_utils import validate_model_id
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.safe_url import UnsafeUserUrlError, validate_user_base_url
|
||||
@@ -1756,14 +1757,23 @@ class ExportAgent(Resource):
|
||||
return make_response(jsonify({"success": False, "message": "id is required"}), 400)
|
||||
with db_session() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "export")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
agent = repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
agent["slug"] = ensure_agent_slug(conn, agent, user)
|
||||
# Serialized as the owner: the agent's prompt, sources, tools and
|
||||
# workflow are the owner's (secrets are never exported).
|
||||
owner_id = ra.owner_id
|
||||
agent["slug"] = ensure_agent_slug(conn, agent, owner_id)
|
||||
try:
|
||||
export = serialize_agent(conn, agent, user)
|
||||
export = serialize_agent(conn, agent, owner_id)
|
||||
except AgentExportError as exc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": str(exc)}), 400
|
||||
|
||||
@@ -25,10 +25,20 @@ from docsgpt.core.json_schema_utils import (
|
||||
normalize_json_schema_payload,
|
||||
)
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.base_repository import canonical_uuid, looks_like_uuid
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
agent_refs,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
require,
|
||||
resolve,
|
||||
settings_many,
|
||||
sponsor_details,
|
||||
sponsors_after_save,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import (
|
||||
can_access,
|
||||
team_access_for,
|
||||
visible_with_access,
|
||||
)
|
||||
from docsgpt.agents.default_tools import is_synthesized_tool_id
|
||||
@@ -196,6 +206,80 @@ def _resolve_folder_id(conn, folder_id, user):
|
||||
return str(folder["id"]), None
|
||||
|
||||
|
||||
# What a caller who reached an agent only through its public share link may
|
||||
# do: chat with it and pin it. A team grant, when there is one, wins.
|
||||
LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]}
|
||||
|
||||
# Agent fields that are the owner's policy (guardrails and the pooled quota).
|
||||
POLICY_FIELDS = (
|
||||
"config", "token_limit", "request_limit", "limited_token_mode", "limited_request_mode",
|
||||
)
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""The JSON error response for an :class:`AccessDenied`."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _tool_attachable(conn, tool_id: str, caller: str) -> bool:
|
||||
"""Whether ``caller`` may newly attach ``tool_id`` to an agent.
|
||||
|
||||
Builtin synthetic ids belong to no one. Otherwise the caller must own the
|
||||
tool or reach it with ``use_in_own``. The agent owner owning it is not
|
||||
enough: an editor could otherwise wire the owner's private tool (run with
|
||||
the owner's credentials) into an agent the editor controls.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
tool_id: The tool id being attached.
|
||||
caller: The user making the change.
|
||||
|
||||
Returns:
|
||||
True when the attachment is allowed.
|
||||
"""
|
||||
tid = str(tool_id)
|
||||
if is_synthesized_tool_id(tid):
|
||||
return True
|
||||
ra = resolve(conn, "tool", tid, caller)
|
||||
return ra is not None and ra.can("use_in_own")
|
||||
|
||||
|
||||
def _ref_attachable(conn, resource_type: str, resource_id: str, caller: str) -> bool:
|
||||
"""Whether ``caller`` may newly reference a source/prompt from an agent.
|
||||
|
||||
Like tools, the caller's own access counts, not the agent owner's.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
resource_type: ``source`` or ``prompt``.
|
||||
resource_id: The referenced id.
|
||||
caller: The user making the change.
|
||||
|
||||
Returns:
|
||||
True when the caller owns it or a team grant reaches them.
|
||||
"""
|
||||
if not resource_id:
|
||||
return True
|
||||
return can_access(conn, resource_type, str(resource_id), caller)
|
||||
|
||||
|
||||
def _policy_changed(existing: dict, update_fields: dict) -> bool:
|
||||
"""True when ``update_fields`` changes any guardrail or quota value."""
|
||||
for key in POLICY_FIELDS:
|
||||
if key not in update_fields:
|
||||
continue
|
||||
new, old = update_fields[key], existing.get(key)
|
||||
if key == "config":
|
||||
if (new or {}) != (old or {}):
|
||||
return True
|
||||
elif key.startswith("limited_"):
|
||||
if bool(new) != bool(old):
|
||||
return True
|
||||
elif int(new or 0) != int(old or 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _reject(message: str, user: str, field: str = "-"):
|
||||
"""Log a request-validation rejection at WARN and return its 400 response.
|
||||
|
||||
@@ -228,6 +312,7 @@ def _format_agent_output(
|
||||
ownership: str = "user",
|
||||
team_access: str | None = None,
|
||||
resolve_names: bool = False,
|
||||
access: dict | None = None,
|
||||
) -> dict:
|
||||
"""Shape a PG agent row into the outward API response dict.
|
||||
|
||||
@@ -239,7 +324,16 @@ def _format_agent_output(
|
||||
``ownership`` is ``"user"`` for the caller's own agents or ``"team"`` for
|
||||
ones shared with a team they're in; ``team_access`` (``viewer``/``editor``)
|
||||
is set on team-shared agents so the UI can gate edit controls.
|
||||
|
||||
``access`` is the caller's ``{"access", "allowed_actions"}`` payload
|
||||
(owner with default switches when omitted). It is embedded verbatim and
|
||||
decides what leaves the server: the full guardrail ``config`` needs
|
||||
``view``, the (masked) ``key`` and public ``shared_token`` need
|
||||
``manage_access_details``.
|
||||
"""
|
||||
if access is None:
|
||||
access = payload_for("agent", "owner", {})
|
||||
allowed = set(access.get("allowed_actions") or [])
|
||||
source_id = agent.get("source_id")
|
||||
extra_source_ids = agent.get("extra_source_ids") or []
|
||||
source_value = str(source_id) if source_id else ""
|
||||
@@ -288,7 +382,13 @@ def _format_agent_output(
|
||||
),
|
||||
"ownership": ownership,
|
||||
"team_access": team_access,
|
||||
"access": access.get("access"),
|
||||
"allowed_actions": sorted(allowed),
|
||||
}
|
||||
# Guardrail policy is edit-page config; a chat-only caller doesn't need it
|
||||
# (and reading the banned-term list makes evading it trivial).
|
||||
if "view" not in allowed:
|
||||
out["config"] = {}
|
||||
# Resolve prompt/source NAMES by id (owner-agnostic) so a team member
|
||||
# viewing a shared agent sees the owner's prompt + source names instead of
|
||||
# a blank prompt / "External KB" (the client otherwise resolves these from
|
||||
@@ -298,9 +398,9 @@ def _format_agent_output(
|
||||
out["source_details"] = resolve_source_details(
|
||||
([source_id] if source_id else []) + list(extra_source_ids)
|
||||
)
|
||||
# Never expose the owner's share/API secrets to a team grantee — the
|
||||
# public ``shared_token`` and the (masked) agent ``key`` are owner-only.
|
||||
if ownership == "team":
|
||||
# The public ``shared_token`` and the (masked) agent ``key`` are access
|
||||
# details: only a caller who may manage them sees them.
|
||||
if "manage_access_details" not in allowed:
|
||||
out["shared_token"] = ""
|
||||
return out
|
||||
if include_key_masked:
|
||||
@@ -435,24 +535,28 @@ class GetAgent(Resource):
|
||||
return {"success": False, "message": "ID required"}, 400
|
||||
try:
|
||||
user = decoded_token["sub"]
|
||||
ownership, team_access = "user", None
|
||||
agent = None
|
||||
sponsored: list = []
|
||||
with db_readonly() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
if not agent:
|
||||
# Team fallback: only after a grant check, fetch ownerless.
|
||||
team_access = team_access_for(conn, user, "agent", agent_id)
|
||||
if team_access:
|
||||
agent = repo.get_by_id(agent_id)
|
||||
ownership = "team"
|
||||
# Anyone who can see the agent reads it (a viewer needs it to
|
||||
# chat); what they get back is trimmed by their actions.
|
||||
ra = resolve(conn, "agent", agent_id, user)
|
||||
if ra is not None:
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
# Edit-page detail: who vouches for resources the owner can't use.
|
||||
if agent and ra.can("view"):
|
||||
sponsored = sponsor_details(conn, "agent", agent)
|
||||
if not agent:
|
||||
return {"status": "Not found"}, 404
|
||||
is_owner = ra.access == "owner"
|
||||
data = _format_agent_output(
|
||||
agent,
|
||||
ownership=ownership,
|
||||
team_access=team_access,
|
||||
ownership="user" if is_owner else "team",
|
||||
team_access=None if is_owner else ra.access,
|
||||
resolve_names=True,
|
||||
access=ra.payload(),
|
||||
)
|
||||
data["resource_sponsors"] = sponsored
|
||||
return make_response(jsonify(data), 200)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Agent fetch error: {e}", exc_info=True)
|
||||
@@ -483,10 +587,18 @@ 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)
|
||||
|
||||
switches = settings_many(
|
||||
conn, "agent", [str(a["id"]) for a in agents + shared_agents]
|
||||
)
|
||||
|
||||
# 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)
|
||||
_format_agent_output(
|
||||
agent,
|
||||
pinned=str(agent["id"]) in pinned_ids,
|
||||
access=payload_for("agent", "owner", switches[str(agent["id"])]),
|
||||
)
|
||||
for agent in agents
|
||||
]
|
||||
list_agents += [
|
||||
@@ -494,6 +606,11 @@ class GetAgents(Resource):
|
||||
agent,
|
||||
ownership="team",
|
||||
team_access=team_shared.get(str(agent["id"])),
|
||||
access=payload_for(
|
||||
"agent",
|
||||
team_shared.get(str(agent["id"])),
|
||||
switches[str(agent["id"])],
|
||||
),
|
||||
)
|
||||
for agent in shared_agents
|
||||
]
|
||||
@@ -705,11 +822,11 @@ class CreateAgent(Resource):
|
||||
if src == "default":
|
||||
continue
|
||||
if looks_like_uuid(src):
|
||||
extra_source_ids.append(src)
|
||||
extra_source_ids.append(canonical_uuid(src))
|
||||
else:
|
||||
source_value = data.get("source", "")
|
||||
if source_value and source_value != "default" and looks_like_uuid(source_value):
|
||||
source_id_resolved = source_value
|
||||
source_id_resolved = canonical_uuid(source_value)
|
||||
|
||||
# Team-sharing write gate: you may reference sources/prompts you
|
||||
# own or that a team has shared with you directly. (Transitive
|
||||
@@ -731,8 +848,20 @@ class CreateAgent(Resource):
|
||||
jsonify({"success": False, "message": "Prompt not accessible"}),
|
||||
403,
|
||||
)
|
||||
# Tools run with the agent owner's credentials: attach only your
|
||||
# own, or ones a team lets you use in your agents.
|
||||
for tid in data.get("tools") or []:
|
||||
if not _tool_attachable(conn, tid, user):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not accessible"}),
|
||||
403,
|
||||
)
|
||||
|
||||
build_data = dict(data)
|
||||
if isinstance(data.get("tools"), list):
|
||||
build_data["tools"] = [canonical_uuid(t) for t in data["tools"]]
|
||||
if looks_like_uuid(data.get("prompt_id")):
|
||||
build_data["prompt_id"] = canonical_uuid(data["prompt_id"])
|
||||
build_data["folder_id"] = pg_folder_id
|
||||
build_data["workflow_id"] = pg_workflow_id
|
||||
build_data["source_id"] = source_id_resolved
|
||||
@@ -888,23 +1017,15 @@ class UpdateAgent(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
is_team_editor = False
|
||||
existing_agent = agents_repo.get_any(agent_id, user)
|
||||
if not existing_agent:
|
||||
# Team write path: only an 'editor' grant may modify a
|
||||
# team-shared agent; a 'viewer' is read-only. Fetch the
|
||||
# ownerless row only AFTER confirming editor access.
|
||||
access = team_access_for(conn, user, "agent", agent_id)
|
||||
if access == "editor":
|
||||
existing_agent = agents_repo.get_by_id(agent_id)
|
||||
is_team_editor = True
|
||||
elif access == "viewer":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Read-only: editor access required"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "edit")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# Every write lands as the owner; the row is fetched only after
|
||||
# the access check above.
|
||||
owner_id = ra.owner_id
|
||||
is_team_editor = ra.access != "owner"
|
||||
existing_agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not existing_agent:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -959,13 +1080,15 @@ class UpdateAgent(Resource):
|
||||
user,
|
||||
field,
|
||||
)
|
||||
if new_status != existing_agent.get("status") and not ra.can("publish"):
|
||||
return _denied(AccessDenied(403, "Your access to this item doesn't allow that"))
|
||||
update_fields["status"] = new_status
|
||||
elif field == "source":
|
||||
source_id = data.get("source")
|
||||
if not source_id or source_id == "default":
|
||||
update_fields["source_id"] = None
|
||||
elif looks_like_uuid(source_id):
|
||||
update_fields["source_id"] = source_id
|
||||
update_fields["source_id"] = canonical_uuid(source_id)
|
||||
else:
|
||||
return _reject(
|
||||
f"Invalid source ID format: {source_id}", user, field
|
||||
@@ -980,7 +1103,7 @@ class UpdateAgent(Resource):
|
||||
if src == "default":
|
||||
continue
|
||||
if looks_like_uuid(src):
|
||||
valid.append(src)
|
||||
valid.append(canonical_uuid(src))
|
||||
else:
|
||||
return _reject(
|
||||
f"Invalid source ID in list: {src}", user, field
|
||||
@@ -1008,7 +1131,7 @@ class UpdateAgent(Resource):
|
||||
tools_list = data.get("tools", [])
|
||||
if not isinstance(tools_list, list):
|
||||
return _reject("Tools must be a list", user, field)
|
||||
update_fields["tools"] = tools_list
|
||||
update_fields["tools"] = [canonical_uuid(t) for t in tools_list]
|
||||
elif field == "json_schema":
|
||||
json_schema = data.get("json_schema")
|
||||
if json_schema is not None:
|
||||
@@ -1084,10 +1207,24 @@ class UpdateAgent(Resource):
|
||||
field,
|
||||
)
|
||||
elif field == "folder_id":
|
||||
folder_input = data.get("folder_id")
|
||||
# Folders are the owner's own organisation; re-sending
|
||||
# the current folder is a no-op anyone may do.
|
||||
folder_input = data.get("folder_id") or None
|
||||
current_folder = (
|
||||
str(existing_agent["folder_id"])
|
||||
if existing_agent.get("folder_id")
|
||||
else None
|
||||
)
|
||||
if folder_input == current_folder:
|
||||
update_fields["folder_id"] = current_folder
|
||||
continue
|
||||
if not ra.can("move_folder"):
|
||||
return _denied(
|
||||
AccessDenied(403, "Only the owner can move this agent between folders")
|
||||
)
|
||||
if folder_input:
|
||||
pg_folder_id, folder_err = _resolve_folder_id(
|
||||
conn, folder_input, user,
|
||||
conn, folder_input, owner_id,
|
||||
)
|
||||
if folder_err:
|
||||
return folder_err
|
||||
@@ -1105,20 +1242,35 @@ class UpdateAgent(Resource):
|
||||
if not normalized:
|
||||
if workflow_required:
|
||||
return _reject("Workflow is required", user, field)
|
||||
update_fields["workflow_id"] = None
|
||||
pg_workflow_id = None
|
||||
else:
|
||||
# The agent runs its workflow as the owner, so it
|
||||
# must be one of the owner's workflows.
|
||||
pg_workflow_id, wf_err = _resolve_workflow_for_user(
|
||||
conn, workflow_input, user,
|
||||
conn, workflow_input, owner_id,
|
||||
)
|
||||
if wf_err:
|
||||
return wf_err
|
||||
update_fields["workflow_id"] = pg_workflow_id
|
||||
# Only the owner may change which workflow the agent
|
||||
# uses, detaching included: editing rights on this
|
||||
# agent extend to the graph it uses, so swapping in the
|
||||
# workflow of another of the owner's agents would hand
|
||||
# that graph to the editor. Editing the graph itself
|
||||
# goes through the workflow routes.
|
||||
current_workflow = existing_agent.get("workflow_id")
|
||||
if is_team_editor and pg_workflow_id != (
|
||||
str(current_workflow) if current_workflow else None
|
||||
):
|
||||
return _denied(
|
||||
AccessDenied(403, "Only the owner can change this agent's workflow")
|
||||
)
|
||||
update_fields["workflow_id"] = pg_workflow_id
|
||||
elif field == "prompt_id":
|
||||
value = data["prompt_id"]
|
||||
if not value or value == "default":
|
||||
update_fields["prompt_id"] = None
|
||||
elif looks_like_uuid(value):
|
||||
update_fields["prompt_id"] = value
|
||||
update_fields["prompt_id"] = canonical_uuid(value)
|
||||
else:
|
||||
return _reject(f"Invalid prompt_id: {value}", user, field)
|
||||
elif field == "allow_system_prompt_override":
|
||||
@@ -1220,7 +1372,7 @@ class UpdateAgent(Resource):
|
||||
for sid in referenced_sources:
|
||||
if str(sid) in existing_source_refs:
|
||||
continue
|
||||
if not can_access(conn, "source", sid, user):
|
||||
if not _ref_attachable(conn, "source", sid, user):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not accessible"}), 403
|
||||
)
|
||||
@@ -1228,28 +1380,25 @@ class UpdateAgent(Resource):
|
||||
if (
|
||||
new_prompt_id
|
||||
and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "")
|
||||
and not can_access(conn, "prompt", new_prompt_id, user)
|
||||
and not _ref_attachable(conn, "prompt", new_prompt_id, user)
|
||||
):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not accessible"}), 403
|
||||
)
|
||||
# A team editor must not attach tools they can't access onto a
|
||||
# shared agent: at run time the agent-key path resolves+decrypts
|
||||
# tools as the OWNER, so an unchecked tool here would let an
|
||||
# editor invoke arbitrary owner credentials. Owners are
|
||||
# unrestricted (they own their tools). Default/builtin synthetic
|
||||
# tool ids belong to no one and are always allowed.
|
||||
if is_team_editor and "tools" in update_fields:
|
||||
from docsgpt.agents.default_tools import is_synthesized_tool_id
|
||||
|
||||
# Tools run with the OWNER's credentials (the agent-key path
|
||||
# resolves and decrypts them as the owner), so a newly attached
|
||||
# tool must be the caller's own or reach them with
|
||||
# ``use_in_own`` -- the owner owning it is not enough. Tools
|
||||
# already on the agent stay. Builtin synthetic ids belong to no
|
||||
# one and are always allowed.
|
||||
if "tools" in update_fields:
|
||||
existing_tools = {
|
||||
str(t) for t in (existing_agent.get("tools") or [])
|
||||
}
|
||||
for tid in update_fields["tools"] or []:
|
||||
tid_s = str(tid)
|
||||
if tid_s in existing_tools or is_synthesized_tool_id(tid_s):
|
||||
if str(tid) in existing_tools:
|
||||
continue
|
||||
if not can_access(conn, "tool", tid_s, user):
|
||||
if not _tool_attachable(conn, tid, user):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Tool not accessible"}
|
||||
@@ -1257,19 +1406,25 @@ class UpdateAgent(Resource):
|
||||
403,
|
||||
)
|
||||
|
||||
# Per-agent quota lives on the row and is pooled across all
|
||||
# members; only the owner may resize that shared pool, so a
|
||||
# team editor's quota changes are dropped.
|
||||
if is_team_editor:
|
||||
for _q in (
|
||||
"token_limit", "request_limit",
|
||||
"limited_token_mode", "limited_request_mode",
|
||||
# Guardrails are the owner's policy for their agent.
|
||||
# An editor who could clear them would silently strip
|
||||
# protection from everyone else using it.
|
||||
"config",
|
||||
):
|
||||
update_fields.pop(_q, None)
|
||||
# A resource the owner can't use runs as the editor who
|
||||
# attached it (its sponsor); record who that is.
|
||||
after_save = dict(existing_agent)
|
||||
for ref_field in ("source_id", "extra_source_ids", "prompt_id", "tools"):
|
||||
if ref_field in update_fields:
|
||||
after_save[ref_field] = update_fields[ref_field]
|
||||
sponsors = sponsors_after_save(
|
||||
conn, "agent", existing_agent, owner_id, user, agent_refs(after_save)
|
||||
)
|
||||
if sponsors != (existing_agent.get("resource_sponsors") or {}):
|
||||
update_fields["resource_sponsors"] = sponsors
|
||||
|
||||
# Guardrails and the pooled quota are policy: an unchanged
|
||||
# value re-sent by a full-form save is fine, a change needs
|
||||
# ``edit_policy``.
|
||||
if not ra.can("edit_policy") and _policy_changed(existing_agent, update_fields):
|
||||
return _denied(
|
||||
AccessDenied(403, "Your access doesn't allow changing guardrails or limits")
|
||||
)
|
||||
|
||||
# Apply update. Owner writes use the dual-key guard; team-editor
|
||||
# writes go by-id (already authorized) with an optimistic-lock
|
||||
@@ -1328,7 +1483,9 @@ class UpdateAgent(Resource):
|
||||
"id": pg_agent_id,
|
||||
"message": "Agent updated successfully",
|
||||
}
|
||||
if newly_generated_key:
|
||||
# A freshly minted key is an access detail: returned only to a caller
|
||||
# who may manage it (the key is still stored either way).
|
||||
if newly_generated_key and ra.can("manage_access_details"):
|
||||
response_data["key"] = (
|
||||
newly_generated_key
|
||||
if may_see_agent_keys(request)
|
||||
@@ -1358,10 +1515,13 @@ class RegenerateAgentKey(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
# Owner-only: rotating a credential is destructive to live
|
||||
# integrations, so this is intentionally stricter than
|
||||
# update_agent (which also allows team editors).
|
||||
existing_agent = agents_repo.get_any(agent_id, user)
|
||||
# Rotating the key is an access detail: the owner, or an editor
|
||||
# while ``editors_can_manage_access_details`` is on.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "manage_access_details")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
existing_agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not existing_agent:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -1389,7 +1549,7 @@ class RegenerateAgentKey(Resource):
|
||||
)
|
||||
|
||||
new_key = str(uuid.uuid4())
|
||||
updated = agents_repo.update(pg_agent_id, user, {"key": new_key})
|
||||
updated = agents_repo.update(pg_agent_id, ra.owner_id, {"key": new_key})
|
||||
if not updated:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -1460,7 +1620,12 @@ class DeleteAgent(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
agent = agents_repo.get_any(agent_id, user)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "delete")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
owner_id = ra.owner_id
|
||||
agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
@@ -1476,14 +1641,20 @@ class DeleteAgent(Resource):
|
||||
# that user's graph.
|
||||
if agent.get("agent_type") == "workflow" and workflow_id:
|
||||
try:
|
||||
WorkflowsRepository(conn).delete(str(workflow_id), user)
|
||||
WorkflowsRepository(conn).delete(str(workflow_id), owner_id)
|
||||
except Exception as wf_err:
|
||||
current_app.logger.warning(
|
||||
f"Workflow cleanup failed for agent {pg_agent_id}: {wf_err}"
|
||||
)
|
||||
agents_repo.delete(pg_agent_id, user)
|
||||
# Strip pinned/shared entries for this agent from the owner's prefs.
|
||||
UsersRepository(conn).remove_agent_from_all(user, pg_agent_id)
|
||||
# Team grants go with the row (AFTER DELETE trigger, 0021);
|
||||
# the switches table has no FK, so drop it explicitly.
|
||||
agents_repo.delete(pg_agent_id, owner_id)
|
||||
delete_settings(conn, "agent", pg_agent_id)
|
||||
# Strip pinned/shared entries for this agent from the owner's
|
||||
# (and the deleting editor's) prefs.
|
||||
users_repo = UsersRepository(conn)
|
||||
for uid in {owner_id, user}:
|
||||
users_repo.remove_agent_from_all(uid, pg_agent_id)
|
||||
record_event(
|
||||
conn,
|
||||
"agent.deleted",
|
||||
@@ -1579,10 +1750,19 @@ class PinnedAgents(Resource):
|
||||
for agent in pinned_agents
|
||||
if _user_may_pin(conn, agent, user_id, shared_with_me)
|
||||
]
|
||||
# A pin reached only through a share link (or a template)
|
||||
# has no grant: chat + pin, nothing more.
|
||||
access_by_id = {}
|
||||
for agent in pinned_agents:
|
||||
ra = resolve(conn, "agent", str(agent["id"]), user_id)
|
||||
access_by_id[str(agent["id"])] = (
|
||||
ra.payload() if ra is not None else dict(LINK_SHARED_ACCESS)
|
||||
)
|
||||
|
||||
list_pinned_agents = []
|
||||
for agent in pinned_agents:
|
||||
source_id = agent.get("source_id")
|
||||
access = access_by_id[str(agent["id"])]
|
||||
list_pinned_agents.append(
|
||||
{
|
||||
"id": str(agent["id"]),
|
||||
@@ -1608,10 +1788,12 @@ class PinnedAgents(Resource):
|
||||
"last_used_at": agent.get("last_used_at", ""),
|
||||
"key": (
|
||||
f"{agent['key'][:4]}...{agent['key'][-4:]}"
|
||||
if agent.get("key") and agent.get("user_id") == user_id
|
||||
if agent.get("key")
|
||||
and "manage_access_details" in access["allowed_actions"]
|
||||
else ""
|
||||
),
|
||||
"pinned": True,
|
||||
**access,
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import text as _sql_text
|
||||
from docsgpt.api import api
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.api.user.base import resolve_tool_details
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require, resolve
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
@@ -21,12 +22,38 @@ agents_sharing_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
# A caller who reached an agent only through its public link may chat with it
|
||||
# and pin it; a team grant, when there is one, gives more.
|
||||
LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]}
|
||||
|
||||
|
||||
def _link_access(conn, agent_id: str, user_id) -> dict:
|
||||
"""The ``access`` payload for an agent the caller reached by share link.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
agent_id: The agent's id.
|
||||
user_id: The caller, or None when anonymous.
|
||||
|
||||
Returns:
|
||||
The caller's own access (owner or a team grant) when they have one,
|
||||
else viewer with ``pin`` and ``use``.
|
||||
"""
|
||||
if user_id:
|
||||
ra = resolve(conn, "agent", agent_id, user_id)
|
||||
if ra is not None:
|
||||
return ra.payload()
|
||||
return dict(LINK_SHARED_ACCESS)
|
||||
|
||||
|
||||
def _serialize_agent_basic(agent: dict) -> dict:
|
||||
"""Shape a PG agent row into the API response dict."""
|
||||
"""Shape a PG agent row into the API response dict.
|
||||
|
||||
The owner's user id is deliberately not included: a share link is public.
|
||||
"""
|
||||
source_id = agent.get("source_id")
|
||||
return {
|
||||
"id": str(agent["id"]),
|
||||
"user": agent.get("user_id", ""),
|
||||
"name": agent.get("name", ""),
|
||||
"image": (
|
||||
generate_image_url(
|
||||
@@ -91,8 +118,8 @@ class SharedAgent(Resource):
|
||||
enriched_tools.append(detail.get("name", ""))
|
||||
data["tools"] = enriched_tools
|
||||
decoded_token = getattr(request, "decoded_token", None)
|
||||
if decoded_token:
|
||||
user_id = decoded_token.get("sub")
|
||||
user_id = decoded_token.get("sub") if decoded_token else None
|
||||
if user_id:
|
||||
owner_id = shared_agent.get("user_id")
|
||||
|
||||
if user_id != owner_id:
|
||||
@@ -100,6 +127,8 @@ class SharedAgent(Resource):
|
||||
users_repo = UsersRepository(conn)
|
||||
users_repo.upsert(user_id)
|
||||
users_repo.add_shared(user_id, agent_id)
|
||||
with db_readonly() as conn:
|
||||
data.update(_link_access(conn, agent_id, user_id))
|
||||
return make_response(jsonify(data), 200)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving shared agent: {err}")
|
||||
@@ -152,6 +181,10 @@ class SharedAgents(Resource):
|
||||
if isinstance(user_doc.get("agent_preferences"), dict)
|
||||
else []
|
||||
)
|
||||
access_by_id = {
|
||||
str(agent["id"]): _link_access(conn, str(agent["id"]), user_id)
|
||||
for agent in shared_agents
|
||||
}
|
||||
|
||||
list_shared_agents = []
|
||||
for agent in shared_agents:
|
||||
@@ -185,6 +218,7 @@ class SharedAgents(Resource):
|
||||
"shared": bool(agent.get("shared", False)),
|
||||
"shared_token": agent.get("shared_token", "") or "",
|
||||
"shared_metadata": agent.get("shared_metadata", {}) or {},
|
||||
**access_by_id[agent_id_str],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -244,7 +278,15 @@ class ShareAgent(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
# The public link is an access detail; it is written as the owner.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "manage_access_details")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
owner_id = ra.owner_id
|
||||
agent = repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
@@ -258,7 +300,7 @@ class ShareAgent(Resource):
|
||||
}
|
||||
shared_token = secrets.token_urlsafe(32)
|
||||
repo.update(
|
||||
str(agent["id"]), user,
|
||||
str(agent["id"]), owner_id,
|
||||
{
|
||||
"shared": True,
|
||||
"shared_token": shared_token,
|
||||
@@ -267,7 +309,7 @@ class ShareAgent(Resource):
|
||||
)
|
||||
else:
|
||||
repo.update(
|
||||
str(agent["id"]), user,
|
||||
str(agent["id"]), owner_id,
|
||||
{
|
||||
"shared": False,
|
||||
"shared_token": None,
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.base import require_agent
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.api.user.tasks import process_agent_webhook
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
@@ -71,7 +72,14 @@ class AgentWebhook(Resource):
|
||||
)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
agent = AgentsRepository(conn).get_any(agent_id, user)
|
||||
# The webhook URL is an access detail; it is minted as the owner.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "manage_access_details")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
@@ -81,7 +89,7 @@ class AgentWebhook(Resource):
|
||||
webhook_token = secrets.token_urlsafe(32)
|
||||
with db_session() as conn:
|
||||
AgentsRepository(conn).update(
|
||||
str(agent["id"]), user,
|
||||
str(agent["id"]), ra.owner_id,
|
||||
{"incoming_webhook_token": webhook_token},
|
||||
)
|
||||
base_url = settings.API_URL.rstrip("/")
|
||||
|
||||
@@ -8,6 +8,7 @@ from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import Connection, text as _sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied, resolve
|
||||
from docsgpt.api.user.base import (
|
||||
generate_date_range,
|
||||
generate_hourly_range,
|
||||
@@ -74,27 +75,38 @@ def _intervals_for_filter(filter_option, start_date, end_date):
|
||||
|
||||
|
||||
def _resolve_agent(conn, api_key_id, user_id):
|
||||
"""Owner-scoped agent lookup for analytics filters.
|
||||
"""Access-checked agent lookup for analytics filters.
|
||||
|
||||
Returns ``(agent, api_key, agent_pg_id)``. ``agent`` is ``None`` when
|
||||
the id doesn't resolve to one of the caller's agents — callers must
|
||||
the id doesn't resolve to an agent the caller can see — callers must
|
||||
short-circuit with an empty result, not fall back to sentinel filter
|
||||
values. ``api_key`` is ``None`` (never ``""``) for key-less agents:
|
||||
draft agents store ``key = ''``, and an ``''`` filter would match the
|
||||
``''`` that writers like ``stack_logs`` stamp on every key-less
|
||||
request — leaking rows across users. NULL matches nothing. Accepts
|
||||
UUID or legacy Mongo ObjectId ids.
|
||||
values. A visible agent needs ``view_logs`` (owner and editors; viewers
|
||||
when the owner turns on ``viewers_can_see_logs``), and the caller then
|
||||
sees exactly the owner's view of it. ``api_key`` is ``None`` (never
|
||||
``""``) for key-less agents: draft agents store ``key = ''``, and an
|
||||
``''`` filter would match the ``''`` that writers like ``stack_logs``
|
||||
stamp on every key-less request — leaking rows across users. NULL
|
||||
matches nothing. Accepts UUID or legacy Mongo ObjectId ids.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 403 when the agent is visible but ``view_logs`` isn't allowed.
|
||||
"""
|
||||
agent = (
|
||||
AgentsRepository(conn).get_any(api_key_id, user_id)
|
||||
if api_key_id
|
||||
else None
|
||||
)
|
||||
ra = resolve(conn, "agent", api_key_id, user_id) if api_key_id else None
|
||||
if ra is None:
|
||||
return None, None, None
|
||||
if not ra.can("view_logs"):
|
||||
raise AccessDenied(403, "Your access to this agent doesn't include its logs")
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
api_key = (agent or {}).get("key") or None
|
||||
agent_pg_id = str(agent["id"]) if agent else None
|
||||
return agent, api_key, agent_pg_id
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""The JSON error response for an :class:`AccessDenied`."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _trace_branch(name: str, sources_sql: str, scope: str) -> dict:
|
||||
"""A ``get_user_logs`` branch listing stored traces of the given sources."""
|
||||
return {
|
||||
@@ -238,6 +250,8 @@ class GetTraces(Resource):
|
||||
traces = RequestTracesRepository(conn).list_by_ref(
|
||||
field, value, user_id=user, agent_id=agent_pg_id
|
||||
)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error getting traces: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -342,6 +356,8 @@ class GetMessageAnalytics(Resource):
|
||||
daily_messages = {interval: 0 for interval in intervals}
|
||||
for row in rows:
|
||||
daily_messages[row._mapping["bucket"]] = int(row._mapping["count"])
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting message analytics: {err}", exc_info=True
|
||||
@@ -466,6 +482,8 @@ class GetTokenAnalytics(Resource):
|
||||
if key not in series:
|
||||
series[key] = {interval: 0 for interval in intervals}
|
||||
series[key][bucket] = series[key].get(bucket, 0) + total
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting token analytics: {err}", exc_info=True
|
||||
@@ -592,6 +610,8 @@ class GetFeedbackAnalytics(Resource):
|
||||
"positive": int(row._mapping["positive"] or 0),
|
||||
"negative": int(row._mapping["negative"] or 0),
|
||||
}
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting feedback analytics: {err}", exc_info=True
|
||||
@@ -710,6 +730,8 @@ class GetToolAnalytics(Resource):
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting tool analytics: {err}", exc_info=True
|
||||
@@ -825,6 +847,8 @@ class GetScheduleAnalytics(Resource):
|
||||
"failed": int(row._mapping["failed"] or 0),
|
||||
"skipped": int(row._mapping["skipped"] or 0),
|
||||
}
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting schedule analytics: {err}", exc_info=True
|
||||
@@ -927,7 +951,8 @@ class GetUserLogs(Resource):
|
||||
200,
|
||||
)
|
||||
params: dict = {
|
||||
"user_id": user,
|
||||
# Agent-scoped logs are the owner's view of the agent.
|
||||
"user_id": agent["user_id"] if agent else user,
|
||||
"limit": page_size + 1,
|
||||
"offset": (page - 1) * page_size,
|
||||
}
|
||||
@@ -1313,6 +1338,8 @@ class GetUserLogs(Resource):
|
||||
"Could not attach trace summaries to the logs page",
|
||||
exc_info=True,
|
||||
)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting user logs: {err}", exc_info=True
|
||||
|
||||
@@ -6,7 +6,14 @@ from flask_restx import fields, Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.team_sharing import team_access_for, visible_with_access
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
require,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.prompts.composer import compose_preset, is_composed_preset
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
@@ -17,6 +24,16 @@ prompts_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""JSON response for an :class:`AccessDenied` (403 or 404)."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _iso(value):
|
||||
"""ISO-8601 string for a timestamp (``expected_updated_at`` round-trips it)."""
|
||||
return value.isoformat() if hasattr(value, "isoformat") else value
|
||||
|
||||
|
||||
@prompts_ns.route("/create_prompt")
|
||||
class CreatePrompt(Resource):
|
||||
create_prompt_model = api.model(
|
||||
@@ -67,26 +84,35 @@ class GetPrompts(Resource):
|
||||
team_shared = visible_with_access(conn, user, "prompt")
|
||||
shared_ids = [pid for pid in team_shared if pid not in owned_ids]
|
||||
shared_prompts = repo.list_by_ids(shared_ids)
|
||||
switches = settings_many(
|
||||
conn, "prompt", [*owned_ids, *(str(p["id"]) for p in shared_prompts)]
|
||||
)
|
||||
list_prompts = [
|
||||
{"id": "default", "name": "default", "type": "public"},
|
||||
{"id": "creative", "name": "creative", "type": "public"},
|
||||
{"id": "strict", "name": "strict", "type": "public"},
|
||||
]
|
||||
for prompt in prompts:
|
||||
pid = str(prompt["id"])
|
||||
list_prompts.append(
|
||||
{
|
||||
"id": str(prompt["id"]),
|
||||
"id": pid,
|
||||
"name": prompt["name"],
|
||||
"type": "private",
|
||||
"updated_at": _iso(prompt.get("updated_at")),
|
||||
**payload_for("prompt", "owner", switches.get(pid)),
|
||||
}
|
||||
)
|
||||
for prompt in shared_prompts:
|
||||
pid = str(prompt["id"])
|
||||
list_prompts.append(
|
||||
{
|
||||
"id": str(prompt["id"]),
|
||||
"id": pid,
|
||||
"name": prompt["name"],
|
||||
"type": "team",
|
||||
"team_access": team_shared.get(str(prompt["id"])),
|
||||
"team_access": team_shared.get(pid),
|
||||
"updated_at": _iso(prompt.get("updated_at")),
|
||||
**payload_for("prompt", team_shared.get(pid), switches.get(pid)),
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
@@ -115,19 +141,28 @@ class GetSinglePrompt(Resource):
|
||||
jsonify({"content": compose_preset(prompt_id)}), 200
|
||||
)
|
||||
with db_readonly() as conn:
|
||||
repo = PromptsRepository(conn)
|
||||
prompt = repo.get_any(prompt_id, user)
|
||||
if not prompt and team_access_for(conn, user, "prompt", prompt_id):
|
||||
# Team fallback: ownerless fetch only after a grant check.
|
||||
prompt = repo.get_for_rendering(prompt_id)
|
||||
ra = require(conn, "prompt", prompt_id, user, "use")
|
||||
prompt = PromptsRepository(conn).get_any(ra.resource_id, ra.owner_id)
|
||||
if not prompt:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not found"}), 404
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return _denied(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving prompt: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
return make_response(jsonify({"content": prompt["content"]}), 200)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"content": prompt["content"],
|
||||
"name": prompt.get("name"),
|
||||
"updated_at": _iso(prompt.get("updated_at")),
|
||||
**ra.payload(),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@prompts_ns.route("/delete_prompt")
|
||||
@@ -151,14 +186,12 @@ class DeletePrompt(Resource):
|
||||
return missing_fields
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = PromptsRepository(conn)
|
||||
prompt = repo.get_any(data["id"], user)
|
||||
if not prompt:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not found"}),
|
||||
404,
|
||||
)
|
||||
repo.delete(str(prompt["id"]), user)
|
||||
ra = require(conn, "prompt", data["id"], user, "delete")
|
||||
# Grants go with the row (delete trigger); switches have no FK.
|
||||
PromptsRepository(conn).delete(ra.resource_id, ra.owner_id)
|
||||
delete_settings(conn, "prompt", ra.resource_id)
|
||||
except AccessDenied as err:
|
||||
return _denied(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error deleting prompt: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -192,43 +225,26 @@ class UpdatePrompt(Resource):
|
||||
return missing_fields
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = PromptsRepository(conn)
|
||||
prompt = repo.get_any(data["id"], user)
|
||||
if prompt:
|
||||
repo.update(str(prompt["id"]), user, data["name"], data["content"])
|
||||
else:
|
||||
# Team editor write path (viewer is read-only).
|
||||
access = team_access_for(conn, user, "prompt", data["id"])
|
||||
if access == "editor":
|
||||
result = repo.update_by_id(
|
||||
data["id"],
|
||||
data["name"],
|
||||
data["content"],
|
||||
expected_updated_at=data.get("expected_updated_at"),
|
||||
)
|
||||
if result is None:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Prompt was modified by someone else",
|
||||
"code": "stale_write",
|
||||
}
|
||||
),
|
||||
409,
|
||||
)
|
||||
elif access == "viewer":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Read-only: editor access required"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not found"}),
|
||||
404,
|
||||
)
|
||||
ra = require(conn, "prompt", data["id"], user, "edit")
|
||||
result = PromptsRepository(conn).update_by_id(
|
||||
ra.resource_id,
|
||||
data["name"],
|
||||
data["content"],
|
||||
expected_updated_at=data.get("expected_updated_at"),
|
||||
)
|
||||
if result is None:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Prompt was modified by someone else",
|
||||
"code": "stale_write",
|
||||
}
|
||||
),
|
||||
409,
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return _denied(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error updating prompt: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
"""The one access check for team-shared resources: roles, actions and switches.
|
||||
|
||||
Every shareable resource (``agent``, ``source``, ``tool``, ``prompt``) has an
|
||||
owner and may be shared to teams as ``viewer`` or ``editor``. What each role
|
||||
may do is a fixed table of *actions* per resource type (``ACTIONS``). The
|
||||
owner can adjust a few of those rows on one resource with *switches*
|
||||
(``SWITCHES``), stored in ``resource_share_settings``. A switch only ever moves
|
||||
one action between two roles; it never touches owner-only actions such as
|
||||
``manage_settings``.
|
||||
|
||||
Routes ask one question, ``require(conn, type, id, user, action)``, and get
|
||||
back a :class:`ResourceAccess` (whose ``owner_id`` is the id to write as) or
|
||||
an :class:`AccessDenied` carrying 404 (not visible) or 403 (visible, but the
|
||||
role may not do this). List and get responses embed ``ResourceAccess.payload()``
|
||||
(``access`` + ``allowed_actions``) so the frontend never re-derives the rules.
|
||||
|
||||
Access is resolved live on every call (grants JOIN ``team_members``), so a
|
||||
revoked grant or membership denies on the next request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.storage.db.base_repository import canonical_uuid, looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RESOURCE_TYPES = ("agent", "source", "tool", "prompt")
|
||||
|
||||
# Weakest role that may perform each action by default. ``owner`` rows are
|
||||
# owner-only unless a switch below moves them.
|
||||
ACTIONS: dict[str, dict[str, str]] = {
|
||||
"agent": {
|
||||
"use": "viewer", # chat with it
|
||||
"pin": "viewer",
|
||||
"view": "editor", # open the edit page and read its full config
|
||||
"edit": "editor",
|
||||
"publish": "editor",
|
||||
"edit_policy": "editor", # guardrails and quotas
|
||||
"view_logs": "editor",
|
||||
"manage_schedules": "editor",
|
||||
"export": "editor",
|
||||
"manage_access_details": "editor", # API key, webhook, public link
|
||||
"move_folder": "owner",
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
"source": {
|
||||
"use": "viewer", # browse files, chunks, graph, wiki; test retrieval; attach to agents
|
||||
"view_config": "editor",
|
||||
"edit": "editor", # chunks, files, wiki, config, sync, reingest, GraphRAG, convert
|
||||
"reconnect": "owner", # change the connector account
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
"tool": {
|
||||
"use": "viewer", # see it and run it inside the owner's shared agents
|
||||
"use_in_own": "viewer", # add it to my own agents and chats
|
||||
"edit": "editor", # name, action descriptions, parameters, approval
|
||||
"edit_credentials": "editor", # secrets, URL, auth (write-only); OAuth servers stay owner-only
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
"prompt": {
|
||||
"use": "viewer", # read it and use it in my own agents
|
||||
"duplicate": "editor",
|
||||
"edit": "editor",
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Switch:
|
||||
"""One owner switch: moves ``action`` to ``role_on`` or ``role_off``."""
|
||||
|
||||
key: str
|
||||
default: bool
|
||||
action: str
|
||||
role_on: str
|
||||
role_off: str
|
||||
|
||||
|
||||
# Order is the order the share dialog lists them in.
|
||||
SWITCHES: dict[str, tuple[Switch, ...]] = {
|
||||
"agent": (
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("editors_can_delete", False, "delete", "editor", "owner"),
|
||||
Switch("editors_can_manage_access_details", True, "manage_access_details", "editor", "owner"),
|
||||
Switch("viewers_can_see_logs", False, "view_logs", "viewer", "editor"),
|
||||
),
|
||||
"source": (
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("editors_can_delete", False, "delete", "editor", "owner"),
|
||||
Switch("viewers_can_see_config", True, "view_config", "viewer", "editor"),
|
||||
),
|
||||
"tool": (
|
||||
Switch("editors_can_change_credentials", True, "edit_credentials", "editor", "owner"),
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("viewers_can_use_in_agents", True, "use_in_own", "viewer", "editor"),
|
||||
),
|
||||
"prompt": (
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("viewers_can_duplicate", True, "duplicate", "viewer", "editor"),
|
||||
),
|
||||
}
|
||||
|
||||
_RANK = {"viewer": 1, "editor": 2, "owner": 3}
|
||||
|
||||
_REPO_FOR_TYPE = {
|
||||
"agent": AgentsRepository,
|
||||
"source": SourcesRepository,
|
||||
"prompt": PromptsRepository,
|
||||
"tool": UserToolsRepository,
|
||||
}
|
||||
|
||||
|
||||
class AccessDenied(Exception):
|
||||
"""Raised by :func:`require`; ``status`` is 404 (not visible) or 403."""
|
||||
|
||||
def __init__(self, status: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def default_settings(resource_type: str) -> dict[str, bool]:
|
||||
"""Every switch of ``resource_type`` at its default."""
|
||||
return {s.key: s.default for s in SWITCHES.get(resource_type, ())}
|
||||
|
||||
|
||||
def _merge(resource_type: str, stored: Optional[dict]) -> dict[str, bool]:
|
||||
merged = default_settings(resource_type)
|
||||
for key, value in (stored or {}).items():
|
||||
if key in merged and isinstance(value, bool):
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def role_table(resource_type: str, settings: Optional[dict]) -> dict[str, str]:
|
||||
"""``action -> weakest role`` for one resource, switches applied."""
|
||||
table = dict(ACTIONS[resource_type])
|
||||
merged = _merge(resource_type, settings)
|
||||
for switch in SWITCHES.get(resource_type, ()):
|
||||
table[switch.action] = switch.role_on if merged[switch.key] else switch.role_off
|
||||
return table
|
||||
|
||||
|
||||
def allowed_actions(
|
||||
resource_type: str, access: Optional[str], settings: Optional[dict]
|
||||
) -> set[str]:
|
||||
"""The actions ``access`` may perform on a resource with these switches."""
|
||||
if access not in _RANK or resource_type not in ACTIONS:
|
||||
return set()
|
||||
rank = _RANK[access]
|
||||
return {action for action, role in role_table(resource_type, settings).items() if rank >= _RANK[role]}
|
||||
|
||||
|
||||
def public_settings(resource_type: str, settings: Optional[dict]) -> list[dict]:
|
||||
"""The switches as ``[{key, value, default}]`` in display order."""
|
||||
merged = _merge(resource_type, settings)
|
||||
return [
|
||||
{"key": s.key, "value": merged[s.key], "default": s.default}
|
||||
for s in SWITCHES.get(resource_type, ())
|
||||
]
|
||||
|
||||
|
||||
def settings_for(conn: Connection, resource_type: str, resource_id: str) -> dict[str, bool]:
|
||||
"""The resource's switches, defaults filled in."""
|
||||
rid = canonical_uuid(str(resource_id))
|
||||
return settings_many(conn, resource_type, [rid])[rid]
|
||||
|
||||
|
||||
def settings_many(
|
||||
conn: Connection, resource_type: str, resource_ids: Iterable[str]
|
||||
) -> dict[str, dict[str, bool]]:
|
||||
"""``resource_id -> switches`` for many resources in one query.
|
||||
|
||||
Keys are canonical (lowercase) UUIDs, the form Postgres returns.
|
||||
"""
|
||||
ids = [canonical_uuid(str(r)) for r in resource_ids]
|
||||
out = {rid: default_settings(resource_type) for rid in ids}
|
||||
uuids = [rid for rid in ids if looks_like_uuid(rid)]
|
||||
if not uuids:
|
||||
return out
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT resource_id, settings FROM resource_share_settings
|
||||
WHERE resource_type = :t AND resource_id = ANY(CAST(:ids AS uuid[]))
|
||||
"""
|
||||
),
|
||||
{"t": resource_type, "ids": uuids},
|
||||
).fetchall()
|
||||
for rid, stored in rows:
|
||||
out[str(rid)] = _merge(resource_type, stored)
|
||||
return out
|
||||
|
||||
|
||||
def set_settings(
|
||||
conn: Connection, resource_type: str, resource_id: str, changes: dict, updated_by: str
|
||||
) -> dict[str, bool]:
|
||||
"""Merge ``changes`` into the resource's switches and return the result.
|
||||
|
||||
Raises:
|
||||
ValueError: an unknown key or a non-boolean value.
|
||||
"""
|
||||
known = default_settings(resource_type)
|
||||
for key, value in changes.items():
|
||||
if key not in known:
|
||||
raise ValueError(f"Unknown setting: {key}")
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"Setting {key} must be true or false")
|
||||
merged = {**settings_for(conn, resource_type, resource_id), **changes}
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO resource_share_settings (resource_type, resource_id, settings, updated_by)
|
||||
VALUES (:t, CAST(:id AS uuid), CAST(:s AS jsonb), :by)
|
||||
ON CONFLICT (resource_type, resource_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, updated_by = EXCLUDED.updated_by,
|
||||
updated_at = now()
|
||||
"""
|
||||
),
|
||||
{"t": resource_type, "id": resource_id, "s": json.dumps(merged), "by": updated_by},
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def delete_settings(conn: Connection, resource_type: str, resource_id: str) -> None:
|
||||
"""Drop a deleted resource's switches (the table has no FK to cascade)."""
|
||||
if looks_like_uuid(resource_id):
|
||||
conn.execute(
|
||||
text("DELETE FROM resource_share_settings WHERE resource_type = :t AND resource_id = CAST(:id AS uuid)"),
|
||||
{"t": resource_type, "id": resource_id},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceAccess:
|
||||
"""What one user may do on one resource."""
|
||||
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
access: str # owner | editor | viewer
|
||||
owner_id: str # the id to read and write the resource as
|
||||
settings: dict = field(default_factory=dict)
|
||||
actions: frozenset = frozenset()
|
||||
|
||||
def can(self, action: str) -> bool:
|
||||
return action in self.actions
|
||||
|
||||
def payload(self) -> dict:
|
||||
"""The fields every API response embeds for this resource."""
|
||||
return {"access": self.access, "allowed_actions": sorted(self.actions)}
|
||||
|
||||
|
||||
def build(resource_type: str, resource_id: str, access: str, owner_id: str, settings: dict) -> ResourceAccess:
|
||||
"""A :class:`ResourceAccess` from already-known parts (list endpoints)."""
|
||||
merged = _merge(resource_type, settings)
|
||||
return ResourceAccess(
|
||||
resource_type=resource_type,
|
||||
resource_id=str(resource_id),
|
||||
access=access,
|
||||
owner_id=owner_id,
|
||||
settings=merged,
|
||||
actions=frozenset(allowed_actions(resource_type, access, merged)),
|
||||
)
|
||||
|
||||
|
||||
def payload_for(resource_type: str, access: Optional[str], settings: Optional[dict]) -> dict:
|
||||
"""``access`` + ``allowed_actions`` without an owner lookup (list endpoints)."""
|
||||
return {
|
||||
"access": access,
|
||||
"allowed_actions": sorted(allowed_actions(resource_type, access, settings)),
|
||||
}
|
||||
|
||||
|
||||
def resolve(
|
||||
conn: Connection, resource_type: str, resource_id: str, user_id: str
|
||||
) -> Optional[ResourceAccess]:
|
||||
"""The caller's access to a resource, or None when they can't see it."""
|
||||
repo_cls = _REPO_FOR_TYPE.get(resource_type)
|
||||
if repo_cls is None or not resource_id or not user_id:
|
||||
return None
|
||||
# Postgres matches any casing but returns lowercase; canonicalise so the
|
||||
# switch lookup (keyed by the returned id) can't miss.
|
||||
resource_id = canonical_uuid(str(resource_id))
|
||||
owned = repo_cls(conn).get_any(resource_id, user_id)
|
||||
if owned is not None:
|
||||
rid = str(owned.get("id") or resource_id)
|
||||
return build(resource_type, rid, "owner", user_id, settings_for(conn, resource_type, rid))
|
||||
# Only canonical UUIDs can carry a grant; casting anything else would
|
||||
# poison the transaction.
|
||||
if not looks_like_uuid(str(resource_id)):
|
||||
return None
|
||||
level = TeamScopeRepository(conn).effective_access(user_id, resource_type, str(resource_id))
|
||||
if level is None:
|
||||
return None
|
||||
grants = TeamResourceGrantsRepository(conn).list_for_resource(resource_type, str(resource_id))
|
||||
if not grants:
|
||||
return None
|
||||
# Every grant row carries the same denormalised owner id.
|
||||
owner_id = grants[0].get("owner_id")
|
||||
return build(resource_type, str(resource_id), level, owner_id, settings_for(conn, resource_type, str(resource_id)))
|
||||
|
||||
|
||||
def require(
|
||||
conn: Connection, resource_type: str, resource_id: str, user_id: str, action: str
|
||||
) -> ResourceAccess:
|
||||
"""Resolve and check one action.
|
||||
|
||||
Raises:
|
||||
KeyError: ``action`` is not an action of ``resource_type`` (a bug).
|
||||
AccessDenied: 404 when the resource isn't visible, 403 when the
|
||||
caller's role may not perform ``action``.
|
||||
"""
|
||||
if action not in ACTIONS.get(resource_type, {}):
|
||||
raise KeyError(f"{resource_type} has no action {action!r}")
|
||||
ra = resolve(conn, resource_type, resource_id, user_id)
|
||||
if ra is None:
|
||||
raise AccessDenied(404, f"{resource_type.capitalize()} not found")
|
||||
if not ra.can(action):
|
||||
raise AccessDenied(403, "Your access to this item doesn't allow that")
|
||||
return ra
|
||||
|
||||
|
||||
# --- Resource sponsors ------------------------------------------------------
|
||||
#
|
||||
# An agent (or workflow) runs as its owner, so a source, prompt or tool it
|
||||
# references is authorized against the owner. When a team editor attaches one
|
||||
# the owner can't use, the editor becomes its *sponsor*: the holder row's
|
||||
# ``resource_sponsors`` maps ``"<type>:<id>"`` to the editor's id, and at run
|
||||
# time the resource is authorized as the sponsor while they can still edit the
|
||||
# holder and still use the resource. A tool still runs with its own row's
|
||||
# credentials (the tool owner's), whoever the principal is.
|
||||
|
||||
# Action a principal needs on a referenced resource for a holder to run it.
|
||||
REF_USE_ACTION = {"source": "use", "prompt": "use", "tool": "use_in_own"}
|
||||
|
||||
|
||||
def sponsor_key(resource_type: str, resource_id: str) -> str:
|
||||
"""The ``resource_sponsors`` key for one referenced resource."""
|
||||
return f"{resource_type}:{resource_id}"
|
||||
|
||||
|
||||
def can_use_ref(conn: Connection, resource_type: str, resource_id: str, user_id: Optional[str]) -> bool:
|
||||
"""Whether ``user_id`` may have ``resource_id`` run inside something they hold.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
resource_type: ``source``, ``prompt`` or ``tool``.
|
||||
resource_id: The referenced id.
|
||||
user_id: The would-be principal.
|
||||
|
||||
Returns:
|
||||
True when the user owns the resource or a team grant gives them
|
||||
``use`` (``use_in_own`` for a tool).
|
||||
"""
|
||||
if not user_id or not resource_id:
|
||||
return False
|
||||
ra = resolve(conn, resource_type, str(resource_id), user_id)
|
||||
return ra is not None and ra.can(REF_USE_ACTION[resource_type])
|
||||
|
||||
|
||||
def _holder_editable_by(conn: Connection, holder_type: str, holder: dict, user_id: str) -> bool:
|
||||
"""Whether ``user_id`` may still edit the agent or workflow ``holder``.
|
||||
|
||||
A workflow is edited through an agent of its owner that uses it, so the
|
||||
check is ``edit`` on any such agent (mirrors the workflow routes).
|
||||
"""
|
||||
if holder_type == "agent":
|
||||
ra = resolve(conn, "agent", str(holder["id"]), user_id)
|
||||
return ra is not None and ra.can("edit")
|
||||
if holder_type == "workflow":
|
||||
agent_ids = conn.execute(
|
||||
text("SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"),
|
||||
{"wid": str(holder["id"]), "owner": holder.get("user_id")},
|
||||
).scalars().all()
|
||||
for agent_id in agent_ids:
|
||||
ra = resolve(conn, "agent", str(agent_id), user_id)
|
||||
if ra is not None and ra.can("edit"):
|
||||
return True
|
||||
return False
|
||||
raise ValueError(f"Unknown sponsor holder type: {holder_type}")
|
||||
|
||||
|
||||
def active_sponsor(
|
||||
conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
|
||||
) -> Optional[str]:
|
||||
"""The sponsor a holder may run ``resource_id`` as, checked live.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
holder_type: ``agent`` or ``workflow``.
|
||||
holder: The holder row (needs ``id``, ``user_id``, ``resource_sponsors``).
|
||||
resource_type: ``source``, ``prompt`` or ``tool``.
|
||||
resource_id: The referenced id.
|
||||
|
||||
Returns:
|
||||
The sponsor's id when one is recorded, still edits the holder and
|
||||
can still use the resource; else None.
|
||||
"""
|
||||
if not holder or not resource_id:
|
||||
return None
|
||||
sponsor = (holder.get("resource_sponsors") or {}).get(sponsor_key(resource_type, str(resource_id)))
|
||||
if not sponsor or sponsor == holder.get("user_id"):
|
||||
return None
|
||||
try:
|
||||
if not _holder_editable_by(conn, holder_type, holder, sponsor):
|
||||
return None
|
||||
return sponsor if can_use_ref(conn, resource_type, str(resource_id), sponsor) else None
|
||||
except Exception:
|
||||
logger.exception("Sponsor check failed for %s %s", resource_type, resource_id)
|
||||
return None
|
||||
|
||||
|
||||
def ref_principal(
|
||||
conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
|
||||
) -> Optional[str]:
|
||||
"""The user a holder's referenced resource is authorized as, or None.
|
||||
|
||||
The owner when they may use it (the default), else a live sponsor.
|
||||
"""
|
||||
if not holder:
|
||||
return None
|
||||
owner = holder.get("user_id")
|
||||
if can_use_ref(conn, resource_type, str(resource_id), owner):
|
||||
return owner
|
||||
return active_sponsor(conn, holder_type, holder, resource_type, resource_id)
|
||||
|
||||
|
||||
def _sponsorable(resource_type: str, resource_id: str) -> bool:
|
||||
"""Only real rows need a principal: skip presets and builtin tool ids."""
|
||||
rid = str(resource_id or "")
|
||||
if not looks_like_uuid(rid):
|
||||
return False
|
||||
if resource_type == "tool":
|
||||
# Lazy: default_tools imports this module lazily too.
|
||||
from docsgpt.agents.default_tools import is_synthesized_tool_id
|
||||
|
||||
return not is_synthesized_tool_id(rid)
|
||||
return True
|
||||
|
||||
|
||||
def agent_refs(agent: dict) -> list[tuple[str, str]]:
|
||||
"""The ``(type, id)`` resources an agent row references."""
|
||||
refs = [("source", str(s)) for s in [agent.get("source_id"), *(agent.get("extra_source_ids") or [])] if s]
|
||||
if agent.get("prompt_id"):
|
||||
refs.append(("prompt", str(agent["prompt_id"])))
|
||||
refs.extend(("tool", str(t)) for t in agent.get("tools") or [] if t)
|
||||
return refs
|
||||
|
||||
|
||||
def sponsors_after_save(
|
||||
conn: Connection,
|
||||
holder_type: str,
|
||||
holder: Optional[dict],
|
||||
owner_id: str,
|
||||
caller: str,
|
||||
refs: Iterable[tuple[str, str]],
|
||||
) -> dict[str, str]:
|
||||
"""The ``resource_sponsors`` map to store after ``caller`` saves ``refs``.
|
||||
|
||||
Per referenced resource the owner can't use: a recorded sponsor who still
|
||||
qualifies is kept; otherwise the caller takes it over when they can use it
|
||||
(a new attachment, or one whose sponsor lost access); otherwise the old
|
||||
record is kept so the editor can show who added it. Resources the owner
|
||||
can use, presets and builtin tools need no sponsor. Removed refs drop out.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
holder_type: ``agent`` or ``workflow``.
|
||||
holder: The holder row before the save (None when creating it).
|
||||
owner_id: The holder's owner.
|
||||
caller: The user saving.
|
||||
refs: Every ``(type, id)`` the holder references after the save.
|
||||
|
||||
Returns:
|
||||
dict: ``"<type>:<id>" -> user_id``.
|
||||
"""
|
||||
previous = (holder or {}).get("resource_sponsors") or {}
|
||||
out: dict[str, str] = {}
|
||||
for resource_type, resource_id in refs:
|
||||
key = sponsor_key(resource_type, resource_id)
|
||||
if key in out or not _sponsorable(resource_type, resource_id):
|
||||
continue
|
||||
if can_use_ref(conn, resource_type, resource_id, owner_id):
|
||||
continue
|
||||
if active_sponsor(conn, holder_type, holder, resource_type, resource_id):
|
||||
out[key] = previous[key]
|
||||
elif caller != owner_id and can_use_ref(conn, resource_type, resource_id, caller):
|
||||
out[key] = caller
|
||||
elif previous.get(key):
|
||||
out[key] = previous[key]
|
||||
return out
|
||||
|
||||
|
||||
def sponsor_details(conn: Connection, holder_type: str, holder: dict) -> list[dict]:
|
||||
"""The holder's sponsored resources for its edit page.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
holder_type: ``agent`` or ``workflow``.
|
||||
holder: The holder row.
|
||||
|
||||
Returns:
|
||||
list: ``{type, id, user_id, label, active}`` per sponsored resource;
|
||||
``label`` is the sponsor's email when on file, ``active`` whether it
|
||||
runs (the sponsor still edits the holder and can use the resource).
|
||||
"""
|
||||
sponsors = holder.get("resource_sponsors") or {}
|
||||
if not sponsors:
|
||||
return []
|
||||
user_ids = sorted({u for u in sponsors.values() if u})
|
||||
labels = dict(
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
|
||||
"AND email IS NOT NULL AND email <> ''"
|
||||
),
|
||||
{"ids": user_ids},
|
||||
).fetchall()
|
||||
) if user_ids else {}
|
||||
out = []
|
||||
for key, user_id in sponsors.items():
|
||||
resource_type, _, resource_id = key.partition(":")
|
||||
if resource_type not in REF_USE_ACTION or not resource_id:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"type": resource_type,
|
||||
"id": resource_id,
|
||||
"user_id": user_id,
|
||||
"label": labels.get(user_id) or user_id,
|
||||
"active": active_sponsor(conn, holder_type, holder, resource_type, resource_id) is not None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -206,6 +206,33 @@ def _append_one_time_turn(
|
||||
return message
|
||||
|
||||
|
||||
def _scheduler_still_allowed(schedule: Dict[str, Any], agent_config: Dict[str, Any]) -> bool:
|
||||
"""Whether the schedule's user may still run this agent.
|
||||
|
||||
The run always executes as the agent's owner. A schedule stored under
|
||||
someone else (set from chat on a shared agent) needs that user to still
|
||||
see the agent — a live team grant, or a public link that is still on —
|
||||
so revoking a grant stops their schedules on the next tick.
|
||||
|
||||
Args:
|
||||
schedule: The schedule row.
|
||||
agent_config: The agent row (or the agentless ephemeral config).
|
||||
|
||||
Returns:
|
||||
True when the run may proceed.
|
||||
"""
|
||||
user_id = schedule.get("user_id")
|
||||
agent_id = agent_config.get("id")
|
||||
if not agent_id or not user_id or agent_config.get("user_id") == user_id:
|
||||
return True
|
||||
if agent_config.get("shared"):
|
||||
return True
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
|
||||
with get_engine().connect() as conn:
|
||||
return resolve(conn, "agent", str(agent_id), user_id) is not None
|
||||
|
||||
|
||||
def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Dict[str, Any]:
|
||||
"""Execute one scheduled run by id; returns a result dict for tracing."""
|
||||
if not settings.POSTGRES_URI:
|
||||
@@ -256,6 +283,22 @@ def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Di
|
||||
error="agent missing")
|
||||
return {"status": "failed", "reason": "agent missing"}
|
||||
|
||||
if not _scheduler_still_allowed(schedule, agent_config):
|
||||
with engine.begin() as conn:
|
||||
updated = ScheduleRunsRepository(conn).update(
|
||||
run_id,
|
||||
{
|
||||
"status": "failed",
|
||||
"finished_at": datetime.now(timezone.utc),
|
||||
"error_type": "internal",
|
||||
"error": "agent access revoked",
|
||||
},
|
||||
)
|
||||
SchedulesRepository(conn).bump_failure_count(str(schedule["id"]))
|
||||
_publish_run_event("schedule.run.failed", updated or run, schedule,
|
||||
error="agent access revoked")
|
||||
return {"status": "failed", "reason": "agent access revoked"}
|
||||
|
||||
with engine.begin() as conn:
|
||||
if not ScheduleRunsRepository(conn).mark_running(run_id, celery_task_id):
|
||||
return {"status": "skipped", "reason": "lost race to mark_running"}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
"""Schedules REST API (owner-scoped via request.decoded_token)."""
|
||||
"""Schedules REST API.
|
||||
|
||||
A schedule on an agent belongs to the agent's owner: it is stored (and runs)
|
||||
under the owner's ``user_id`` whoever creates it, and managing it needs
|
||||
``manage_schedules`` on the agent (owner and team editors). A schedule the
|
||||
caller made themselves (e.g. via the chat scheduler tool on a shared agent)
|
||||
stays theirs to manage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,6 +27,7 @@ from docsgpt.agents.scheduler_utils import (
|
||||
resolve_timezone,
|
||||
)
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
@@ -103,11 +111,70 @@ def _format_run(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _agent_owned(agent_id: str, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
def _agent_for_schedules(agent_id: str, user_id: str) -> tuple[Dict[str, Any], str]:
|
||||
"""The agent row and the id its schedules live under.
|
||||
|
||||
Args:
|
||||
agent_id: Agent id from the URL.
|
||||
user_id: The caller.
|
||||
|
||||
Returns:
|
||||
``(agent, owner_id)``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when the agent isn't visible, 403 without
|
||||
``manage_schedules``.
|
||||
"""
|
||||
if not looks_like_uuid(str(agent_id)):
|
||||
return None
|
||||
raise AccessDenied(404, "agent not found")
|
||||
with db_readonly() as conn:
|
||||
return AgentsRepository(conn).get_any(agent_id, user_id)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user_id, "manage_schedules")
|
||||
except AccessDenied as denied:
|
||||
if denied.status == 404:
|
||||
raise AccessDenied(404, "agent not found")
|
||||
raise
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if agent is None:
|
||||
raise AccessDenied(404, "agent not found")
|
||||
return agent, ra.owner_id
|
||||
|
||||
|
||||
def _schedule_for(conn, schedule_id: str, user_id: str) -> tuple[Dict[str, Any], str]:
|
||||
"""Fetch a schedule the caller may manage, and the id to act as.
|
||||
|
||||
The caller's own schedule is always theirs. Otherwise it must be a
|
||||
schedule of an agent they hold ``manage_schedules`` on, stored under
|
||||
that agent's owner (another member's private schedule stays hidden).
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
schedule_id: Schedule UUID.
|
||||
user_id: The caller.
|
||||
|
||||
Returns:
|
||||
``(schedule, acting_user_id)``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't manage it.
|
||||
"""
|
||||
row = SchedulesRepository(conn).get_internal(schedule_id)
|
||||
if row is None:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
if row.get("user_id") == user_id:
|
||||
return row, user_id
|
||||
agent_id = row.get("agent_id")
|
||||
if not agent_id:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
try:
|
||||
ra = require(conn, "agent", str(agent_id), user_id, "manage_schedules")
|
||||
except AccessDenied as denied:
|
||||
if denied.status == 404:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
raise
|
||||
if row.get("user_id") != ra.owner_id:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
return row, ra.owner_id
|
||||
|
||||
|
||||
def _user_id() -> Optional[str]:
|
||||
@@ -150,13 +217,14 @@ class AgentSchedules(Resource):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
agent, owner_id = _agent_for_schedules(agent_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
rows = SchedulesRepository(conn).list_for_agent(
|
||||
str(agent["id"]), user_id,
|
||||
str(agent["id"]), owner_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
current_app.logger.error("list schedules failed: %s", exc, exc_info=True)
|
||||
@@ -195,9 +263,10 @@ class AgentSchedules(Resource):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
agent, owner_id = _agent_for_schedules(agent_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
data = request.get_json(silent=True) or {}
|
||||
instruction = (data.get("instruction") or "").strip()
|
||||
tz_name = (data.get("timezone") or "UTC").strip() or "UTC"
|
||||
@@ -219,7 +288,7 @@ class AgentSchedules(Resource):
|
||||
except (TypeError, ValueError):
|
||||
return _err("token_budget must be a non-negative integer")
|
||||
with db_readonly() as conn:
|
||||
count = SchedulesRepository(conn).count_active_for_user(user_id)
|
||||
count = SchedulesRepository(conn).count_active_for_user(owner_id)
|
||||
if (
|
||||
settings.SCHEDULE_MAX_PER_USER > 0
|
||||
and count >= settings.SCHEDULE_MAX_PER_USER
|
||||
@@ -240,7 +309,7 @@ class AgentSchedules(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
created = SchedulesRepository(conn).create(
|
||||
user_id=user_id,
|
||||
user_id=owner_id,
|
||||
agent_id=str(agent["id"]),
|
||||
trigger_type="once",
|
||||
instruction=instruction,
|
||||
@@ -295,7 +364,7 @@ class AgentSchedules(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
created = SchedulesRepository(conn).create(
|
||||
user_id=user_id,
|
||||
user_id=owner_id,
|
||||
agent_id=str(agent["id"]),
|
||||
trigger_type="recurring",
|
||||
instruction=instruction,
|
||||
@@ -337,9 +406,10 @@ class AgentScheduleStats(Resource):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
agent, owner_id = _agent_for_schedules(agent_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
try:
|
||||
days = max(1, min(int(request.args.get("days", 30)), 365))
|
||||
except (TypeError, ValueError):
|
||||
@@ -347,7 +417,7 @@ class AgentScheduleStats(Resource):
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
stats = ScheduleRunsRepository(conn).stats_for_agent(
|
||||
str(agent["id"]), user_id, days=days,
|
||||
str(agent["id"]), owner_id, days=days,
|
||||
)
|
||||
except Exception as exc:
|
||||
current_app.logger.error(
|
||||
@@ -377,9 +447,10 @@ class ScheduleResource(Resource):
|
||||
if not looks_like_uuid(schedule_id):
|
||||
return _err("invalid schedule id", 400)
|
||||
with db_readonly() as conn:
|
||||
row = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if row is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
row, _acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
return _ok({"schedule": _format_schedule(row)})
|
||||
|
||||
@api.doc(description="Edit a schedule's editable fields.")
|
||||
@@ -439,9 +510,10 @@ class ScheduleResource(Resource):
|
||||
fields_in["end_at"] = None
|
||||
# Recompute next_run_at when cron/tz changes.
|
||||
with db_session() as conn:
|
||||
existing = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if existing is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
existing, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
if (
|
||||
("cron" in fields_in or "timezone" in fields_in)
|
||||
and existing.get("trigger_type") == "recurring"
|
||||
@@ -467,7 +539,7 @@ class ScheduleResource(Resource):
|
||||
except ScheduleValidationError as exc:
|
||||
return _err(str(exc))
|
||||
updated = SchedulesRepository(conn).update(
|
||||
schedule_id, user_id, fields_in,
|
||||
schedule_id, acting, fields_in,
|
||||
)
|
||||
return _ok({"schedule": _format_schedule(updated or {})})
|
||||
|
||||
@@ -484,9 +556,10 @@ class ScheduleResource(Resource):
|
||||
if action not in {"pause", "resume"}:
|
||||
return _err("action must be 'pause' or 'resume'")
|
||||
with db_session() as conn:
|
||||
existing = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if existing is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
existing, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
if existing.get("status") in ("cancelled", "completed"):
|
||||
return _err("schedule is terminal", 409)
|
||||
if action == "pause":
|
||||
@@ -538,13 +611,13 @@ class ScheduleResource(Resource):
|
||||
)
|
||||
fields_in["next_run_at"] = run_at_dt
|
||||
updated = SchedulesRepository(conn).update(
|
||||
schedule_id, user_id, fields_in,
|
||||
schedule_id, acting, fields_in,
|
||||
)
|
||||
if action == "resume":
|
||||
SchedulesRepository(conn).reset_failure_count(schedule_id)
|
||||
if action == "resume" and updated:
|
||||
_publish_schedule_event(
|
||||
user_id, "schedule.resumed", schedule_id, status="active",
|
||||
acting, "schedule.resumed", schedule_id, status="active",
|
||||
)
|
||||
return _ok({"schedule": _format_schedule(updated or {})})
|
||||
|
||||
@@ -557,11 +630,15 @@ class ScheduleResource(Resource):
|
||||
if not looks_like_uuid(schedule_id):
|
||||
return _err("invalid schedule id", 400)
|
||||
with db_session() as conn:
|
||||
ok = SchedulesRepository(conn).delete(schedule_id, user_id)
|
||||
try:
|
||||
_row, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
ok = SchedulesRepository(conn).delete(schedule_id, acting)
|
||||
if not ok:
|
||||
return _err("schedule not found", 404)
|
||||
_publish_schedule_event(
|
||||
user_id, "schedule.cancelled", schedule_id, status="cancelled",
|
||||
acting, "schedule.cancelled", schedule_id, status="cancelled",
|
||||
)
|
||||
return _ok({"success": True})
|
||||
|
||||
@@ -579,8 +656,12 @@ class ScheduleRunNow(Resource):
|
||||
# FOR UPDATE serializes concurrent Run-Now POSTs (timestamp-unique
|
||||
# scheduled_for values would otherwise sneak past the unique index).
|
||||
with db_session() as conn:
|
||||
try:
|
||||
_row, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
schedule = SchedulesRepository(conn).get_for_update(
|
||||
schedule_id, user_id,
|
||||
schedule_id, acting,
|
||||
)
|
||||
if schedule is None:
|
||||
return _err("schedule not found", 404)
|
||||
@@ -590,9 +671,10 @@ class ScheduleRunNow(Resource):
|
||||
return _err("a run is already in flight", 409)
|
||||
scheduled_for = datetime.now(timezone.utc)
|
||||
agent_id_raw = schedule.get("agent_id")
|
||||
# The run belongs to (and executes as) the schedule's owner.
|
||||
run = ScheduleRunsRepository(conn).record_pending(
|
||||
schedule_id,
|
||||
user_id,
|
||||
acting,
|
||||
str(agent_id_raw) if agent_id_raw else None,
|
||||
scheduled_for,
|
||||
trigger_source="manual",
|
||||
@@ -633,11 +715,12 @@ class ScheduleRunList(Resource):
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
with db_readonly() as conn:
|
||||
schedule = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if schedule is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
_schedule, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
rows = ScheduleRunsRepository(conn).list_runs(
|
||||
schedule_id, user_id, limit=limit, offset=offset,
|
||||
schedule_id, acting, limit=limit, offset=offset,
|
||||
)
|
||||
return _ok(
|
||||
{
|
||||
@@ -659,10 +742,11 @@ class ScheduleRunDetail(Resource):
|
||||
if not looks_like_uuid(schedule_id) or not looks_like_uuid(run_id):
|
||||
return _err("invalid id", 400)
|
||||
with db_readonly() as conn:
|
||||
schedule = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if schedule is None:
|
||||
return _err("schedule not found", 404)
|
||||
run = ScheduleRunsRepository(conn).get(run_id, user_id)
|
||||
try:
|
||||
schedule, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
run = ScheduleRunsRepository(conn).get(run_id, acting)
|
||||
if run is None or str(run.get("schedule_id")) != str(
|
||||
schedule["id"]
|
||||
):
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Role checks shared by the source routes (sources, chunks, upload, search).
|
||||
|
||||
Thin wrappers over :mod:`docsgpt.api.user.resource_access` so every source
|
||||
endpoint resolves the row the same way and answers denials with the same
|
||||
JSON shape: 404 when the caller can't see the source, 403 when they can but
|
||||
their role may not perform the action.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from flask import jsonify, make_response
|
||||
from sqlalchemy import Connection
|
||||
|
||||
from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
|
||||
def load_source(
|
||||
conn: Connection, source_id: Optional[str], user: str, action: str
|
||||
) -> tuple[dict, ResourceAccess]:
|
||||
"""Check ``action`` on a source and return its row with the caller's access.
|
||||
|
||||
The row is fetched by the canonical id only after the check passes, so a
|
||||
team member gets the owner's row without ownership scoping.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
source_id: Source id from the request (UUID or legacy id).
|
||||
user: The caller's ``sub``.
|
||||
action: A ``source`` action from ``resource_access.ACTIONS``.
|
||||
|
||||
Returns:
|
||||
tuple: ``(source_row, ResourceAccess)``; write as ``ra.owner_id``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't do it.
|
||||
"""
|
||||
if not source_id:
|
||||
raise AccessDenied(404, "Source not found")
|
||||
ra = require(conn, "source", str(source_id), user, action)
|
||||
doc = SourcesRepository(conn).get_by_id(ra.resource_id)
|
||||
if doc is None:
|
||||
raise AccessDenied(404, "Source not found")
|
||||
return doc, ra
|
||||
|
||||
|
||||
def denied_response(err: AccessDenied):
|
||||
"""The JSON error response for an :class:`AccessDenied`."""
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": err.message}), err.status
|
||||
)
|
||||
@@ -7,8 +7,8 @@ from flask_restx import fields, Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.base import get_vector_store
|
||||
from docsgpt.api.user.team_sharing import can_access, effective_write_owner
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.storage.db.session import db_readonly
|
||||
from docsgpt.utils import check_required_fields, num_tokens_from_string
|
||||
from docsgpt.vectorstore.base import InvalidChunkMetadataError
|
||||
@@ -18,38 +18,27 @@ sources_chunks_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_source(doc_id: str, user: str):
|
||||
"""Resolve a source (UUID or legacy ObjectId) the caller may READ.
|
||||
def _resolve_source(doc_id: str, user: str, action: str = "use") -> dict:
|
||||
"""Resolve a source (UUID or legacy ObjectId) the caller may ``action`` on.
|
||||
|
||||
Read access = owner or any team grant (viewer/editor). Returns the row
|
||||
dict (with PG UUID in ``id``) or ``None`` if missing or not visible.
|
||||
``use`` (browse chunks) is open to every role; ``edit`` (add / delete /
|
||||
update chunks) needs owner or team editor. The vector partition is keyed
|
||||
by source id, so a team editor's write needs no owner id.
|
||||
|
||||
Args:
|
||||
doc_id: Source id from the request.
|
||||
user: The caller's ``sub``.
|
||||
action: ``use`` for reads, ``edit`` for chunk writes.
|
||||
|
||||
Returns:
|
||||
dict: The source row (PG UUID in ``id``).
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't do it.
|
||||
"""
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(doc_id, user)
|
||||
if doc is not None:
|
||||
return doc
|
||||
if not can_access(conn, "source", doc_id, user):
|
||||
return None
|
||||
return SourcesRepository(conn).get_by_id(doc_id)
|
||||
|
||||
|
||||
def _resolve_source_for_write(doc_id: str, user: str):
|
||||
"""Resolve a source the caller may WRITE chunks on.
|
||||
|
||||
Returns the row dict when ``user`` owns the source, or when they hold a
|
||||
team ``editor`` grant (adding/removing/editing documents is editor-allowed
|
||||
— the vector partition is keyed by source_id, owner-agnostic). Returns
|
||||
``None`` for viewer-only / no access. Source deletion stays owner-only and
|
||||
is handled elsewhere.
|
||||
"""
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(doc_id, user)
|
||||
if doc is not None:
|
||||
return doc
|
||||
owner = effective_write_owner(conn, "source", doc_id, user)
|
||||
if not owner:
|
||||
return None
|
||||
return SourcesRepository(conn).get_any(doc_id, owner)
|
||||
doc, _ra = load_source(conn, doc_id, user, action)
|
||||
return doc
|
||||
|
||||
|
||||
def _remap_graph_chunk(doc: dict, old_chunk_id: str, new_chunk_id: str) -> None:
|
||||
@@ -193,13 +182,11 @@ class GetChunks(Resource):
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
try:
|
||||
doc = _resolve_source(doc_id, user)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"error": "Document not found or access denied"}), 404
|
||||
)
|
||||
resolved_id = str(doc["id"])
|
||||
try:
|
||||
store = get_vector_store(resolved_id)
|
||||
@@ -278,12 +265,12 @@ class AddChunk(Resource):
|
||||
metadata["token_count"] = token_count
|
||||
|
||||
try:
|
||||
doc = _resolve_source_for_write(doc_id, user)
|
||||
doc = _resolve_source(doc_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"error": "Source not accessible"}), 403)
|
||||
try:
|
||||
store = get_vector_store(str(doc["id"]))
|
||||
chunk_id = store.add_chunk(text, metadata)
|
||||
@@ -311,12 +298,12 @@ class DeleteChunk(Resource):
|
||||
chunk_id = request.args.get("chunk_id")
|
||||
|
||||
try:
|
||||
doc = _resolve_source_for_write(doc_id, user)
|
||||
doc = _resolve_source(doc_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"error": "Source not accessible"}), 403)
|
||||
try:
|
||||
store = get_vector_store(str(doc["id"]))
|
||||
deleted = store.delete_chunk(chunk_id)
|
||||
@@ -378,12 +365,12 @@ class UpdateChunk(Resource):
|
||||
metadata = {}
|
||||
metadata["token_count"] = token_count
|
||||
try:
|
||||
doc = _resolve_source_for_write(doc_id, user)
|
||||
doc = _resolve_source(doc_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"error": "Source not accessible"}), 403)
|
||||
try:
|
||||
store = get_vector_store(str(doc["id"]))
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ from flask_restx import fields, Namespace, Resource
|
||||
from pydantic import ValidationError
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.sources.routes import _resolve_readable_source
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.core.model_utils import get_default_model_id
|
||||
from docsgpt.retriever.dispatcher import Dispatcher
|
||||
from docsgpt.retriever.retriever_creator import RetrieverCreator
|
||||
@@ -102,21 +103,16 @@ class SourceSearch(Resource):
|
||||
# Read access = owner or any team grant (viewer included), matching
|
||||
# the other source read endpoints (wiki pages, graph).
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
# An unresolvable id yields None (→ 404); reaching here means the
|
||||
# An unresolvable id is AccessDenied (404); reaching here means the
|
||||
# lookup itself failed, which is ours, not the caller's.
|
||||
logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Could not resolve source"}), 500
|
||||
)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not found or access denied"}
|
||||
),
|
||||
404,
|
||||
)
|
||||
resolved_id = str(doc["id"])
|
||||
|
||||
# A supplied config is validated exactly as strictly as a saved one (D7
|
||||
|
||||
+145
-177
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import math
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from flask import current_app, jsonify, make_response, redirect, request
|
||||
from flask_restx import fields, Namespace, Resource
|
||||
@@ -19,11 +20,14 @@ from docsgpt.api.user.tasks import (
|
||||
reingest_source_task,
|
||||
sync_source,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import (
|
||||
can_access,
|
||||
effective_write_owner,
|
||||
visible_with_access,
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.graphrag import graphrag_available
|
||||
from docsgpt.parser.remote.remote_creator import normalize_remote_data
|
||||
@@ -70,6 +74,31 @@ def _get_provider_from_remote_data(remote_data):
|
||||
return None
|
||||
|
||||
|
||||
def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -> dict:
|
||||
"""Add ``access`` + ``allowed_actions`` to a listed source row.
|
||||
|
||||
The source's behaviour ``config`` is cut down to its ``kind`` when the
|
||||
caller's role may not ``view_config`` (a viewer when the owner turned
|
||||
``viewers_can_see_config`` off). ``kind`` stays because the UI needs it to
|
||||
pick the wiki / graph view.
|
||||
|
||||
Args:
|
||||
entry: The row as the list endpoint builds it.
|
||||
access: ``owner`` / ``editor`` / ``viewer``.
|
||||
switches: The source's owner switches (``settings_many`` output).
|
||||
|
||||
Returns:
|
||||
dict: ``entry`` with the access payload merged in.
|
||||
"""
|
||||
payload = payload_for("source", access, switches)
|
||||
if "view_config" not in payload["allowed_actions"]:
|
||||
config = entry.get("config")
|
||||
if config is not None:
|
||||
entry["config"] = {"kind": (config or {}).get("kind", "classic")}
|
||||
entry.update(payload)
|
||||
return entry
|
||||
|
||||
|
||||
@sources_ns.route("/sources")
|
||||
class CombinedJson(Resource):
|
||||
@api.doc(description="Provide JSON file with combined available indexes")
|
||||
@@ -93,6 +122,7 @@ class CombinedJson(Resource):
|
||||
team_shared = visible_with_access(conn, user, "source")
|
||||
shared_ids = [sid for sid in team_shared if sid not in owned_ids]
|
||||
shared_sources = repo.list_by_ids(shared_ids)
|
||||
switches = settings_many(conn, "source", [*owned_ids, *shared_ids])
|
||||
# list_for_user sorts by created_at DESC; legacy shape sorted by
|
||||
# "date" DESC. Both are monotonic on creation so the ordering is
|
||||
# equivalent for dev; re-sort defensively.
|
||||
@@ -103,7 +133,7 @@ class CombinedJson(Resource):
|
||||
|
||||
def _source_entry(index, *, ownership="user", team_access=None):
|
||||
provider = _get_provider_from_remote_data(index.get("remote_data"))
|
||||
return {
|
||||
entry = {
|
||||
"id": str(index["id"]),
|
||||
"name": index.get("name"),
|
||||
"date": index.get("date"),
|
||||
@@ -121,6 +151,11 @@ class CombinedJson(Resource):
|
||||
"ownership": ownership,
|
||||
"team_access": team_access,
|
||||
}
|
||||
return _with_access(
|
||||
entry,
|
||||
"owner" if ownership == "user" else team_access,
|
||||
switches.get(str(index["id"])),
|
||||
)
|
||||
|
||||
for index in indexes:
|
||||
data.append(_source_entry(index))
|
||||
@@ -178,6 +213,9 @@ class PaginatedSources(Resource):
|
||||
sort_order=sort_order,
|
||||
extra_ids=extra_ids,
|
||||
)
|
||||
switches = settings_many(
|
||||
conn, "source", [str(doc["id"]) for doc in window]
|
||||
)
|
||||
|
||||
paginated_docs = []
|
||||
for doc in window:
|
||||
@@ -185,32 +223,37 @@ class PaginatedSources(Resource):
|
||||
# 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)
|
||||
entry = {
|
||||
"id": str(doc["id"]),
|
||||
"name": doc.get("name", ""),
|
||||
"date": doc.get("date", ""),
|
||||
"model": settings.EMBEDDINGS_NAME,
|
||||
"location": "local",
|
||||
"tokens": doc.get("tokens", ""),
|
||||
"retriever": doc.get("retriever", "classic"),
|
||||
"syncFrequency": doc.get("sync_frequency", ""),
|
||||
"provider": provider,
|
||||
"isNested": bool(doc.get("directory_structure")),
|
||||
"type": doc.get("type", "file"),
|
||||
# Lenient read (D7): always emit a fully-defaulted
|
||||
# config so the edit modal can pre-fill, even for a
|
||||
# legacy {} row.
|
||||
"config": SourceConfig.parse(
|
||||
doc.get("config")
|
||||
).model_dump(),
|
||||
# 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"]))
|
||||
),
|
||||
}
|
||||
paginated_docs.append(
|
||||
{
|
||||
"id": str(doc["id"]),
|
||||
"name": doc.get("name", ""),
|
||||
"date": doc.get("date", ""),
|
||||
"model": settings.EMBEDDINGS_NAME,
|
||||
"location": "local",
|
||||
"tokens": doc.get("tokens", ""),
|
||||
"retriever": doc.get("retriever", "classic"),
|
||||
"syncFrequency": doc.get("sync_frequency", ""),
|
||||
"provider": provider,
|
||||
"isNested": bool(doc.get("directory_structure")),
|
||||
"type": doc.get("type", "file"),
|
||||
# Lenient read (D7): always emit a fully-defaulted
|
||||
# config so the edit modal can pre-fill, even for a
|
||||
# legacy {} row.
|
||||
"config": SourceConfig.parse(
|
||||
doc.get("config")
|
||||
).model_dump(),
|
||||
# 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"]))
|
||||
),
|
||||
}
|
||||
_with_access(
|
||||
entry,
|
||||
"owner" if owned else team_shared.get(str(doc["id"])),
|
||||
switches.get(str(doc["id"])),
|
||||
)
|
||||
)
|
||||
response = {
|
||||
"total": total_documents,
|
||||
@@ -242,14 +285,17 @@ class DeleteOldIndexes(Resource):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Missing required fields"}), 400
|
||||
)
|
||||
# Owner-only unless the owner turned ``editors_can_delete`` on; the
|
||||
# row is deleted as the owner either way.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
doc, ra = load_source(conn, source_id, user, "delete")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"status": "not found"}), 404)
|
||||
owner = ra.owner_id
|
||||
storage = StorageCreator.get_storage()
|
||||
resolved_id = str(doc["id"])
|
||||
|
||||
@@ -283,13 +329,17 @@ class DeleteOldIndexes(Resource):
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
SourcesRepository(conn).delete(resolved_id, user)
|
||||
# The AFTER DELETE trigger drops the source's team grants; the
|
||||
# owner switches have no FK, so clear them here.
|
||||
SourcesRepository(conn).delete(resolved_id, owner)
|
||||
delete_settings(conn, "source", resolved_id)
|
||||
record_event(
|
||||
conn,
|
||||
"source.deleted",
|
||||
actor=user,
|
||||
source_id=resolved_id,
|
||||
name=doc.get("name"),
|
||||
owner=owner if owner != user else None,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -342,21 +392,13 @@ class ManageSync(Resource):
|
||||
)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = SourcesRepository(conn)
|
||||
doc = repo.get_any(source_id, user)
|
||||
if doc is not None:
|
||||
repo.update(str(doc["id"]), user, {"sync_frequency": sync_frequency})
|
||||
else:
|
||||
# Team editor write path (sync_frequency is metadata, no
|
||||
# ingestion side effects). Reingest/sync triggers stay
|
||||
# owner-only pending a cost/side-effect decision.
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
repo.update(source_id, owner, {"sync_frequency": sync_frequency})
|
||||
# Owner or team editor; the write lands as the owner.
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
SourcesRepository(conn).update(
|
||||
str(doc["id"]), ra.owner_id, {"sync_frequency": sync_frequency}
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating sync frequency: {err}", exc_info=True
|
||||
@@ -391,21 +433,15 @@ class SyncSource(Resource):
|
||||
# source_id (owner-agnostic), so dispatching as the owner is correct.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
owner = user
|
||||
if doc is None:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if owner:
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid source ID"}), 400
|
||||
)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not accessible"}), 403
|
||||
)
|
||||
owner = ra.owner_id
|
||||
source_type = doc.get("type", "")
|
||||
if source_type and source_type.startswith("connector"):
|
||||
return make_response(
|
||||
@@ -469,12 +505,9 @@ class ReingestSource(Resource):
|
||||
# (owner-agnostic), so dispatching as the owner is correct.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
owner = user
|
||||
if doc is None:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if owner:
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error looking up source: {err}", exc_info=True
|
||||
@@ -482,10 +515,7 @@ class ReingestSource(Resource):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid source ID"}), 400
|
||||
)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not accessible"}), 403
|
||||
)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
# Drop the stale chunk-progress row so the sources list stops
|
||||
# deriving a 'failed' status; reingest never rewrites it itself.
|
||||
@@ -548,11 +578,7 @@ class DirectoryStructure(Resource):
|
||||
return make_response(jsonify({"error": "Document ID is required"}), 400)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, doc_id, user)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"error": "Document not found or access denied"}), 404
|
||||
)
|
||||
doc, _ra = load_source(conn, doc_id, user, "use")
|
||||
directory_structure = doc.get("directory_structure", {})
|
||||
base_path = doc.get("file_path", "")
|
||||
|
||||
@@ -579,6 +605,8 @@ class DirectoryStructure(Resource):
|
||||
),
|
||||
200,
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
f"Error retrieving directory structure: {e}", exc_info=True
|
||||
@@ -636,23 +664,9 @@ class SourceConfigResource(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = SourcesRepository(conn)
|
||||
# Resolve the owner to write AS: ``user`` when they own the
|
||||
# source, the real owner when ``user`` holds a team ``editor``
|
||||
# grant. A viewer / no-access resolves to None → 403.
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = repo.get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; the write lands as the owner.
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
owner = ra.owner_id
|
||||
# Ingest-time fields (config.chunking) only take effect after a
|
||||
# re-ingest (D8); compare against the current config to decide.
|
||||
current_config = SourceConfig.parse(doc.get("config"))
|
||||
@@ -683,6 +697,8 @@ class SourceConfigResource(Resource):
|
||||
repo.update(
|
||||
str(doc["id"]), owner, {"config": new_config.model_dump()}
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating source config for {source_id}: {err}", exc_info=True
|
||||
@@ -734,20 +750,6 @@ def _unsupported_retrieval_warnings(config) -> list:
|
||||
return warnings
|
||||
|
||||
|
||||
def _resolve_readable_source(conn, source_id, user):
|
||||
"""Return a source dict the caller may READ, or None.
|
||||
|
||||
Read access = owner or any team grant (viewer/editor). Resolves the row
|
||||
without ownership scoping only after the grant check passes.
|
||||
"""
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
if doc is not None:
|
||||
return doc
|
||||
if not can_access(conn, "source", source_id, user):
|
||||
return None
|
||||
return SourcesRepository(conn).get_by_id(source_id)
|
||||
|
||||
|
||||
def _wiki_page_node(page):
|
||||
return {
|
||||
"path": page.get("path"),
|
||||
@@ -886,12 +888,10 @@ class WikiPages(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
pages = WikiPagesRepository(conn).list_for_source(str(doc["id"]))
|
||||
directory_structure = doc.get("directory_structure") or {}
|
||||
except Exception as err:
|
||||
@@ -934,12 +934,10 @@ class WikiPage(Resource):
|
||||
)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
page = WikiPagesRepository(conn).get_by_path(str(doc["id"]), path)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -977,20 +975,12 @@ class WikiPage(Resource):
|
||||
expected_version = data.get("expected_version")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; writes and re-embeds run as the owner.
|
||||
try:
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
try:
|
||||
page = WikiPagesRepository(conn).upsert(
|
||||
@@ -1074,20 +1064,12 @@ class ConvertSourceToWiki(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; writes and re-embeds run as the owner.
|
||||
try:
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
# A mid-ingest source has an incomplete directory structure, so
|
||||
# it could be mis-detected as blank and wrongly enabled inline.
|
||||
@@ -1194,20 +1176,12 @@ class EnableSourceGraphRAG(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; writes and re-embeds run as the owner.
|
||||
try:
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
cfg = SourceConfig.parse(doc.get("config"))
|
||||
repo = SourcesRepository(conn)
|
||||
@@ -1314,12 +1288,10 @@ class SourceGraph(Resource):
|
||||
limit = None
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
resolved_source_id = str(doc["id"])
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -1449,12 +1421,10 @@ class SourceGraphNodes(Resource):
|
||||
type_key = request.args.get("type")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
resolved_source_id = str(doc["id"])
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -1500,12 +1470,10 @@ class SourceGraphNode(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
resolved_source_id = str(doc["id"])
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
|
||||
@@ -14,7 +14,8 @@ from sqlalchemy import text as sql_text
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.user.tasks import ingest, ingest_connector_task, ingest_remote
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.source_ids import derive_source_id as _derive_source_id
|
||||
from docsgpt.parser.connectors.connector_creator import ConnectorCreator
|
||||
@@ -721,22 +722,10 @@ class ManageSourceFiles(Resource):
|
||||
# (owner-agnostic), so running the ops as the owner is correct.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
source = SourcesRepository(conn).get_any(source_id, user)
|
||||
owner = user
|
||||
if source is None:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if owner:
|
||||
source = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if not source:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Source not found or access denied",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
source, ra = load_source(conn, source_id, user, "edit")
|
||||
owner = ra.owner_id
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error finding source: {err}", exc_info=True)
|
||||
return make_response(
|
||||
|
||||
@@ -13,13 +13,10 @@ from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection
|
||||
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
@@ -89,22 +86,14 @@ def effective_write_owner(
|
||||
...)`` repo methods (which match on ``WHERE id AND user_id = :owner``) without
|
||||
a separate ownerless write path. None means viewer-only or no access → the
|
||||
route should answer 403/404. Delete is never authorized here — owner-only.
|
||||
|
||||
A thin wrapper over :func:`resource_access.resolve`; new code should call
|
||||
``resource_access.require`` with a specific action instead.
|
||||
"""
|
||||
if owns_resource(conn, resource_type, resource_id, user_id):
|
||||
return user_id
|
||||
# Past the ownership check, only canonical-UUID resources can carry a team
|
||||
# grant; a legacy/non-UUID id can't, and casting it would poison the txn.
|
||||
if not looks_like_uuid(resource_id):
|
||||
ra = resolve(conn, resource_type, resource_id, user_id)
|
||||
if ra is None or ra.access not in ("owner", "editor"):
|
||||
return None
|
||||
grants = TeamResourceGrantsRepository(conn).list_for_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
if not grants:
|
||||
return None
|
||||
if TeamScopeRepository(conn).can_write(user_id, resource_type, resource_id):
|
||||
# All grant rows carry the same denormalised owner_id.
|
||||
return grants[0].get("owner_id")
|
||||
return None
|
||||
return ra.owner_id
|
||||
|
||||
|
||||
def can_access(
|
||||
@@ -116,9 +105,9 @@ def can_access(
|
||||
``source_id`` to an agent): you may reference what you own or what a team has
|
||||
shared with you directly. Transitive access *through* a shared agent is a
|
||||
separate, run-time concept and is intentionally NOT gated here.
|
||||
|
||||
A thin wrapper over :func:`resource_access.resolve`.
|
||||
"""
|
||||
if not resource_id:
|
||||
return True
|
||||
if owns_resource(conn, resource_type, resource_id, user_id):
|
||||
return True
|
||||
return TeamScopeRepository(conn).can_read(user_id, resource_type, resource_id)
|
||||
return resolve(conn, resource_type, resource_id, user_id) is not None
|
||||
@@ -6,9 +6,12 @@ Authorization model (two planes, see ``team_authz.py``):
|
||||
- Team detail / member list / grant list require team membership.
|
||||
- Member management and team edit require ``team_admin``.
|
||||
- Team deletion and owner transfer are owner-only (a global ``admin`` overrides).
|
||||
- Sharing a resource requires the caller to OWN it (dispatched by resource_type)
|
||||
and be a member of the target team. Sharing is additive visibility — the
|
||||
resource's owner is never changed.
|
||||
- Sharing a resource requires the ``share`` action on it (the owner, or an
|
||||
editor when the owner turned on ``editors_can_share``; see
|
||||
``resource_access.py``) and membership of the target team. Unsharing needs
|
||||
``share`` (no membership required) or ``team_admin`` of the team. Sharing is
|
||||
additive visibility — the resource's owner is never changed.
|
||||
- The team owner can't be demoted or removed; they transfer ownership first.
|
||||
|
||||
``team_id`` always comes from the URL path (never the body) — enforced by
|
||||
``require_team_role`` and by reading ``team_id`` as a route kwarg here.
|
||||
@@ -22,14 +25,25 @@ import uuid
|
||||
|
||||
from flask import jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.api.user.authz import ROLE_ADMIN, has_role
|
||||
from docsgpt.api.user.resource_access import (
|
||||
RESOURCE_TYPES,
|
||||
AccessDenied,
|
||||
ResourceAccess,
|
||||
build,
|
||||
public_settings,
|
||||
require,
|
||||
set_settings,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_authz import (
|
||||
has_team_role,
|
||||
team_admin_required,
|
||||
team_member_required,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import is_valid_resource_type, owns_resource
|
||||
from docsgpt.api.user.team_sharing import is_valid_resource_type
|
||||
from docsgpt.events.publisher import publish_user_event
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
@@ -44,6 +58,7 @@ from docsgpt.storage.db.repositories.team_members import (
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
@@ -62,6 +77,99 @@ def _current_user() -> str | None:
|
||||
return token.get("sub") if isinstance(token, dict) else None
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""JSON response for an :class:`AccessDenied` (404 not visible, 403 not allowed)."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _team_payload(team: dict, user: str | None) -> dict:
|
||||
"""Add ``is_owner`` so the UI can gate owner-only actions (delete, transfer)."""
|
||||
team["is_owner"] = bool(user) and team.get("owner_id") == user
|
||||
return team
|
||||
|
||||
|
||||
# Name + owner of each shareable resource, looked up unscoped by id (the grant
|
||||
# row already proves it was shared; the caller's own access is computed below).
|
||||
_RESOURCE_ROW_SQL = {
|
||||
"agent": "SELECT id, name, user_id FROM agents WHERE id = ANY(CAST(:ids AS uuid[]))",
|
||||
"source": "SELECT id, name, user_id FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))",
|
||||
"prompt": "SELECT id, name, user_id FROM prompts WHERE id = ANY(CAST(:ids AS uuid[]))",
|
||||
"tool": (
|
||||
"SELECT id, COALESCE(NULLIF(custom_name, ''), NULLIF(display_name, ''), name) AS name, "
|
||||
"user_id FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[]))"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _resource_rows(conn, grants: list[dict]) -> dict[tuple[str, str], dict]:
|
||||
"""``(type, id) -> {name, user_id}`` for every resource the grants point at."""
|
||||
ids_by_type: dict[str, set[str]] = {}
|
||||
for g in grants:
|
||||
ids_by_type.setdefault(g["resource_type"], set()).add(str(g["resource_id"]))
|
||||
out: dict[tuple[str, str], dict] = {}
|
||||
for rtype, ids in ids_by_type.items():
|
||||
sql = _RESOURCE_ROW_SQL.get(rtype)
|
||||
if not sql:
|
||||
continue
|
||||
for rid, name, owner in conn.execute(text(sql), {"ids": list(ids)}).fetchall():
|
||||
out[(rtype, str(rid))] = {"name": name, "user_id": owner}
|
||||
return out
|
||||
|
||||
|
||||
def _caller_access(
|
||||
conn, user: str, grants: list[dict], rows: dict[tuple[str, str], dict]
|
||||
) -> dict[tuple[str, str], ResourceAccess]:
|
||||
"""The caller's live access to each granted resource, in a few bulk queries.
|
||||
|
||||
Same answer as ``resource_access.resolve`` per resource (owner by the
|
||||
resource's ``user_id``, else the strongest team grant reaching the caller),
|
||||
without four queries per row.
|
||||
"""
|
||||
scope = TeamScopeRepository(conn)
|
||||
out: dict[tuple[str, str], ResourceAccess] = {}
|
||||
for rtype in {g["resource_type"] for g in grants}:
|
||||
ids = sorted({str(g["resource_id"]) for g in grants if g["resource_type"] == rtype})
|
||||
via_teams = scope.visible_with_access(user, rtype)
|
||||
settings = settings_many(conn, rtype, ids)
|
||||
for rid in ids:
|
||||
row = rows.get((rtype, rid))
|
||||
if row is None:
|
||||
continue # dangling grant: the resource is gone
|
||||
if row["user_id"] == user:
|
||||
level = "owner"
|
||||
else:
|
||||
level = via_teams.get(rid)
|
||||
if level is None:
|
||||
continue
|
||||
out[(rtype, rid)] = build(rtype, rid, level, row["user_id"], settings[rid])
|
||||
return out
|
||||
|
||||
|
||||
def _user_labels(conn, user_ids: set[str]) -> dict[str, str]:
|
||||
"""``user_id -> email`` for the users on file with an email."""
|
||||
ids = [u for u in user_ids if u]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
|
||||
"AND email IS NOT NULL AND email <> ''"
|
||||
),
|
||||
{"ids": ids},
|
||||
).fetchall()
|
||||
return {uid: email for uid, email in rows}
|
||||
|
||||
|
||||
def _valid_resource(resource_type, resource_id) -> bool:
|
||||
"""A known shareable type and a canonical-UUID id (grants are UUID-only)."""
|
||||
return (
|
||||
is_valid_resource_type(resource_type)
|
||||
and bool(resource_id)
|
||||
and isinstance(resource_id, str)
|
||||
and looks_like_uuid(resource_id)
|
||||
)
|
||||
|
||||
|
||||
# Metadata keys naming the user a team event acted on, most specific first.
|
||||
# Lets ``_audit`` fill ``target_id`` without every call site repeating it.
|
||||
_TARGET_METADATA_KEYS = ("target_user", "target_user_id", "new_owner")
|
||||
@@ -218,6 +326,7 @@ class Teams(Resource):
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
teams = TeamsRepository(conn).list_for_user(user)
|
||||
teams = [_team_payload(t, user) for t in teams]
|
||||
return make_response(jsonify({"success": True, "teams": teams}), 200)
|
||||
except Exception as err:
|
||||
logger.error("List teams failed: %s", err, exc_info=True)
|
||||
@@ -243,6 +352,7 @@ class Teams(Resource):
|
||||
)
|
||||
_audit(conn, user, "team.create", team_id=team["id"], name=name)
|
||||
team["member_role"] = ROLE_TEAM_ADMIN
|
||||
_team_payload(team, user)
|
||||
return make_response(jsonify({"success": True, "team": team}), 201)
|
||||
except Exception as err:
|
||||
logger.error("Create team failed: %s", err, exc_info=True)
|
||||
@@ -263,6 +373,7 @@ class Team(Resource):
|
||||
members = TeamMembersRepository(conn)
|
||||
team["members"] = members.list_members(team_id)
|
||||
team["member_role"] = members.role_for(user, team_id)
|
||||
_team_payload(team, user)
|
||||
return make_response(jsonify({"success": True, "team": team}), 200)
|
||||
except Exception as err:
|
||||
logger.error("Get team failed: %s", err, exc_info=True)
|
||||
@@ -370,6 +481,10 @@ class TeamMember(Resource):
|
||||
return {"success": False, "message": "invalid role"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
if role == ROLE_TEAM_MEMBER:
|
||||
blocked = self._owner_guard(conn, team_id, member_id, "demote")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
members = TeamMembersRepository(conn)
|
||||
if role == ROLE_TEAM_MEMBER and self._would_orphan_admins(
|
||||
members, team_id, member_id
|
||||
@@ -403,6 +518,9 @@ class TeamMember(Resource):
|
||||
return {"success": False, "message": "Forbidden"}, 403
|
||||
try:
|
||||
with db_session() as conn:
|
||||
blocked = self._owner_guard(conn, team_id, member_id, "remove")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
members = TeamMembersRepository(conn)
|
||||
if self._would_orphan_admins(members, team_id, member_id):
|
||||
return {
|
||||
@@ -423,6 +541,38 @@ class TeamMember(Resource):
|
||||
logger.error("Remove member failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
@staticmethod
|
||||
def _owner_guard(conn, team_id: str, member_id: str, change: str):
|
||||
"""Refuse to demote or remove the team owner.
|
||||
|
||||
Another admin gets 403; the owner themselves gets 400 telling them to
|
||||
transfer ownership first (the team would otherwise have an owner who
|
||||
isn't an admin, or isn't a member at all).
|
||||
|
||||
Args:
|
||||
conn: Open connection.
|
||||
team_id: Team from the URL path.
|
||||
member_id: The member being changed.
|
||||
change: ``"demote"`` or ``"remove"`` (for the message).
|
||||
|
||||
Returns:
|
||||
A response to return, or None when the change may proceed.
|
||||
"""
|
||||
team = TeamsRepository(conn).get(team_id)
|
||||
if not team or team.get("owner_id") != member_id:
|
||||
return None
|
||||
if member_id == _current_user():
|
||||
message = (
|
||||
"Transfer team ownership to another member before you leave the team"
|
||||
if change == "remove"
|
||||
else "Transfer team ownership to another member before you step down as admin"
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": message}), 400)
|
||||
verb = "removed" if change == "remove" else "demoted"
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"The team owner can't be {verb}"}), 403
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _would_orphan_admins(
|
||||
members: TeamMembersRepository, team_id: str, member_id: str
|
||||
@@ -443,21 +593,66 @@ class TeamMember(Resource):
|
||||
class TeamGrants(Resource):
|
||||
@team_member_required
|
||||
def get(self, team_id):
|
||||
"""List resources shared with this team. Requires membership."""
|
||||
"""List resources shared with this team. Requires membership.
|
||||
|
||||
Each row carries the resource's name, owner and people labels (email
|
||||
when on file, else null) and ``caller`` — the caller's own live
|
||||
``{access, allowed_actions}`` on that resource (null if none). A plain
|
||||
member sees whole-team grants and grants aimed at them; a team_admin,
|
||||
or someone with ``share`` on the resource, sees every grant.
|
||||
"""
|
||||
user = _current_user()
|
||||
token = getattr(request, "decoded_token", None)
|
||||
resource_type = request.args.get("resource_type")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
grants = TeamResourceGrantsRepository(conn).list_for_team(
|
||||
team_id, resource_type
|
||||
)
|
||||
return make_response(jsonify({"success": True, "grants": grants}), 200)
|
||||
team_role = TeamMembersRepository(conn).role_for(user, team_id)
|
||||
sees_all = team_role == ROLE_TEAM_ADMIN or has_role(token, ROLE_ADMIN)
|
||||
rows = _resource_rows(conn, grants)
|
||||
access = _caller_access(conn, user, grants, rows)
|
||||
visible = []
|
||||
for g in grants:
|
||||
key = (g["resource_type"], str(g["resource_id"]))
|
||||
ra = access.get(key)
|
||||
target = g.get("target_user_id")
|
||||
if not (sees_all or not target or target == user or (ra and ra.can("share"))):
|
||||
continue
|
||||
row = rows.get(key) or {}
|
||||
g["resource_name"] = row.get("name")
|
||||
g["owner_id"] = row.get("user_id") or g.get("owner_id")
|
||||
g["caller"] = ra.payload() if ra else None
|
||||
visible.append(g)
|
||||
labels = _user_labels(
|
||||
conn,
|
||||
{
|
||||
u
|
||||
for g in visible
|
||||
for u in (g.get("owner_id"), g.get("target_user_id"), g.get("granted_by"))
|
||||
if u
|
||||
},
|
||||
)
|
||||
for g in visible:
|
||||
g["owner_label"] = labels.get(g.get("owner_id"))
|
||||
g["target_user_label"] = labels.get(g.get("target_user_id"))
|
||||
g["granted_by_label"] = labels.get(g.get("granted_by"))
|
||||
return make_response(
|
||||
jsonify({"success": True, "grants": visible, "team_role": team_role}), 200
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error("List grants failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
@team_member_required
|
||||
def post(self, team_id):
|
||||
"""Share a resource the caller OWNS with this team (additive visibility)."""
|
||||
"""Share a resource with this team, or change an existing grant's level.
|
||||
|
||||
Needs ``share`` on the resource (the owner, or an editor when the owner
|
||||
turned on ``editors_can_share``) and membership of the team. The grant
|
||||
records the real owner as ``owner_id`` and the caller as ``granted_by``.
|
||||
"""
|
||||
user = _current_user()
|
||||
data = request.get_json(silent=True) or {}
|
||||
resource_type = data.get("resource_type")
|
||||
@@ -465,20 +660,18 @@ class TeamGrants(Resource):
|
||||
access_level = data.get("access_level", "viewer")
|
||||
# None → share with the whole team; a sub → share with that one member.
|
||||
target_user_id = (data.get("target_user_id") or "").strip() or None
|
||||
if (
|
||||
not is_valid_resource_type(resource_type)
|
||||
or not resource_id
|
||||
or not looks_like_uuid(resource_id)
|
||||
):
|
||||
if not _valid_resource(resource_type, resource_id):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
if access_level not in _VALID_ACCESS_LEVELS:
|
||||
return {"success": False, "message": "invalid access_level"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
# Ownership is the security boundary: dispatch by resource_type so
|
||||
# a mismatched type/id can't register a bogus grant.
|
||||
if not owns_resource(conn, resource_type, resource_id, user):
|
||||
return {"success": False, "message": "Not the resource owner"}, 403
|
||||
# ``require`` dispatches by resource_type, so a mismatched
|
||||
# type/id can't register a bogus grant (the table has no FK).
|
||||
try:
|
||||
ra = require(conn, resource_type, resource_id, user, "share")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# A per-member share target must actually be a member of the team.
|
||||
if target_user_id and not TeamMembersRepository(conn).is_member(
|
||||
target_user_id, team_id
|
||||
@@ -487,8 +680,8 @@ class TeamGrants(Resource):
|
||||
grant = TeamResourceGrantsRepository(conn).grant(
|
||||
team_id,
|
||||
resource_type,
|
||||
resource_id,
|
||||
owner_id=user,
|
||||
ra.resource_id,
|
||||
owner_id=ra.owner_id,
|
||||
granted_by=user,
|
||||
access_level=access_level,
|
||||
target_user_id=target_user_id,
|
||||
@@ -512,15 +705,21 @@ class TeamGrants(Resource):
|
||||
logger.error("Share resource failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
@team_member_required
|
||||
def delete(self, team_id):
|
||||
"""Unshare a resource. Allowed for the resource owner or a team_admin.
|
||||
"""Unshare a resource from this team.
|
||||
|
||||
Identifiers come from query params (some proxies strip DELETE bodies),
|
||||
with a JSON-body fallback for older clients.
|
||||
Allowed with ``share`` on the resource — no membership needed, so an
|
||||
owner who left the team can still pull their resource back — or for a
|
||||
team_admin of this team. ``target_user_id`` picks one member's grant
|
||||
(absent → the whole-team grant). Identifiers come from query params
|
||||
(some proxies strip DELETE bodies), with a JSON-body fallback.
|
||||
"""
|
||||
user = _current_user()
|
||||
token = getattr(request, "decoded_token", None)
|
||||
if not user:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Authentication required"}), 401
|
||||
)
|
||||
data = request.get_json(silent=True) or {}
|
||||
resource_type = request.args.get("resource_type") or data.get("resource_type")
|
||||
resource_id = request.args.get("resource_id") or data.get("resource_id")
|
||||
@@ -528,17 +727,15 @@ class TeamGrants(Resource):
|
||||
target_user_id = (
|
||||
request.args.get("target_user_id") or data.get("target_user_id") or ""
|
||||
).strip() or None
|
||||
if (
|
||||
not is_valid_resource_type(resource_type)
|
||||
or not resource_id
|
||||
or not looks_like_uuid(resource_id)
|
||||
):
|
||||
if not _valid_resource(resource_type, resource_id):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
is_owner = owns_resource(conn, resource_type, resource_id, user)
|
||||
if not is_owner and not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
|
||||
return {"success": False, "message": "Forbidden"}, 403
|
||||
try:
|
||||
require(conn, resource_type, resource_id, user, "share")
|
||||
except AccessDenied:
|
||||
if not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
|
||||
return {"success": False, "message": "Forbidden"}, 403
|
||||
revoked = TeamResourceGrantsRepository(conn).revoke(
|
||||
team_id, resource_type, resource_id, target_user_id=target_user_id
|
||||
)
|
||||
@@ -602,26 +799,25 @@ class TeamOwnerTransfer(Resource):
|
||||
@teams_ns.route("/resource_shares")
|
||||
class ResourceShares(Resource):
|
||||
def get(self):
|
||||
"""List the teams a resource the caller OWNS is shared with.
|
||||
"""List the teams a resource is shared with. Needs ``share`` on it.
|
||||
|
||||
Powers the share dialog (show current shares + unshare). Owner-only so a
|
||||
non-owner can't enumerate a resource's sharing graph.
|
||||
Powers the share dialog (current shares + unshare), so only someone who
|
||||
may change the sharing can enumerate it: 404 when the resource isn't
|
||||
visible, 403 when the caller's role can't share.
|
||||
"""
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return {"success": False}, 401
|
||||
resource_type = request.args.get("resource_type")
|
||||
resource_id = request.args.get("resource_id")
|
||||
if (
|
||||
not is_valid_resource_type(resource_type)
|
||||
or not resource_id
|
||||
or not looks_like_uuid(resource_id)
|
||||
):
|
||||
if not _valid_resource(resource_type, resource_id):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
if not owns_resource(conn, resource_type, resource_id, user):
|
||||
return {"success": False, "message": "Not the resource owner"}, 403
|
||||
try:
|
||||
require(conn, resource_type, resource_id, user, "share")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
shares = TeamResourceGrantsRepository(conn).list_for_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
@@ -631,6 +827,83 @@ class ResourceShares(Resource):
|
||||
return {"success": False}, 400
|
||||
|
||||
|
||||
def _settings_response(ra: ResourceAccess):
|
||||
"""The ``resource_settings`` body: switches plus the caller's access."""
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"resource_type": ra.resource_type,
|
||||
"resource_id": ra.resource_id,
|
||||
"settings": public_settings(ra.resource_type, ra.settings),
|
||||
**ra.payload(),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@teams_ns.route("/resource_settings")
|
||||
class ResourceSettings(Resource):
|
||||
def get(self):
|
||||
"""A resource's sharing switches. Anyone with access may read them.
|
||||
|
||||
Query: ``resource_type``, ``resource_id``. Returns ``settings`` as
|
||||
``[{key, value, default}]`` in display order, plus the caller's
|
||||
``access`` and ``allowed_actions``.
|
||||
"""
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return {"success": False}, 401
|
||||
resource_type = request.args.get("resource_type")
|
||||
resource_id = request.args.get("resource_id")
|
||||
if resource_type not in RESOURCE_TYPES or not resource_id:
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
try:
|
||||
ra = require(conn, resource_type, resource_id, user, "use")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
return _settings_response(ra)
|
||||
except Exception as err:
|
||||
logger.error("Get resource settings failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
def put(self):
|
||||
"""Change a resource's sharing switches. Needs ``manage_settings`` (owner).
|
||||
|
||||
Body: ``{"resource_type", "resource_id", "settings": {key: bool}}``.
|
||||
An unknown key or a non-boolean value is a 400. Returns the GET shape.
|
||||
"""
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return {"success": False}, 401
|
||||
data = request.get_json(silent=True) or {}
|
||||
resource_type = data.get("resource_type")
|
||||
resource_id = data.get("resource_id")
|
||||
changes = data.get("settings")
|
||||
if resource_type not in RESOURCE_TYPES or not resource_id or not isinstance(resource_id, str):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
if not isinstance(changes, dict):
|
||||
return {"success": False, "message": "settings must be an object"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
try:
|
||||
ra = require(conn, resource_type, resource_id, user, "manage_settings")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
try:
|
||||
merged = set_settings(conn, resource_type, ra.resource_id, changes, user)
|
||||
except ValueError as bad:
|
||||
return {"success": False, "message": str(bad)}, 400
|
||||
updated = build(resource_type, ra.resource_id, ra.access, ra.owner_id, merged)
|
||||
return _settings_response(updated)
|
||||
except Exception as err:
|
||||
logger.error("Update resource settings failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
|
||||
@teams_ns.route("/admin/teams")
|
||||
class AllTeams(Resource):
|
||||
method_decorators = []
|
||||
|
||||
+116
-72
@@ -7,7 +7,16 @@ from flask_restx import Namespace, Resource, fields
|
||||
|
||||
from docsgpt.agents.tools.mcp_tool import MCPOAuthManager, MCPTool
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.tools.routes import transform_actions
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.api.user.tools.routes import (
|
||||
_CREDENTIALS_FOR_NEW_SERVER,
|
||||
_MCP_CREDENTIAL_AUTH_TYPES,
|
||||
_mcp_origin_changed,
|
||||
check_oauth_mcp_owner_only,
|
||||
denied_response,
|
||||
transform_actions,
|
||||
)
|
||||
from docsgpt.cache import get_redis_instance
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
|
||||
@@ -75,12 +84,61 @@ def _validate_mcp_server_url(config: dict) -> None:
|
||||
raise ValueError(f"Invalid server URL: {exc}") from exc
|
||||
|
||||
|
||||
def _existing_mcp_context(tool_id, user, config):
|
||||
"""Resolve the stored MCP tool a test/save refers to, and its credentials.
|
||||
|
||||
With no ``tool_id`` the caller acts on their own new server. With one,
|
||||
the caller needs ``edit_credentials`` on that tool and everything runs as
|
||||
its owner. Stored secrets are write-only, so an empty secret field reuses
|
||||
the stored one while the origin (scheme, host, port) is unchanged; a new
|
||||
origin never inherits them.
|
||||
A server that is or would become OAuth is the owner's alone (its tokens
|
||||
are the owner's sign-in).
|
||||
|
||||
Returns:
|
||||
``(existing_doc, owner_id, is_owner, moved, credentials)``, or a Flask
|
||||
response (404 / 400) to return as is.
|
||||
|
||||
Raises:
|
||||
AccessDenied: the caller can't see the tool (404), can't change its
|
||||
credentials (403), or isn't the owner of an OAuth server (403).
|
||||
"""
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
if not tool_id:
|
||||
return None, user, True, False, auth_credentials
|
||||
with db_readonly() as conn:
|
||||
ra = require(conn, "tool", tool_id, user, "edit_credentials")
|
||||
existing_doc = UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id)
|
||||
if not existing_doc or existing_doc.get("name") != "mcp_tool":
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found or access denied"}), 404,
|
||||
)
|
||||
existing_config = existing_doc.get("config") or {}
|
||||
check_oauth_mcp_owner_only(ra, existing_config, config)
|
||||
moved = _mcp_origin_changed(config, existing_config)
|
||||
auth_type = config.get("auth_type", "none")
|
||||
new_secret_keys = set(auth_credentials) - {"api_key_header"}
|
||||
if moved and auth_type in _MCP_CREDENTIAL_AUTH_TYPES and not new_secret_keys:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": _CREDENTIALS_FOR_NEW_SERVER}), 400
|
||||
)
|
||||
credentials = dict(auth_credentials)
|
||||
existing_encrypted = None if moved else existing_config.get("encrypted_credentials")
|
||||
if existing_encrypted:
|
||||
credentials = {**decrypt_credentials(existing_encrypted, ra.owner_id), **auth_credentials}
|
||||
return existing_doc, ra.owner_id, ra.access == "owner", moved, credentials
|
||||
|
||||
|
||||
@tools_mcp_ns.route("/mcp_server/test")
|
||||
class TestMCPServerConfig(Resource):
|
||||
@api.expect(
|
||||
api.model(
|
||||
"MCPServerTestModel",
|
||||
{
|
||||
"id": fields.String(
|
||||
required=False,
|
||||
description="Stored tool to test with (empty secrets reuse its stored ones)",
|
||||
),
|
||||
"config": fields.Raw(
|
||||
required=True, description="MCP server configuration to test"
|
||||
),
|
||||
@@ -111,11 +169,14 @@ class TestMCPServerConfig(Resource):
|
||||
|
||||
_validate_mcp_server_url(config)
|
||||
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
ctx = _existing_mcp_context(data.get("id"), user, config)
|
||||
if not isinstance(ctx, tuple):
|
||||
return ctx
|
||||
_existing_doc, owner_id, _is_owner, _moved, auth_credentials = ctx
|
||||
test_config = config.copy()
|
||||
test_config["auth_credentials"] = auth_credentials
|
||||
|
||||
mcp_tool = MCPTool(config=test_config, user_id=user)
|
||||
mcp_tool = MCPTool(config=test_config, user_id=owner_id)
|
||||
result = mcp_tool.test_connection()
|
||||
|
||||
if result.get("requires_oauth"):
|
||||
@@ -148,6 +209,8 @@ class TestMCPServerConfig(Resource):
|
||||
"tools": result.get("tools", []),
|
||||
}
|
||||
return make_response(jsonify(safe_result), 200)
|
||||
except AccessDenied as e:
|
||||
return denied_response(e)
|
||||
except ValueError as e:
|
||||
current_app.logger.warning(f"Invalid MCP server test request: {e}")
|
||||
return make_response(
|
||||
@@ -207,12 +270,20 @@ class MCPServerSave(Resource):
|
||||
|
||||
_validate_mcp_server_url(config)
|
||||
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
# An existing id is always an update of THAT row, written as its
|
||||
# owner; it never falls through to creating a copy for the caller.
|
||||
ctx = _existing_mcp_context(data.get("id"), user, config)
|
||||
if not isinstance(ctx, tuple):
|
||||
return ctx
|
||||
existing_doc, owner_id, is_owner, _moved, merged_credentials = ctx
|
||||
auth_type = config.get("auth_type", "none")
|
||||
mcp_config = config.copy()
|
||||
mcp_config["auth_credentials"] = auth_credentials
|
||||
mcp_config["auth_credentials"] = merged_credentials
|
||||
|
||||
if auth_type == "oauth":
|
||||
# Only the owner reaches here for an existing server (see
|
||||
# ``_existing_mcp_context``), and every OAuth save needs the
|
||||
# sign-in they just completed.
|
||||
if not config.get("oauth_task_id"):
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -239,8 +310,8 @@ class MCPServerSave(Resource):
|
||||
400,
|
||||
)
|
||||
actions_metadata = result.get("tools", [])
|
||||
elif auth_type == "none" or auth_credentials:
|
||||
mcp_tool = MCPTool(config=mcp_config, user_id=user)
|
||||
elif auth_type == "none" or merged_credentials:
|
||||
mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
|
||||
mcp_tool.discover_tools()
|
||||
actions_metadata = mcp_tool.get_actions_metadata()
|
||||
else:
|
||||
@@ -249,30 +320,10 @@ class MCPServerSave(Resource):
|
||||
)
|
||||
storage_config = config.copy()
|
||||
|
||||
tool_id = data.get("id")
|
||||
existing_doc = None
|
||||
existing_encrypted = None
|
||||
if tool_id:
|
||||
with db_readonly() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
existing_doc = repo.get_any(tool_id, user)
|
||||
if existing_doc and existing_doc.get("name") == "mcp_tool":
|
||||
existing_encrypted = (existing_doc.get("config") or {}).get(
|
||||
"encrypted_credentials"
|
||||
)
|
||||
else:
|
||||
existing_doc = None
|
||||
|
||||
if auth_credentials:
|
||||
if existing_encrypted:
|
||||
existing_secrets = decrypt_credentials(existing_encrypted, user)
|
||||
existing_secrets.update(auth_credentials)
|
||||
auth_credentials = existing_secrets
|
||||
if merged_credentials:
|
||||
storage_config["encrypted_credentials"] = encrypt_credentials(
|
||||
auth_credentials, user
|
||||
merged_credentials, owner_id
|
||||
)
|
||||
elif existing_encrypted:
|
||||
storage_config["encrypted_credentials"] = existing_encrypted
|
||||
|
||||
for field in [
|
||||
"api_key",
|
||||
@@ -288,53 +339,48 @@ class MCPServerSave(Resource):
|
||||
display_name = data["displayName"]
|
||||
description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}"
|
||||
status_bool = bool(data.get("status", True))
|
||||
fields_out = {
|
||||
"display_name": display_name,
|
||||
"custom_name": display_name,
|
||||
"description": description,
|
||||
"config": storage_config,
|
||||
"actions": transformed_actions,
|
||||
}
|
||||
updated_message = (
|
||||
f"MCP server updated successfully! Discovered {len(transformed_actions)} tools."
|
||||
)
|
||||
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
if existing_doc:
|
||||
repo.update(
|
||||
str(existing_doc["id"]), user,
|
||||
{
|
||||
"display_name": display_name,
|
||||
"custom_name": display_name,
|
||||
"description": description,
|
||||
"config": storage_config,
|
||||
"actions": transformed_actions,
|
||||
"status": status_bool,
|
||||
},
|
||||
)
|
||||
if existing_doc is not None:
|
||||
# ``status`` is the owner's own chat switch; an editor's
|
||||
# save doesn't flip it.
|
||||
if is_owner:
|
||||
fields_out["status"] = status_bool
|
||||
repo.update(str(existing_doc["id"]), owner_id, fields_out)
|
||||
saved_id = str(existing_doc["id"])
|
||||
response_data = {
|
||||
"success": True,
|
||||
"id": saved_id,
|
||||
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
|
||||
"message": updated_message,
|
||||
"tools_count": len(transformed_actions),
|
||||
}
|
||||
else:
|
||||
fields_out["status"] = status_bool
|
||||
# Fall back to find_by_user_and_name — the original
|
||||
# dual-write path also ran an existence check before
|
||||
# deciding between insert and update.
|
||||
existing_by_name = repo.find_by_user_and_name(user, "mcp_tool")
|
||||
if tool_id is None and existing_by_name and (
|
||||
if existing_by_name and (
|
||||
(existing_by_name.get("config") or {}).get("server_url")
|
||||
== storage_config.get("server_url")
|
||||
):
|
||||
repo.update(
|
||||
str(existing_by_name["id"]), user,
|
||||
{
|
||||
"display_name": display_name,
|
||||
"custom_name": display_name,
|
||||
"description": description,
|
||||
"config": storage_config,
|
||||
"actions": transformed_actions,
|
||||
"status": status_bool,
|
||||
},
|
||||
)
|
||||
repo.update(str(existing_by_name["id"]), user, fields_out)
|
||||
saved_id = str(existing_by_name["id"])
|
||||
response_data = {
|
||||
"success": True,
|
||||
"id": saved_id,
|
||||
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
|
||||
"message": updated_message,
|
||||
"tools_count": len(transformed_actions),
|
||||
}
|
||||
else:
|
||||
@@ -355,18 +401,9 @@ class MCPServerSave(Resource):
|
||||
"message": f"MCP server created successfully! Discovered {len(transformed_actions)} tools.",
|
||||
"tools_count": len(transformed_actions),
|
||||
}
|
||||
if tool_id and existing_doc is None:
|
||||
# Client requested update on a non-existent tool id.
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Tool not found or access denied",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
return make_response(jsonify(response_data), 200)
|
||||
except AccessDenied as e:
|
||||
return denied_response(e)
|
||||
except ValueError as e:
|
||||
current_app.logger.warning(f"Invalid MCP server save request: {e}")
|
||||
return make_response(
|
||||
@@ -455,6 +492,13 @@ class MCPAuthStatus(Resource):
|
||||
tools_repo = UserToolsRepository(conn)
|
||||
sessions_repo = ConnectorSessionsRepository(conn)
|
||||
all_tools = tools_repo.list_for_user(user)
|
||||
owned_ids = {str(t["id"]) for t in all_tools}
|
||||
# Team-shared MCP servers the caller can see run with the
|
||||
# owner's connection, so their status is the owner's.
|
||||
shared_ids = [
|
||||
tid for tid in visible_with_access(conn, user, "tool") if tid not in owned_ids
|
||||
]
|
||||
all_tools = all_tools + tools_repo.list_by_ids(shared_ids)
|
||||
mcp_tools = [t for t in all_tools if t.get("name") == "mcp_tool"]
|
||||
if not mcp_tools:
|
||||
return make_response(
|
||||
@@ -472,7 +516,7 @@ class MCPAuthStatus(Resource):
|
||||
if server_url:
|
||||
parsed = urlparse(server_url)
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
oauth_server_urls[tool_id] = base_url
|
||||
oauth_server_urls[tool_id] = (tool.get("user_id") or user, base_url)
|
||||
else:
|
||||
statuses[tool_id] = "needs_auth"
|
||||
else:
|
||||
@@ -484,9 +528,9 @@ class MCPAuthStatus(Resource):
|
||||
# and the URL in ``server_url``; reuse the repo's
|
||||
# per-URL accessor rather than an ad-hoc $in query.
|
||||
url_has_tokens: dict = {}
|
||||
for base_url in set(oauth_server_urls.values()):
|
||||
for owner_id, base_url in set(oauth_server_urls.values()):
|
||||
session = sessions_repo.get_by_user_and_server_url(
|
||||
user, base_url,
|
||||
owner_id, base_url,
|
||||
)
|
||||
tokens = (
|
||||
(session or {}).get("session_data", {}) or {}
|
||||
@@ -494,13 +538,13 @@ class MCPAuthStatus(Resource):
|
||||
# MCP code also stashes tokens into token_info on
|
||||
# the row; consider either present as "connected".
|
||||
token_info = (session or {}).get("token_info") or {}
|
||||
url_has_tokens[base_url] = bool(
|
||||
url_has_tokens[(owner_id, base_url)] = bool(
|
||||
tokens.get("access_token")
|
||||
or token_info.get("access_token")
|
||||
)
|
||||
|
||||
for tool_id, base_url in oauth_server_urls.items():
|
||||
if url_has_tokens.get(base_url):
|
||||
for tool_id, key in oauth_server_urls.items():
|
||||
if url_has_tokens.get(key):
|
||||
statuses[tool_id] = "connected"
|
||||
else:
|
||||
statuses[tool_id] = "needs_auth"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""Tool management routes."""
|
||||
|
||||
import copy
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.agents.default_tools import (
|
||||
builtin_agent_tools_for_management,
|
||||
@@ -13,12 +18,21 @@ from docsgpt.agents.default_tools import (
|
||||
is_synthesized_tool_id,
|
||||
WORKFLOW_ONLY_BUILTINS,
|
||||
)
|
||||
from docsgpt.agents.tool_executor import API_TOOL_SECRET_SECTIONS, API_TOOL_SECRETS_KEY
|
||||
from docsgpt.agents.tools.spec_parser import parse_spec
|
||||
from docsgpt.agents.tools.tool_manager import ToolManager
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.artifacts.authz import Principal, authorize_artifact
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner, visible_with_access
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
require,
|
||||
ResourceAccess,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
|
||||
@@ -26,6 +40,9 @@ from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.artifacts import ArtifactsRepository
|
||||
from docsgpt.storage.db.repositories.notes import NotesRepository
|
||||
from docsgpt.storage.db.repositories.todos import TodosRepository
|
||||
from docsgpt.storage.db.repositories.user_tool_preferences import (
|
||||
UserToolPreferencesRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
@@ -166,6 +183,284 @@ def _merge_secrets_on_update(new_config, existing_config, config_requirements, u
|
||||
return storage_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Access + secrets helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
_CREDENTIALS_FOR_NEW_SERVER = "Enter credentials for the new server"
|
||||
_FORBIDDEN_MESSAGE = "Your access to this item doesn't allow that"
|
||||
_MCP_CREDENTIAL_AUTH_TYPES = {"api_key", "bearer", "basic"}
|
||||
_META_KEYS = ("name", "displayName", "customName", "description", "actions")
|
||||
|
||||
|
||||
class CredentialsRequired(Exception):
|
||||
"""A save moved a tool to a new origin without supplying new secrets."""
|
||||
|
||||
|
||||
def denied_response(err: AccessDenied):
|
||||
"""JSON response for an :class:`AccessDenied` (403 or 404)."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def check_action(ra: ResourceAccess, action: str) -> None:
|
||||
"""Raise a 403 :class:`AccessDenied` unless ``ra`` allows ``action``."""
|
||||
if not ra.can(action):
|
||||
raise AccessDenied(403, _FORBIDDEN_MESSAGE)
|
||||
|
||||
|
||||
_DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
|
||||
|
||||
|
||||
def url_origin(url: Any) -> str:
|
||||
"""``scheme://host:port`` of ``url``, lower-cased, default port filled in.
|
||||
|
||||
Saved secrets follow the origin, not just the host: the same host over
|
||||
``http`` would send them in cleartext, and another port can be another
|
||||
service. ``''`` when the URL has no host or doesn't parse.
|
||||
"""
|
||||
try:
|
||||
parts = urlparse(str(url or "").strip())
|
||||
host = (parts.hostname or "").lower()
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
return ""
|
||||
if not host:
|
||||
return ""
|
||||
scheme = (parts.scheme or "").lower()
|
||||
return f"{scheme}://{host}:{port or _DEFAULT_PORTS.get(scheme, '')}"
|
||||
|
||||
|
||||
def _has_value(value: Any) -> bool:
|
||||
return value is not None and value != ""
|
||||
|
||||
|
||||
def _secret_props(action: Any):
|
||||
"""Yield ``(section, param, spec)`` for an api_tool action's secret-bearing params."""
|
||||
if not isinstance(action, dict):
|
||||
return
|
||||
for section in API_TOOL_SECRET_SECTIONS:
|
||||
block = action.get(section)
|
||||
props = block.get("properties") if isinstance(block, dict) else None
|
||||
if not isinstance(props, dict):
|
||||
continue
|
||||
for param, spec in props.items():
|
||||
if isinstance(spec, dict):
|
||||
yield section, param, spec
|
||||
|
||||
|
||||
def _stored_api_tool_secrets(config: dict, owner_id: str) -> dict:
|
||||
"""Decrypted ``{action: {section: {param: value}}}`` plus legacy plaintext values."""
|
||||
config = config or {}
|
||||
blob = config.get(API_TOOL_SECRETS_KEY)
|
||||
secrets: dict = decrypt_credentials(blob, owner_id) if blob else {}
|
||||
for name, action in (config.get("actions") or {}).items():
|
||||
for section, param, spec in _secret_props(action):
|
||||
value = spec.get("value")
|
||||
if _has_value(value):
|
||||
secrets.setdefault(name, {}).setdefault(section, {}).setdefault(param, value)
|
||||
return secrets
|
||||
|
||||
|
||||
def mask_api_tool_config(config: dict) -> dict:
|
||||
"""Copy of an api_tool config with header/query values blanked and ``has_value`` set.
|
||||
|
||||
Args:
|
||||
config: The stored ``user_tools.config``.
|
||||
|
||||
Returns:
|
||||
A deep copy safe to return to any caller: the encrypted blob is dropped
|
||||
and every header / query-param entry has ``value: ""`` plus ``has_value``.
|
||||
"""
|
||||
out = copy.deepcopy(config or {})
|
||||
out.pop(API_TOOL_SECRETS_KEY, None)
|
||||
for action in (out.get("actions") or {}).values():
|
||||
for _section, _param, spec in _secret_props(action):
|
||||
spec["has_value"] = _has_value(spec.get("value")) or bool(spec.get("has_value"))
|
||||
spec["value"] = ""
|
||||
return out
|
||||
|
||||
|
||||
def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: str) -> dict:
|
||||
"""Move api_tool header/query values into an encrypted blob keyed by the owner.
|
||||
|
||||
An incoming entry with a value replaces the stored one; an empty value with
|
||||
``has_value`` keeps it (legacy plaintext values included); anything else
|
||||
clears it. When an action's URL origin changes the stored values are not
|
||||
carried over.
|
||||
|
||||
Args:
|
||||
new_config: The config the client sent.
|
||||
existing_config: The stored config (``{}`` on create).
|
||||
owner_id: The tool row's ``user_id`` — the encryption key owner.
|
||||
|
||||
Returns:
|
||||
The config to persist.
|
||||
|
||||
Raises:
|
||||
CredentialsRequired: an origin changed, the client asked to keep a value,
|
||||
and there is nothing to keep.
|
||||
"""
|
||||
existing_config = existing_config or {}
|
||||
stored = _stored_api_tool_secrets(existing_config, owner_id)
|
||||
old_actions = existing_config.get("actions") or {}
|
||||
out = copy.deepcopy(new_config or {})
|
||||
out.pop(API_TOOL_SECRETS_KEY, None)
|
||||
sealed: dict = {}
|
||||
for name, action in (out.get("actions") or {}).items():
|
||||
old = old_actions.get(name) if isinstance(old_actions, dict) else None
|
||||
moved = isinstance(old, dict) and url_origin(old.get("url")) != url_origin(
|
||||
action.get("url") if isinstance(action, dict) else ""
|
||||
)
|
||||
prior = {} if moved else stored.get(name, {})
|
||||
for section, param, spec in _secret_props(action):
|
||||
value = spec.get("value")
|
||||
if _has_value(value):
|
||||
kept = value
|
||||
elif spec.get("has_value"):
|
||||
kept = (prior.get(section) or {}).get(param)
|
||||
if not _has_value(kept):
|
||||
if moved:
|
||||
raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER)
|
||||
kept = None
|
||||
else:
|
||||
kept = None
|
||||
spec["value"] = ""
|
||||
spec["has_value"] = kept is not None
|
||||
if kept is not None:
|
||||
sealed.setdefault(name, {}).setdefault(section, {})[param] = kept
|
||||
if sealed:
|
||||
out[API_TOOL_SECRETS_KEY] = encrypt_credentials(sealed, owner_id)
|
||||
return out
|
||||
|
||||
|
||||
def _api_tool_config_needs_credentials(new_config: dict, existing_config: dict) -> bool:
|
||||
"""Whether an api_tool config change touches endpoints or secrets.
|
||||
|
||||
Descriptions, parameter schemas and on/off flags are ``edit``; a new or
|
||||
changed URL, a new action (it brings a URL), a secret value or any other
|
||||
config key is ``edit_credentials``.
|
||||
"""
|
||||
new_config = new_config or {}
|
||||
existing_config = existing_config or {}
|
||||
ignore = ("actions", API_TOOL_SECRETS_KEY, "has_encrypted_credentials")
|
||||
if {k: v for k, v in new_config.items() if k not in ignore} != {
|
||||
k: v for k, v in existing_config.items() if k not in ignore
|
||||
}:
|
||||
return True
|
||||
old_actions = existing_config.get("actions") or {}
|
||||
for name, action in (new_config.get("actions") or {}).items():
|
||||
old = old_actions.get(name)
|
||||
if not isinstance(old, dict) or not isinstance(action, dict):
|
||||
return True
|
||||
if str(action.get("url") or "") != str(old.get("url") or ""):
|
||||
return True
|
||||
for _section, _param, spec in _secret_props(action):
|
||||
if _has_value(spec.get("value")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _mcp_origin_changed(new_config: dict, existing_config: dict) -> bool:
|
||||
"""Whether a save moves an MCP server to another scheme, host or port."""
|
||||
old_url = (existing_config or {}).get("server_url")
|
||||
return bool(old_url) and url_origin(old_url) != url_origin((new_config or {}).get("server_url"))
|
||||
|
||||
|
||||
SHARED_OAUTH_OWNER_ONLY = "Only the owner can change or reconnect this server"
|
||||
|
||||
|
||||
def check_oauth_mcp_owner_only(
|
||||
ra: ResourceAccess, existing_config: Optional[dict], new_config: Optional[dict]
|
||||
) -> None:
|
||||
"""Keep a shared OAuth MCP server's connection with its owner.
|
||||
|
||||
MCP OAuth tokens are looked up by owner + server URL and a shared server
|
||||
runs as its owner, so a grantee who moved an OAuth server, switched a
|
||||
server to OAuth, or re-ran its sign-in would be using the owner's account
|
||||
somewhere the owner never chose. Until connectors own OAuth accounts, any
|
||||
connection change on a server that is (or would become) OAuth is
|
||||
owner-only.
|
||||
|
||||
Args:
|
||||
ra: The caller's access to the tool.
|
||||
existing_config: The stored ``config``.
|
||||
new_config: The incoming ``config``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 403 when a non-owner touches an OAuth server's config.
|
||||
"""
|
||||
if ra.access == "owner":
|
||||
return
|
||||
configs = [c if isinstance(c, dict) else {} for c in (existing_config, new_config)]
|
||||
auth_types = {c.get("auth_type") for c in configs}
|
||||
if "oauth" in auth_types:
|
||||
raise AccessDenied(403, SHARED_OAUTH_OWNER_ONLY)
|
||||
|
||||
|
||||
def _prepare_tool_config(tool_doc: dict, new_config: dict, config_requirements: dict) -> dict:
|
||||
"""Validate-free merge of an incoming config with the stored one, as the owner.
|
||||
|
||||
Handles the three secret stores: ``config_requirements`` secrets
|
||||
(``encrypted_credentials``), api_tool header/query values, and the MCP
|
||||
origin-change rule (a new scheme, host or port drops stored credentials).
|
||||
|
||||
Raises:
|
||||
CredentialsRequired: the MCP origin changed and no new secret arrived.
|
||||
"""
|
||||
owner_id = tool_doc["user_id"]
|
||||
existing_config = tool_doc.get("config") or {}
|
||||
if tool_doc.get("name") == "api_tool":
|
||||
return _seal_api_tool_secrets(new_config, existing_config, owner_id)
|
||||
moved = tool_doc.get("name") == "mcp_tool" and _mcp_origin_changed(new_config, existing_config)
|
||||
if moved:
|
||||
existing_config = {k: v for k, v in existing_config.items() if k != "encrypted_credentials"}
|
||||
final = _merge_secrets_on_update(new_config, existing_config, config_requirements, owner_id)
|
||||
if moved and final.get("auth_type") in _MCP_CREDENTIAL_AUTH_TYPES and not final.get(
|
||||
"encrypted_credentials"
|
||||
):
|
||||
raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER)
|
||||
return final
|
||||
|
||||
|
||||
def _shared_via(conn: Connection, user_id: str, tool_ids: list) -> dict:
|
||||
"""``tool_id -> team name`` through which a grant reaches ``user_id``."""
|
||||
ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT ON (g.resource_id) g.resource_id, t.name
|
||||
FROM team_resource_grants g
|
||||
JOIN team_members m ON m.team_id = g.team_id
|
||||
JOIN teams t ON t.id = g.team_id
|
||||
WHERE m.user_id = :user_id AND g.resource_type = 'tool'
|
||||
AND g.resource_id = ANY(CAST(:ids AS uuid[]))
|
||||
AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
|
||||
ORDER BY g.resource_id, (g.access_level = 'editor') DESC, t.name
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "ids": ids},
|
||||
).fetchall()
|
||||
return {str(r[0]): r[1] for r in rows}
|
||||
|
||||
|
||||
def _owner_labels(conn: Connection, owner_ids) -> dict:
|
||||
"""``user_id -> email`` for the owners that have one on record."""
|
||||
ids = sorted({str(o) for o in owner_ids if o})
|
||||
if not ids:
|
||||
return {}
|
||||
rows = conn.execute(
|
||||
text("SELECT user_id, email FROM users WHERE user_id = ANY(:ids) AND email IS NOT NULL"),
|
||||
{"ids": ids},
|
||||
).fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
|
||||
def _load_owned_row(conn: Connection, ra: ResourceAccess) -> Optional[dict]:
|
||||
"""The tool row behind ``ra``, read as its owner."""
|
||||
return UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id)
|
||||
|
||||
|
||||
def transform_actions(actions_metadata):
|
||||
"""Set default flags on action metadata for storage.
|
||||
|
||||
@@ -239,6 +534,10 @@ class GetTools(Resource):
|
||||
team_shared = visible_with_access(conn, user, "tool")
|
||||
shared_ids = [tid for tid in team_shared if tid not in owned_ids]
|
||||
shared_rows = tools_repo.list_by_ids(shared_ids)
|
||||
switches = settings_many(conn, "tool", [*owned_ids, *shared_ids])
|
||||
prefs = UserToolPreferencesRepository(conn).in_chat_many(user, shared_ids)
|
||||
shared_via = _shared_via(conn, user, shared_ids)
|
||||
owner_labels = _owner_labels(conn, [r.get("user_id") for r in shared_rows])
|
||||
user_tools = []
|
||||
|
||||
def _shape_tool(row, *, ownership="user", force_strip_secret=False):
|
||||
@@ -257,14 +556,25 @@ class GetTools(Resource):
|
||||
):
|
||||
tool_copy["config"]["has_encrypted_credentials"] = True
|
||||
tool_copy["config"].pop("encrypted_credentials", None)
|
||||
if tool_copy.get("name") == "api_tool":
|
||||
# Header / query-param values are secrets for everyone.
|
||||
tool_copy["config"] = mask_api_tool_config(tool_copy.get("config") or {})
|
||||
tool_copy["ownership"] = ownership
|
||||
return tool_copy
|
||||
|
||||
for row in rows:
|
||||
user_tools.append(_shape_tool(row))
|
||||
shaped = _shape_tool(row)
|
||||
shaped.update(payload_for("tool", "owner", switches.get(str(row["id"]))))
|
||||
shaped["in_chat"] = bool(row.get("status"))
|
||||
user_tools.append(shaped)
|
||||
for row in shared_rows:
|
||||
tid = str(row["id"])
|
||||
shaped = _shape_tool(row, ownership="team", force_strip_secret=True)
|
||||
shaped["team_access"] = team_shared.get(str(row["id"]))
|
||||
shaped["team_access"] = team_shared.get(tid)
|
||||
shaped.update(payload_for("tool", team_shared.get(tid), switches.get(tid)))
|
||||
shaped["in_chat"] = prefs.get(tid, False)
|
||||
shaped["shared_via"] = shared_via.get(tid)
|
||||
shaped["owner_label"] = owner_labels.get(row.get("user_id"))
|
||||
user_tools.append(shaped)
|
||||
|
||||
# ``scheduler`` is dual-registered (default chat tool + agent-
|
||||
@@ -275,6 +585,7 @@ class GetTools(Resource):
|
||||
for default_row in default_tools_for_management(user_doc):
|
||||
default_copy = _row_to_api(default_row)
|
||||
default_copy["default"] = True
|
||||
default_copy["in_chat"] = bool(default_copy.get("status"))
|
||||
if default_copy.get("name") in BUILTIN_AGENT_TOOLS:
|
||||
default_copy["builtin"] = True
|
||||
seen_ids.add(str(default_copy["id"]))
|
||||
@@ -386,9 +697,12 @@ class CreateTool(Resource):
|
||||
),
|
||||
400,
|
||||
)
|
||||
storage_config = _encrypt_secret_fields(
|
||||
data["config"], config_requirements, user
|
||||
)
|
||||
if data["name"] == "api_tool":
|
||||
storage_config = _seal_api_tool_secrets(data["config"], {}, user)
|
||||
else:
|
||||
storage_config = _encrypt_secret_fields(
|
||||
data["config"], config_requirements, user
|
||||
)
|
||||
with db_session() as conn:
|
||||
created = UserToolsRepository(conn).create(
|
||||
user,
|
||||
@@ -478,43 +792,49 @@ class UpdateTool(Resource):
|
||||
),
|
||||
400,
|
||||
)
|
||||
if "config" in data and isinstance(data["config"], dict) and "actions" in data["config"]:
|
||||
for action_name in list((data["config"]["actions"] or {}).keys()):
|
||||
if not validate_function_name(action_name):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
|
||||
"param": "tools[].function.name",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
try:
|
||||
update_data: dict = {}
|
||||
for key in ("name", "displayName", "customName", "description", "actions"):
|
||||
for key in _META_KEYS:
|
||||
if key in data:
|
||||
update_data[key] = data[key]
|
||||
if "config" in data:
|
||||
if "actions" in data["config"]:
|
||||
for action_name in list(data["config"]["actions"].keys()):
|
||||
if not validate_function_name(action_name):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
|
||||
"param": "tools[].function.name",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
)
|
||||
with db_session() as conn:
|
||||
ra = require(conn, "tool", data["id"], user, "use")
|
||||
tool_doc = _load_owned_row(conn, ra)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}), 404,
|
||||
)
|
||||
if update_data:
|
||||
check_action(ra, "edit")
|
||||
if "config" in data:
|
||||
tool_name = tool_doc.get("name", data.get("name"))
|
||||
existing_config = tool_doc.get("config", {}) or {}
|
||||
if tool_name == "mcp_tool":
|
||||
check_oauth_mcp_owner_only(ra, existing_config, data["config"])
|
||||
if tool_name == "api_tool" and not _api_tool_config_needs_credentials(
|
||||
data["config"], existing_config
|
||||
):
|
||||
check_action(ra, "edit")
|
||||
else:
|
||||
check_action(ra, "edit_credentials")
|
||||
tool_instance = tool_manager.tools.get(tool_name)
|
||||
config_requirements = (
|
||||
tool_instance.get_config_requirements()
|
||||
if tool_instance
|
||||
else {}
|
||||
tool_instance.get_config_requirements() if tool_instance else {}
|
||||
)
|
||||
existing_config = tool_doc.get("config", {}) or {}
|
||||
has_existing_secrets = "encrypted_credentials" in existing_config
|
||||
|
||||
if config_requirements:
|
||||
validation_errors = _validate_config(
|
||||
data["config"], config_requirements,
|
||||
@@ -529,29 +849,27 @@ class UpdateTool(Resource):
|
||||
}),
|
||||
400,
|
||||
)
|
||||
|
||||
update_data["config"] = _merge_secrets_on_update(
|
||||
data["config"], existing_config, config_requirements, user
|
||||
update_data["config"] = _prepare_tool_config(
|
||||
tool_doc, data["config"], config_requirements
|
||||
)
|
||||
if "status" in data:
|
||||
update_data["status"] = bool(data["status"])
|
||||
repo.update(
|
||||
str(tool_doc["id"]), user, _api_to_update_fields(update_data),
|
||||
)
|
||||
else:
|
||||
if "status" in data:
|
||||
update_data["status"] = bool(data["status"])
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
if ra.access == "owner":
|
||||
update_data["status"] = bool(data["status"])
|
||||
else:
|
||||
# A grantee's chat switch is personal; the owner's
|
||||
# ``status`` is the owner's own chat setting.
|
||||
check_action(ra, "use_in_own")
|
||||
UserToolPreferencesRepository(conn).set_in_chat(
|
||||
user, str(tool_doc["id"]), bool(data["status"])
|
||||
)
|
||||
repo.update(
|
||||
str(tool_doc["id"]), user, _api_to_update_fields(update_data),
|
||||
if update_data:
|
||||
UserToolsRepository(conn).update(
|
||||
str(tool_doc["id"]), ra.owner_id, _api_to_update_fields(update_data),
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except CredentialsRequired as err:
|
||||
return make_response(jsonify({"success": False, "message": str(err)}), 400)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error updating tool: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -596,12 +914,14 @@ class UpdateToolConfig(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
ra = require(conn, "tool", data["id"], user, "edit_credentials")
|
||||
tool_doc = _load_owned_row(conn, ra)
|
||||
if not tool_doc:
|
||||
return make_response(jsonify({"success": False}), 404)
|
||||
|
||||
tool_name = tool_doc.get("name")
|
||||
if tool_name == "mcp_tool":
|
||||
check_oauth_mcp_owner_only(ra, tool_doc.get("config"), data["config"])
|
||||
server_url = (data["config"].get("server_url") or "").strip()
|
||||
if server_url:
|
||||
try:
|
||||
@@ -633,11 +953,13 @@ class UpdateToolConfig(Resource):
|
||||
400,
|
||||
)
|
||||
|
||||
final_config = _merge_secrets_on_update(
|
||||
data["config"], existing_config, config_requirements, user
|
||||
)
|
||||
final_config = _prepare_tool_config(tool_doc, data["config"], config_requirements)
|
||||
|
||||
repo.update(str(tool_doc["id"]), user, {"config": final_config})
|
||||
repo.update(str(tool_doc["id"]), ra.owner_id, {"config": final_config})
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except CredentialsRequired as err:
|
||||
return make_response(jsonify({"success": False, "message": str(err)}), 400)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating tool config: {err}", exc_info=True
|
||||
@@ -684,20 +1006,12 @@ class UpdateToolActions(Resource):
|
||||
)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if tool_doc:
|
||||
repo.update(str(tool_doc["id"]), user, {"actions": data["actions"]})
|
||||
else:
|
||||
# Team editor write path (secrets stay owner-only — actions
|
||||
# carry no credentials, so editing them is safe).
|
||||
owner = effective_write_owner(conn, "tool", data["id"], user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
)
|
||||
repo.update(data["id"], owner, {"actions": data["actions"]})
|
||||
# ``edit`` covers action on/off, descriptions and approval
|
||||
# (``require_approval``); actions carry no credentials.
|
||||
ra = require(conn, "tool", data["id"], user, "edit")
|
||||
UserToolsRepository(conn).update(ra.resource_id, ra.owner_id, {"actions": data["actions"]})
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating tool actions: {err}", exc_info=True
|
||||
@@ -753,16 +1067,19 @@ class UpdateToolStatus(Resource):
|
||||
400,
|
||||
)
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
ra = require(conn, "tool", data["id"], user, "use")
|
||||
if ra.access == "owner":
|
||||
UserToolsRepository(conn).update(
|
||||
ra.resource_id, ra.owner_id, {"status": bool(data["status"])},
|
||||
)
|
||||
repo.update(
|
||||
str(tool_doc["id"]), user, {"status": bool(data["status"])},
|
||||
)
|
||||
else:
|
||||
# A grantee's "In my chats" switch is personal.
|
||||
check_action(ra, "use_in_own")
|
||||
UserToolPreferencesRepository(conn).set_in_chat(
|
||||
user, ra.resource_id, bool(data["status"])
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating tool status: {err}", exc_info=True
|
||||
@@ -802,13 +1119,13 @@ class DeleteTool(Resource):
|
||||
)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}), 404
|
||||
)
|
||||
repo.delete(str(tool_doc["id"]), user)
|
||||
ra = require(conn, "tool", data["id"], user, "delete")
|
||||
# Grants are removed by the ``user_tools`` delete trigger and
|
||||
# chat preferences by FK cascade; the switches have no FK.
|
||||
UserToolsRepository(conn).delete(ra.resource_id, ra.owner_id)
|
||||
delete_settings(conn, "tool", ra.resource_id)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error deleting tool: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
"""Workflow management routes."""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from flask import current_app, request
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.agents.workflows.cel_evaluator import (
|
||||
CelEvaluationError,
|
||||
validate_cel_expression,
|
||||
)
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
can_use_ref,
|
||||
resolve,
|
||||
sponsor_details,
|
||||
sponsors_after_save,
|
||||
)
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
|
||||
from docsgpt.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
|
||||
@@ -46,6 +54,118 @@ def _resolve_workflow(repo: WorkflowsRepository, workflow_id: str, user_id: str)
|
||||
return repo.get_by_legacy_id(workflow_id, user_id)
|
||||
|
||||
|
||||
def _workflow_access(conn, workflow_id: str, user_id: str, action: str):
|
||||
"""Resolve a workflow the caller may ``action``, and the id to act as.
|
||||
|
||||
The caller's own workflow is always theirs. Otherwise access comes from
|
||||
an agent of the workflow's owner that uses it: ``view`` to read it,
|
||||
``edit`` to change it, ``delete`` to remove it (checked on that agent).
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
workflow_id: Workflow UUID or legacy id.
|
||||
user_id: The caller.
|
||||
action: Agent action required (``view``, ``edit`` or ``delete``).
|
||||
|
||||
Returns:
|
||||
``(workflow, acting_user_id)``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't ``action``.
|
||||
"""
|
||||
repo = WorkflowsRepository(conn)
|
||||
own = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if own is not None:
|
||||
return own, user_id
|
||||
if not looks_like_uuid(str(workflow_id)):
|
||||
raise AccessDenied(404, "Workflow not found")
|
||||
workflow = repo.get_by_id(str(workflow_id))
|
||||
if workflow is None:
|
||||
raise AccessDenied(404, "Workflow not found")
|
||||
agent_ids = conn.execute(
|
||||
sql_text(
|
||||
"SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"
|
||||
),
|
||||
{"wid": str(workflow["id"]), "owner": workflow["user_id"]},
|
||||
).scalars().all()
|
||||
visible = False
|
||||
for agent_id in agent_ids:
|
||||
ra = resolve(conn, "agent", str(agent_id), user_id)
|
||||
if ra is None:
|
||||
continue
|
||||
visible = True
|
||||
if ra.can(action):
|
||||
return workflow, ra.owner_id
|
||||
if not visible:
|
||||
raise AccessDenied(404, "Workflow not found")
|
||||
raise AccessDenied(403, "Your access to this item doesn't allow that")
|
||||
|
||||
|
||||
def _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]:
|
||||
"""The ``(type, id)`` tools and sources a workflow's agent nodes reference.
|
||||
|
||||
Args:
|
||||
nodes: Nodes as the builder sends them (config under ``data`` or
|
||||
``data.config``, like the engine reads it).
|
||||
|
||||
Returns:
|
||||
list: ``("tool", id)`` and ``("source", id)`` pairs.
|
||||
"""
|
||||
refs: List[Tuple[str, str]] = []
|
||||
for node in nodes or []:
|
||||
if not isinstance(node, dict) or node.get("type") != "agent":
|
||||
continue
|
||||
data = node.get("data") or {}
|
||||
cfg = data.get("config") if isinstance(data.get("config"), dict) else data
|
||||
for resource_type, key in (("tool", "tools"), ("source", "sources")):
|
||||
values = cfg.get(key) or []
|
||||
if isinstance(values, (str, int)):
|
||||
values = [values]
|
||||
refs.extend((resource_type, str(v)) for v in values if v)
|
||||
return refs
|
||||
|
||||
|
||||
def _new_node_ref_denied(
|
||||
conn, previous_nodes: List[Dict], new_nodes: List[Dict], caller: str
|
||||
) -> Optional[AccessDenied]:
|
||||
"""403 for the first node tool/source ``caller`` newly adds but can't use.
|
||||
|
||||
A workflow runs as its owner, so an editor saving the owner's graph must
|
||||
not reference the owner's private tools or sources: the caller's own
|
||||
access counts (``use_in_own`` for a tool, ``use`` for a source), not the
|
||||
owner's. Refs already in the stored graph stay, like an agent's.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
previous_nodes: The stored graph's nodes, in builder shape.
|
||||
new_nodes: The nodes being saved.
|
||||
caller: The editor saving.
|
||||
|
||||
Returns:
|
||||
An :class:`AccessDenied` to return, or None when every new ref is fine.
|
||||
"""
|
||||
from docsgpt.agents.default_tools import is_synthesized_tool_id
|
||||
|
||||
existing = set(_node_refs(previous_nodes))
|
||||
for resource_type, resource_id in _node_refs(new_nodes):
|
||||
if (resource_type, resource_id) in existing:
|
||||
continue
|
||||
if resource_type == "tool" and is_synthesized_tool_id(resource_id):
|
||||
continue
|
||||
if resource_type == "source" and resource_id == "default":
|
||||
continue
|
||||
if not can_use_ref(conn, resource_type, resource_id, caller):
|
||||
return AccessDenied(403, f"{resource_type.capitalize()} not accessible")
|
||||
return None
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""403/404 in this module's ``error`` shape, plus the shared ``message`` key."""
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": err.message, "message": err.message}), err.status
|
||||
)
|
||||
|
||||
|
||||
def _write_graph(
|
||||
conn,
|
||||
pg_workflow_id: str,
|
||||
@@ -499,10 +619,10 @@ class WorkflowDetail(Resource):
|
||||
user_id = get_user_id()
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
repo = WorkflowsRepository(conn)
|
||||
workflow = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if workflow is None:
|
||||
return error_response("Workflow not found", 404)
|
||||
try:
|
||||
workflow, _acting = _workflow_access(conn, workflow_id, user_id, "view")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
pg_workflow_id = str(workflow["id"])
|
||||
graph_version = get_workflow_graph_version(workflow)
|
||||
nodes = WorkflowNodesRepository(conn).find_by_version(
|
||||
@@ -511,6 +631,7 @@ class WorkflowDetail(Resource):
|
||||
edges = WorkflowEdgesRepository(conn).find_by_version(
|
||||
pg_workflow_id, graph_version,
|
||||
)
|
||||
sponsored = sponsor_details(conn, "workflow", workflow)
|
||||
except Exception as err:
|
||||
return _workflow_error_response("Failed to fetch workflow", err)
|
||||
|
||||
@@ -519,6 +640,7 @@ class WorkflowDetail(Resource):
|
||||
"workflow": serialize_workflow(workflow),
|
||||
"nodes": [serialize_node(n) for n in nodes],
|
||||
"edges": [serialize_edge(e) for e in edges],
|
||||
"resource_sponsors": sponsored,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -533,37 +655,54 @@ class WorkflowDetail(Resource):
|
||||
nodes_data = data.get("nodes", [])
|
||||
edges_data = data.get("edges", [])
|
||||
|
||||
validation_errors = validate_workflow_structure(
|
||||
nodes_data, edges_data, user_id=user_id
|
||||
)
|
||||
if validation_errors:
|
||||
return error_response(
|
||||
"Workflow validation failed", errors=validation_errors
|
||||
)
|
||||
nodes_data = normalize_agent_node_json_schemas(nodes_data)
|
||||
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = WorkflowsRepository(conn)
|
||||
workflow = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if workflow is None:
|
||||
return error_response("Workflow not found", 404)
|
||||
try:
|
||||
workflow, acting = _workflow_access(conn, workflow_id, user_id, "edit")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# Validated as the owner: the workflow runs with the owner's
|
||||
# models, so their BYOM ids are the ones that must resolve.
|
||||
validation_errors = validate_workflow_structure(
|
||||
nodes_data, edges_data, user_id=acting
|
||||
)
|
||||
if validation_errors:
|
||||
return error_response(
|
||||
"Workflow validation failed", errors=validation_errors
|
||||
)
|
||||
nodes_data = normalize_agent_node_json_schemas(nodes_data)
|
||||
pg_workflow_id = str(workflow["id"])
|
||||
current_graph_version = get_workflow_graph_version(workflow)
|
||||
if acting != user_id:
|
||||
previous_nodes = [
|
||||
serialize_node(n)
|
||||
for n in WorkflowNodesRepository(conn).find_by_version(
|
||||
pg_workflow_id, current_graph_version,
|
||||
)
|
||||
]
|
||||
denied = _new_node_ref_denied(conn, previous_nodes, nodes_data, user_id)
|
||||
if denied is not None:
|
||||
return _denied(denied)
|
||||
next_graph_version = current_graph_version + 1
|
||||
|
||||
_write_graph(
|
||||
conn, pg_workflow_id, next_graph_version,
|
||||
nodes_data, edges_data,
|
||||
)
|
||||
repo.update(
|
||||
pg_workflow_id, user_id,
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"current_graph_version": next_graph_version,
|
||||
},
|
||||
workflow_fields = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"current_graph_version": next_graph_version,
|
||||
}
|
||||
# A node tool/source the owner can't use runs as the editor
|
||||
# who attached it (its sponsor); record who that is.
|
||||
sponsors = sponsors_after_save(
|
||||
conn, "workflow", workflow, acting, user_id, _node_refs(nodes_data)
|
||||
)
|
||||
if sponsors != (workflow.get("resource_sponsors") or {}):
|
||||
workflow_fields["resource_sponsors"] = sponsors
|
||||
repo.update(pg_workflow_id, acting, workflow_fields)
|
||||
WorkflowNodesRepository(conn).delete_other_versions(
|
||||
pg_workflow_id, next_graph_version,
|
||||
)
|
||||
@@ -582,11 +721,12 @@ class WorkflowDetail(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = WorkflowsRepository(conn)
|
||||
workflow = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if workflow is None:
|
||||
return error_response("Workflow not found", 404)
|
||||
try:
|
||||
workflow, acting = _workflow_access(conn, workflow_id, user_id, "delete")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# ON DELETE CASCADE on workflow_nodes/edges cleans children.
|
||||
repo.delete(str(workflow["id"]), user_id)
|
||||
repo.delete(str(workflow["id"]), acting)
|
||||
except Exception as err:
|
||||
return _workflow_error_response("Failed to delete workflow", err)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ def _collect_source_ids(agent: Dict[str, Any]) -> List[str]:
|
||||
|
||||
|
||||
def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -> List[str]:
|
||||
"""Drop source ids the agent's owner may not read.
|
||||
"""Drop source ids the agent may not read.
|
||||
|
||||
``_collect_source_ids`` trusts whatever the agent row carries, and this
|
||||
service searches those ids directly. That made it the second half of a
|
||||
@@ -64,20 +64,23 @@ def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -
|
||||
agent: The agent row resolved from the API key.
|
||||
source_ids: Ids extracted from that row.
|
||||
|
||||
A source the owner can't read still searches while the editor who
|
||||
attached it (its sponsor) can edit the agent and read the source.
|
||||
|
||||
Returns:
|
||||
list: The subset the agent's owner may read.
|
||||
list: The subset the agent's owner (or a live sponsor) may read.
|
||||
"""
|
||||
owner = agent.get("user_id")
|
||||
if not owner:
|
||||
logger.warning("Agent %s has no owner; refusing to search its sources.", agent.get("id"))
|
||||
return []
|
||||
|
||||
from docsgpt.api.user.team_sharing import can_access
|
||||
from docsgpt.api.user.resource_access import ref_principal
|
||||
|
||||
allowed = []
|
||||
for sid in source_ids:
|
||||
try:
|
||||
permitted = can_access(conn, "source", str(sid), owner)
|
||||
permitted = ref_principal(conn, "agent", agent, "source", str(sid)) is not None
|
||||
except Exception:
|
||||
# Fail closed, matching the answer path.
|
||||
logger.warning("Access check failed for source %s; dropping it.", sid)
|
||||
|
||||
@@ -35,6 +35,24 @@ def looks_like_uuid(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(_UUID_RE.match(value))
|
||||
|
||||
|
||||
def canonical_uuid(value: Any) -> Any:
|
||||
"""The lowercase canonical form of a UUID string; anything else unchanged.
|
||||
|
||||
Postgres accepts any casing on ``CAST(... AS uuid)`` but returns the
|
||||
lowercase form, so an id used as a dict key or stored in a JSON/array
|
||||
column must be canonical to match what the database hands back.
|
||||
|
||||
Args:
|
||||
value: A candidate id.
|
||||
|
||||
Returns:
|
||||
``str(UUID(value))`` for a UUID, else ``value`` as given.
|
||||
"""
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
return str(UUID(value)) if looks_like_uuid(value) else value
|
||||
|
||||
|
||||
def row_to_dict(row: Any) -> dict:
|
||||
"""Convert a SQLAlchemy ``Row`` to a plain JSON-safe dict.
|
||||
|
||||
|
||||
@@ -187,6 +187,23 @@ Index(
|
||||
team_resource_grants_table.c.resource_id,
|
||||
)
|
||||
|
||||
# Per-asset sharing switches set by the owner (migration 0038). A missing row
|
||||
# means every switch is at its default; keys are validated in
|
||||
# ``docsgpt/api/user/resource_access.py``.
|
||||
resource_share_settings_table = Table(
|
||||
"resource_share_settings",
|
||||
metadata,
|
||||
Column("resource_type", Text, primary_key=True),
|
||||
Column("resource_id", UUID(as_uuid=True), primary_key=True),
|
||||
Column("settings", JSONB, nullable=False, server_default="{}"),
|
||||
Column("updated_by", Text),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
CheckConstraint(
|
||||
"resource_type IN ('agent', 'source', 'prompt', 'tool')",
|
||||
name="resource_share_settings_type_check",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
prompts_table = Table(
|
||||
"prompts",
|
||||
@@ -218,6 +235,22 @@ user_tools_table = Table(
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
# A grantee's personal "In my chats" switch for a tool shared with them
|
||||
# (migration 0038). The owner's own switch stays ``user_tools.status``.
|
||||
user_tool_preferences_table = Table(
|
||||
"user_tool_preferences",
|
||||
metadata,
|
||||
Column("user_id", Text, primary_key=True),
|
||||
Column(
|
||||
"tool_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("user_tools.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column("in_chat", Boolean, nullable=False, server_default="false"),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
token_usage_table = Table(
|
||||
"token_usage",
|
||||
metadata,
|
||||
@@ -367,6 +400,9 @@ agents_table = Table(
|
||||
# Per-agent behavior contract (AgentConfig — guardrails today). Empty
|
||||
# ``{}`` parses to guardrails-disabled.
|
||||
Column("config", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
|
||||
# ``"<type>:<id>" -> user_id`` of the editor vouching for a referenced
|
||||
# resource the owner can't use (migration 0039, see resource_access.py).
|
||||
Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
|
||||
Column("default_model_id", Text),
|
||||
Column("folder_id", UUID(as_uuid=True), ForeignKey("agent_folders.id", ondelete="SET NULL")),
|
||||
Column("workflow_id", UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="SET NULL")),
|
||||
@@ -925,6 +961,8 @@ workflows_table = Table(
|
||||
Column("name", Text, nullable=False),
|
||||
Column("description", Text),
|
||||
Column("current_graph_version", Integer, nullable=False, server_default="1"),
|
||||
# Same shape as ``agents.resource_sponsors``, for node tools and sources.
|
||||
Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("legacy_mongo_id", Text),
|
||||
|
||||
@@ -43,14 +43,14 @@ class AgentsRepository:
|
||||
"limited_token_mode", "limited_request_mode",
|
||||
"allow_system_prompt_override",
|
||||
"shared", "shared_token", "shared_metadata",
|
||||
"tools", "json_schema", "models", "config", "legacy_mongo_id",
|
||||
"tools", "json_schema", "models", "config", "resource_sponsors", "legacy_mongo_id",
|
||||
"created_at", "updated_at", "last_used_at",
|
||||
}
|
||||
|
||||
for col, val in kwargs.items():
|
||||
if col not in _ALLOWED or val is None:
|
||||
continue
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
|
||||
# JSONB columns: pass the Python object directly. SQLAlchemy
|
||||
# Core's JSONB type processor json.dumps it once during
|
||||
# bind; pre-serialising would double-encode and the value
|
||||
@@ -234,7 +234,7 @@ class AgentsRepository:
|
||||
allowed = {
|
||||
"name", "description", "agent_type", "status", "key", "slug", "source_id",
|
||||
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
|
||||
"config",
|
||||
"config", "resource_sponsors",
|
||||
"default_model_id", "folder_id", "workflow_id",
|
||||
"extra_source_ids", "image",
|
||||
"limited_token_mode", "token_limit",
|
||||
@@ -248,7 +248,7 @@ class AgentsRepository:
|
||||
return False
|
||||
values: dict = {}
|
||||
for col, val in filtered.items():
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
|
||||
values[col] = val
|
||||
elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"):
|
||||
values[col] = str(val) if val else None
|
||||
@@ -294,7 +294,7 @@ class AgentsRepository:
|
||||
allowed = {
|
||||
"name", "description", "agent_type", "status", "key", "slug", "source_id",
|
||||
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
|
||||
"config",
|
||||
"config", "resource_sponsors",
|
||||
"default_model_id", "folder_id", "workflow_id",
|
||||
"extra_source_ids", "image",
|
||||
"limited_token_mode", "token_limit",
|
||||
@@ -309,7 +309,7 @@ class AgentsRepository:
|
||||
|
||||
values: dict = {}
|
||||
for col, val in filtered.items():
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
|
||||
if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
|
||||
# See note in create(): JSONB columns receive Python
|
||||
# objects, the type processor handles serialisation.
|
||||
values[col] = val
|
||||
|
||||
@@ -107,8 +107,9 @@ class TeamResourceGrantsRepository:
|
||||
that one member (the caller must have validated they're a team member).
|
||||
``ON CONFLICT`` on the functional dedup index makes re-sharing
|
||||
last-write-wins on ``access_level``. The caller MUST have verified
|
||||
``granted_by`` owns the resource (dispatched by ``resource_type``) — the
|
||||
polymorphic table has no FK to catch a type/id mismatch.
|
||||
``granted_by`` holds ``share`` on the resource (``resource_access.require``,
|
||||
dispatched by ``resource_type``) and pass the real owner as ``owner_id`` —
|
||||
the polymorphic table has no FK to catch a type/id mismatch.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Repository for the ``user_tool_preferences`` table.
|
||||
|
||||
A grantee's personal "In my chats" switch for a tool shared with them. The
|
||||
owner's own switch stays in ``user_tools.status``; a missing row here means
|
||||
off, so sharing a tool never adds it to anyone's chats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
|
||||
|
||||
class UserToolPreferencesRepository:
|
||||
"""Per-user, per-tool chat preferences for shared tools."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def set_in_chat(self, user_id: str, tool_id: str, in_chat: bool) -> None:
|
||||
"""Upsert the caller's "In my chats" switch for one tool.
|
||||
|
||||
Args:
|
||||
user_id: The grantee's user id.
|
||||
tool_id: The shared tool's UUID.
|
||||
in_chat: Whether the tool joins the grantee's agentless chats.
|
||||
"""
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO user_tool_preferences (user_id, tool_id, in_chat)
|
||||
VALUES (:user_id, CAST(:tool_id AS uuid), :in_chat)
|
||||
ON CONFLICT (user_id, tool_id)
|
||||
DO UPDATE SET in_chat = EXCLUDED.in_chat, updated_at = now()
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "tool_id": str(tool_id), "in_chat": bool(in_chat)},
|
||||
)
|
||||
|
||||
def in_chat_many(self, user_id: str, tool_ids: Iterable[str]) -> dict[str, bool]:
|
||||
"""``tool_id -> in_chat`` for the given tools; missing rows are False.
|
||||
|
||||
Args:
|
||||
user_id: The grantee's user id.
|
||||
tool_ids: Tool ids to look up (non-UUIDs are ignored).
|
||||
|
||||
Returns:
|
||||
A dict with one entry per UUID-shaped input id.
|
||||
"""
|
||||
ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
|
||||
out = {tid: False for tid in ids}
|
||||
if not ids or not user_id:
|
||||
return out
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT tool_id, in_chat FROM user_tool_preferences
|
||||
WHERE user_id = :user_id AND tool_id = ANY(CAST(:ids AS uuid[]))
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "ids": ids},
|
||||
).fetchall()
|
||||
for tool_id, in_chat in rows:
|
||||
out[str(tool_id)] = bool(in_chat)
|
||||
return out
|
||||
|
||||
def list_in_chat_tool_ids(self, user_id: str) -> list[str]:
|
||||
"""Ids of tools the user switched into their chats (any owner).
|
||||
|
||||
Args:
|
||||
user_id: The grantee's user id.
|
||||
|
||||
Returns:
|
||||
Tool ids as strings; access must still be re-checked by the caller.
|
||||
"""
|
||||
if not user_id:
|
||||
return []
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT tool_id FROM user_tool_preferences
|
||||
WHERE user_id = :user_id AND in_chat = true
|
||||
ORDER BY updated_at
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id},
|
||||
).fetchall()
|
||||
return [str(r[0]) for r in rows]
|
||||
@@ -9,6 +9,7 @@ Covers CRUD on workflow metadata:
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
@@ -85,12 +86,17 @@ class WorkflowsRepository:
|
||||
return [row_to_dict(r) for r in result.fetchall()]
|
||||
|
||||
def update(self, workflow_id: str, user_id: str, fields: dict) -> bool:
|
||||
allowed = {"name", "description", "current_graph_version"}
|
||||
allowed = {"name", "description", "current_graph_version", "resource_sponsors"}
|
||||
filtered = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not filtered:
|
||||
return False
|
||||
|
||||
set_parts = [f"{col} = :{col}" for col in filtered]
|
||||
set_parts = [
|
||||
f"{col} = CAST(:{col} AS jsonb)" if col == "resource_sponsors" else f"{col} = :{col}"
|
||||
for col in filtered
|
||||
]
|
||||
if "resource_sponsors" in filtered:
|
||||
filtered["resource_sponsors"] = json.dumps(filtered["resource_sponsors"] or {})
|
||||
set_parts.append("updated_at = now()")
|
||||
params = {**filtered, "id": workflow_id, "user_id": user_id}
|
||||
|
||||
|
||||
+100
-17
@@ -17,7 +17,8 @@ from the Tailwind scale, never from arbitrary `[...]` values. Runtime values
|
||||
go in CSS custom properties or, when unavoidable, an inline style with a
|
||||
disable comment. Conditional classes go through `cn(...)`, never a template
|
||||
literal (prettier sorts the classes inside `cn`, and `cn` merges conflicts).
|
||||
Every user-visible string, attributes included (`aria-label`, IconButton
|
||||
What a role may do is gated with `can(item, action)` and a refused action is
|
||||
not rendered (see Access and roles). Every user-visible string, attributes included (`aria-label`, IconButton
|
||||
`label`, `placeholder`, `title`, `alt`, `hint`), is a `t()` key in all seven
|
||||
locales (`de en es jp ru zh zh-TW`); admin pages are English by design.
|
||||
Interpolated user text (a name, a timezone, a date) passes `interpolation: {
|
||||
@@ -228,13 +229,14 @@ pill`, the only `outline-primary` on the agent pages themselves (the Access
|
||||
`outline field pill` (Save draft, Preview with `Play` first), Cancel `ghost
|
||||
field pill`, and page-level actions go in the `ActionMenu` (`size="toolbar"`)
|
||||
at the end of the title row, through `SectionShell titleAction`: Access
|
||||
details and Share with team on Overview, and Preview until the agent is
|
||||
details and Share with team on Overview (each only when the role allows
|
||||
it, see Access and roles), and Preview until the agent is
|
||||
published (before that the preview only says "Publish to preview"). So the
|
||||
row never holds more than three buttons and fits a phone, where the main
|
||||
one stretches across it (`flex-1 sm:flex-none`; `PageToolbar`'s action slot
|
||||
spans the stacked row below `sm`). The workflow builder's toolbar has no
|
||||
title row and keeps its ⋯ at the row's end, holding Edit details, Access
|
||||
details and Delete (see Workflow builder). The
|
||||
details, Share with team and Delete (see Workflow builder). The
|
||||
agent's Delete is in Overview's danger zone; only the workflow builder's
|
||||
toolbar has it in the ⋯ menu, as a `destructive` item. The row itself is
|
||||
`agents/components/AgentPageToolbar` (see Page chrome). A row's common
|
||||
@@ -294,7 +296,10 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
|
||||
(`has-[[data-variant=section-toggle]:focus-visible]:ring-3 … ring-inset`,
|
||||
inset because a scrolling column clips an outset ring), so pages pass
|
||||
nothing for it. Status badges go beside the button, not inside it, or the
|
||||
hover underline runs under them.
|
||||
hover underline runs under them. Because the ring comes from the Card,
|
||||
`section-toggle` only goes inside a Card: a collapsible group in a Modal
|
||||
or drawer (Share's Access settings) is the inline `link sm` disclosure
|
||||
toggle above.
|
||||
- Drop `text-white` on default and destructive buttons; the foreground token
|
||||
already provides it. Drop `disabled:cursor-not-allowed` on buttons; the
|
||||
base disables pointer events. Fields are the other way round (see Focus,
|
||||
@@ -466,7 +471,10 @@ pill, including schedule and run status pills (`agents/schedules/StatusBadge.tsx
|
||||
maps schedule and run statuses to Badge variants), trace chips and statuses,
|
||||
token scope chips, "Disabled" and tool chips. A grey chip is `neutral`, never
|
||||
a `bg-muted` pill. The admin role is `default` wherever it shows (Teams, Admin
|
||||
→ Users); every other role is `neutral`. Chips that show code (token scopes) pass `font-mono`, and
|
||||
→ Users); every other role is `neutral`. A shared asset's tile (agent,
|
||||
source, tool, prompt) shows the caller's role with `components/RoleBadge`: a
|
||||
`neutral` Badge with `Users` first (Editor, Viewer, from `roleOf()`); your own
|
||||
assets show none. Chips that show code (token scopes) pass `font-mono`, and
|
||||
stat chips `tabular-nums`, as approved exceptions. HTTP method pills take their variant from
|
||||
`getMethodBadgeVariant` (`utils/httpMethodColors.ts`): GET `success`, POST
|
||||
`info`, PUT `warning`, DELETE `destructive`, PATCH `default`, anything else
|
||||
@@ -603,6 +611,13 @@ state assignment) is not a bare row: each of its fields has a floating
|
||||
label. Never hand-build a label above a field, a
|
||||
hand-positioned floating label, or a `div.flex-col` + `Label` + `<p>` stack.
|
||||
|
||||
A saved secret (an API key, a tool's credential, a custom header value) never
|
||||
comes back from the API, for any role. Its field is empty and `type="password"`;
|
||||
in a FormField the saved state is the `hint` ("Saved. Leave empty to keep
|
||||
it."), because a placeholder is hidden while the label rests. Only a
|
||||
label-less table cell carries it as the placeholder
|
||||
(`settings.tools.savedSecretPlaceholder`). Never a `••••` placeholder.
|
||||
|
||||
### SettingRow (`ui/setting-row.tsx`)
|
||||
|
||||
A setting with a control on the right (a Switch, a short Input) is
|
||||
@@ -632,7 +647,10 @@ description use a Switch in a SettingRow instead.
|
||||
### Switch, TimePicker and Calendar (`ui/switch.tsx`, `ui/time-picker.tsx`, `ui/calendar.tsx`)
|
||||
|
||||
`Switch` (Radix) is an on/off setting, placed inside a `SettingRow` that names
|
||||
it. The track is `primary` when on and `bg-input` when off, with a white
|
||||
it. A tile's own on/off (the tool tile's "In my chats") is the one exception:
|
||||
a `Label text-muted-foreground text-xs font-normal` + `Switch` pair at the
|
||||
tile's bottom-right. For a shared tool it is the caller's own preference,
|
||||
never a switch that turns the tool off for everyone. The track is `primary` when on and `bg-input` when off, with a white
|
||||
thumb in both themes (see Elevation).
|
||||
|
||||
`TimePicker` picks a time of day as two `SelectTrigger`s, hours and minutes,
|
||||
@@ -680,6 +698,11 @@ row (the Agents filter pills) are not a ToggleGroup: they are `Button asChild
|
||||
variant={active ? 'outline' : 'ghost-muted'} size="sm" shape="pill"` around
|
||||
each `Link`, with `aria-current="page"` on the current one.
|
||||
|
||||
Filtering a list by kind (a team's shared resources, Share's People) is this
|
||||
`xs` group in its muted track, each item `{label} {formatCount(n)}`, beside a
|
||||
`SearchInput size="sm"` (`w-full sm:w-56`); no match is `EmptyState size="xs"
|
||||
illustration="none"`.
|
||||
|
||||
### Separator (`ui/separator.tsx`)
|
||||
|
||||
A 1px `bg-border` rule, horizontal or `orientation="vertical"`, decorative
|
||||
@@ -717,7 +740,9 @@ height of its own). `label` puts a muted caption under the ring (a long job:
|
||||
Convert to wiki, Enable GraphRAG); it is also the ring's name. `size` takes
|
||||
the Spinner sizes (`default` unless set): `sm` inside a picker (the
|
||||
MultiSelect popover's list), `lg` for a full panel (a remote device's
|
||||
config). Spinners
|
||||
config). A role guard shows it while it resolves, then redirects
|
||||
silently: `AdminRoute` at `fill="screen"`, `AgentRouteGuard` at
|
||||
`fill="parent"` (it renders inside the app shell's scroll column) (see Access and roles). Spinners
|
||||
inside a control (a busy Button, a picker's `sm` ring) stay `Spinner`.
|
||||
|
||||
Nothing to show is `EmptyState`: `size` `default | sm | xs` (128 / 96 / 64px
|
||||
@@ -838,6 +863,43 @@ variant="destructive"` above the form, not text in or beside the button.
|
||||
publish validation) is a destructive `Alert` floating at `z-20` on an opaque
|
||||
`bg-card rounded-xl shadow-md` wrapper that stays until closed; a toast
|
||||
would truncate each error to one line and dismiss itself.
|
||||
- A switch moves at once and flips back when the server refuses, with a
|
||||
destructive toast (an Alert inside a modal). A delete removes its row only
|
||||
after the server confirms; a 403 shows `errors.forbidden`.
|
||||
|
||||
### Access and roles
|
||||
|
||||
Agents, sources, tools and prompts come to the UI with `access` and
|
||||
`allowed_actions`. Gate every control with `can(item, action)`
|
||||
(`utils/accessUtils`; `canAgent` in `agents/agentAccess.ts` for agents, which
|
||||
also refuses Logs, Schedules and Pin on a draft), never on `ownership`,
|
||||
`team_access` or the user id.
|
||||
|
||||
- An action the role doesn't allow is not rendered. A menu builds its
|
||||
options from `can()`, and a ⋯ with no options is not drawn; a footer or
|
||||
row left empty goes, with its `Separator`. Never a disabled Save or a
|
||||
disabled menu item for a role.
|
||||
- A control stays visible but `disabled` only when it shows state the caller
|
||||
should still see (a switch's on/off, a policy's value). Then the reason is
|
||||
on screen (an Alert or a FormField `hint`), never a bare grey control. A
|
||||
control that can't apply to the caller at all (a tool's "In my chats" when
|
||||
the grant doesn't allow it in their chats) is hidden, not disabled.
|
||||
- A view-only form opens with the same fields and `ViewOnlyNotice`
|
||||
(`components/ViewOnlyNotice`: an `Alert role="note"` with `Lock` first and
|
||||
`common.viewOnlyNotice`) as its first child. Where only part of an editable
|
||||
form is locked (a tool's credentials when the owner turned off "Editors can
|
||||
change credentials"), the same notice passes its own `message`. There is no Save; Cancel becomes a lone Close.
|
||||
Fields are `disabled` (a group: `<fieldset disabled className="min-w-0">`);
|
||||
long text the user reads or copies (a prompt, a chunk) is `readOnly`, so it
|
||||
keeps full contrast and scrolls. Titles and menu items swap Edit and
|
||||
`Pencil` for View and `Eye`.
|
||||
- Source views follow the same rule: every write (Add file, Sync, Add chunk,
|
||||
Edit, Delete) shows only with `can(source, 'edit')`; reading, copying and
|
||||
paging stay.
|
||||
- A page the role can't open is refused twice: its tabs and section items
|
||||
are filtered by the same action (`AgentPageHeader`, `navigation/sections`),
|
||||
and its route is wrapped in a guard (`AgentRouteGuard`) that redirects to
|
||||
the list as a deep-link fallback, with `LoadingState` while it resolves.
|
||||
|
||||
### Alert (`ui/alert.tsx`)
|
||||
|
||||
@@ -890,7 +952,9 @@ line, a trailing control) is `ListRow` inside `ListRows` (`divide-y
|
||||
divide-border`, no box of its own; wrap it in `Card padding="none"` or a
|
||||
bordered list for one). Rows are `px-4 py-3`, the title `text-sm
|
||||
font-medium`. `interactive` (with `asChild` around a `<Link>` or `<button>`)
|
||||
hovers to `bg-accent` and draws an inset focus ring. An icon square in
|
||||
hovers to `bg-accent` and draws an inset focus ring. `selected` marks the
|
||||
row whose detail is open in a drawer beside the list (a team's shared
|
||||
resources), exactly as a selected TableRow (see Table). An icon square in
|
||||
`leading` is a plain `bg-muted text-muted-foreground size-8 rounded-md` span.
|
||||
In a narrow side panel (the graph node panel's relationships) rows are
|
||||
`size="sm"`: `px-2 py-1.5`, `gap-2.5`, `rounded-md` and top-aligned so a small
|
||||
@@ -919,7 +983,11 @@ the current page size, so picking a bigger size never hides the way back
|
||||
("Page 1 of 1", chevrons off). Counts and numbers in the UI format on the app
|
||||
language: `formatCount` (`utils/dateTimeUtils`, `Intl` on `intlLocale()`), never
|
||||
`toLocaleString()` or a raw `{{count}}`; plural keys get the number as `count`
|
||||
and the formatted text as its own param. The one exception is a headline
|
||||
and the formatted text as `formatted` (`{{formatted}} members`), a key with no
|
||||
plural passes only `formatted`, and a count is never baked into the key name
|
||||
(`memberCountOne`). `jp` and `zhTW` aren't language tags i18next knows, so it
|
||||
plurals them by English rules: every plural key there has an `_one` form too
|
||||
(the same text as `_other`), or a count of 1 falls back to English. The one exception is a headline
|
||||
total that can reach tens of thousands (the chunk count in the Chunks byline
|
||||
and embedded toolbar): it may use `abbreviateCount` (`components/chunkUtils`,
|
||||
`Intl` compact notation: "12K", "12 тыс.", "1.2万"; grouped digits where the
|
||||
@@ -986,8 +1054,8 @@ The title is for chats only: the open conversation's name, or the agent's
|
||||
name on a new agent chat, with the agent's `Avatar` in front. A plain new chat
|
||||
has no title. So do section pages (settings, admin, an agent's pages), because
|
||||
`SectionShell` already draws their title. When the chat has actions, the title
|
||||
is a `ghost sm` Button that opens a `DropdownMenu` with Edit agent (owned
|
||||
agents), Share, Rename and Delete. Rename edits the name in place, as the
|
||||
is a `ghost sm` Button that opens a `DropdownMenu` with Edit agent (a role
|
||||
that may open its edit page), Share, Rename and Delete. Rename edits the name in place, as the
|
||||
sidebar row does. A title with no actions (a shared agent's new chat) is
|
||||
plain text.
|
||||
|
||||
@@ -1057,8 +1125,10 @@ variant="field"` over a `CommandList` of `CommandItem`s, so the arrow keys
|
||||
chunk's previous / next (`IconButton ghost-muted icon-sm pill`, and the
|
||||
arrow keys), then Edit (`outline sm pill`, `Pencil` first) and an
|
||||
`ActionMenu size="toolbar"` (Copy text; a chunk's also has Delete, a
|
||||
destructive item). A wiki page has Edit (editors only) and the same menu
|
||||
with Copy text, and no previous / next; the navigator walks them.
|
||||
destructive item). A wiki page has Edit and the same menu
|
||||
with Copy text, and no previous / next; the navigator walks them. Edit,
|
||||
Delete and Add chunk show only to a role that may edit (see Access and
|
||||
roles).
|
||||
- **Read in the page, edit in a drawer**: Edit and Add chunk open
|
||||
`SourceEditSheet`, a right `Sheet size="wide"` (a working surface): title
|
||||
and a mono description (path, version, tokens), then any `fields` edited
|
||||
@@ -1112,7 +1182,8 @@ pill`) and Save (`default lg pill`, off until the draft or a field changes, and
|
||||
source chunks as `filled sm interactive` tiles. A tile opens a read drawer
|
||||
(`Sheet size="detail"`, one record) with the chunk rendered and the entity's
|
||||
name marked in the brand tint (`bg-secondary`), and Open in Files and Edit
|
||||
(the same `SourceEditSheet`) in its footer; Cancel or Discard in that edit
|
||||
(the same `SourceEditSheet`) in its footer, which is omitted when neither
|
||||
applies; Cancel or Discard in that edit
|
||||
drawer returns to the read drawer, Save closes both, and closing it leaves
|
||||
the node selected. The relationship list is capped server-side: its heading
|
||||
counts the true total (`relationships_total`) and, when the list is partial,
|
||||
@@ -1155,8 +1226,8 @@ opens the workflow details. The status Badge follows (`success` Published,
|
||||
`neutral` Draft until the first save), then the tabs. On the right: "Unsaved
|
||||
changes" as muted `text-sm` meta while there are any, Preview, Save, and the
|
||||
⋯ (`ActionMenu size="toolbar"`): Edit details first (the same drawer, for
|
||||
anyone who doesn't try the name), then Access details and Delete once the
|
||||
workflow is saved.
|
||||
anyone who doesn't try the name), then Access details, Share with team and
|
||||
Delete once the workflow is saved, each only when the role allows it.
|
||||
|
||||
The workflow details are a right `Sheet size="default"`
|
||||
(`components/WorkflowDetailsSheet.tsx`), built like AgentPreviewSheet (header,
|
||||
@@ -1250,6 +1321,13 @@ ScheduleFormModal's editable name, the search palette). A step heading
|
||||
under a Back button uses the title's classes (`text-xl leading-tight
|
||||
font-semibold`), not a larger size.
|
||||
|
||||
A list in a modal that can grow long (Share's People) shows the first three
|
||||
once it passes five, a `link inline` "Show all N" (12px `ArrowRight`) in its
|
||||
SectionHeader's `actions` and a muted `text-xs` "and N more" line, counts
|
||||
through `formatCount`. The full list is a second step: a `ghost sm` Back with
|
||||
`ArrowLeft`, the title-class heading, then the search and kind filter (see
|
||||
ToggleGroup), with `hideTitle` on that step only.
|
||||
|
||||
Buttons go in `footer`, never in `children`. The footer stacks full width on
|
||||
phones (primary on top) and sits in a right-aligned row from `sm` up. The
|
||||
standard pair is `footer={<ModalActions cancelLabel onCancel submitLabel
|
||||
@@ -1286,7 +1364,12 @@ one shared drawer, `agents/components/AgentPreviewSheet`, for workflow and
|
||||
classic agents alike: `size="wide"`, a `SheetTitle`
|
||||
and a one-line `SheetDescription` (the agent's name) in a header padded
|
||||
`pr-12` to clear the close X, an `info` Running badge while it answers, a
|
||||
`Separator`, then the preview. The workflow preview's Execution details and
|
||||
`Separator`, then the preview. Every right drawer with a custom header
|
||||
(AgentPreviewSheet, SourceEditSheet, GraphChunkSheet, a team's resource
|
||||
detail) is that recipe: `SheetContent` `p-0`, a header at `px-6 pt-6 pr-12
|
||||
pb-4` that never scrolls, a `Separator`, then the one scrolling body at `px-6
|
||||
py-6`. The title wraps (`wrap-break-word`), it doesn't truncate. The row whose
|
||||
drawer is open is `selected` (ListRow or TableRow). The workflow preview's Execution details and
|
||||
Artifacts rows are the answer's step-row recipe (see Alert), each step a
|
||||
`subtle sm` panel with its output in a `filled sm` well.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
agentEditPathFor,
|
||||
sharedAgentPath,
|
||||
} from './agents/paths';
|
||||
import { canOpenAgentEditor } from './agents/agentAccess';
|
||||
import { Agent } from './agents/types';
|
||||
import conversationService from './api/services/conversationService';
|
||||
import userService from './api/services/userService';
|
||||
@@ -391,9 +392,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
const currentConversation = conversationId
|
||||
? conversations?.data?.find((c) => c.id === conversationId)
|
||||
: undefined;
|
||||
const ownsSelectedAgent = Boolean(
|
||||
selectedAgent?.id && agents?.some((a) => a.id === selectedAgent.id),
|
||||
);
|
||||
// Edit agent is offered to a role that may open the edit page.
|
||||
const canEditSelectedAgent = canOpenAgentEditor(selectedAgent, agents);
|
||||
const mobileTitle = routeSection
|
||||
? undefined
|
||||
: (currentConversation?.name ?? selectedAgent?.name);
|
||||
@@ -863,7 +863,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
onRename={updateConversationName}
|
||||
onDelete={handleDeleteConversation}
|
||||
editAgentPath={
|
||||
!routeSection && ownsSelectedAgent && selectedAgent
|
||||
!routeSection && canEditSelectedAgent && selectedAgent
|
||||
? agentEditPathFor(selectedAgent)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
goToLevel: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) =>
|
||||
selector({ preference: { token: null, agents: [] } }),
|
||||
useDispatch: () => mocks.dispatch,
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: { deleteAgent: mocks.deleteAgent },
|
||||
}));
|
||||
|
||||
vi.mock('../navigation/SidebarLevelProvider', () => ({
|
||||
useSidebarLevel: () => ({ goToLevel: mocks.goToLevel }),
|
||||
}));
|
||||
|
||||
vi.mock('../modals/MoveToFolderModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({
|
||||
default: ({
|
||||
modalState,
|
||||
handleSubmit,
|
||||
}: {
|
||||
modalState: string;
|
||||
handleSubmit: () => void;
|
||||
}) =>
|
||||
modalState === 'ACTIVE' ? (
|
||||
<button type="button" data-testid="confirm" onClick={handleSubmit} />
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import AgentCard from './AgentCard';
|
||||
import type { Agent } from './types';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const OWNER_ACTIONS = [
|
||||
'delete',
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'manage_settings',
|
||||
'move_folder',
|
||||
'pin',
|
||||
'publish',
|
||||
'share',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
const EDITOR_ACTIONS = [
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'pin',
|
||||
'publish',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
const VIEWER_ACTIONS = ['pin', 'use'];
|
||||
|
||||
const agentWith = (
|
||||
access: 'owner' | 'editor' | 'viewer',
|
||||
allowed: string[],
|
||||
): Agent =>
|
||||
({
|
||||
id: 'a1',
|
||||
name: 'Deal Desk',
|
||||
description: 'Researches deals',
|
||||
status: 'published',
|
||||
agent_type: 'classic',
|
||||
ownership: access === 'owner' ? 'user' : 'team',
|
||||
team_access: access === 'owner' ? null : access,
|
||||
access,
|
||||
allowed_actions: allowed,
|
||||
}) as Agent;
|
||||
|
||||
describe('AgentCard menu', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.deleteAgent.mockReset();
|
||||
});
|
||||
|
||||
const render = async (agent: Agent, section: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<AgentCard agent={agent} agents={[agent]} section={section} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const openMenu = async () => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="agents.card.actions"]',
|
||||
);
|
||||
if (!trigger) return [];
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
);
|
||||
};
|
||||
|
||||
const menuLabels = async () =>
|
||||
(await openMenu()).map((item) => item.textContent);
|
||||
|
||||
it('gives the owner the full menu', async () => {
|
||||
await render(agentWith('owner', OWNER_ACTIONS), 'user');
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.edit',
|
||||
'agents.exportAgent',
|
||||
'agents.shareWithTeam',
|
||||
'agents.card.pin',
|
||||
'agents.folders.moveToFolder',
|
||||
'agents.form.buttons.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives an editor Logs, Edit, Export and Pin', async () => {
|
||||
await render(agentWith('editor', EDITOR_ACTIONS), 'team');
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.edit',
|
||||
'agents.exportAgent',
|
||||
'agents.card.pin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('brings Share and Delete back for an editor the owner allows', async () => {
|
||||
await render(
|
||||
agentWith('editor', [...EDITOR_ACTIONS, 'share', 'delete']),
|
||||
'team',
|
||||
);
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.edit',
|
||||
'agents.exportAgent',
|
||||
'agents.shareWithTeam',
|
||||
'agents.card.pin',
|
||||
'agents.form.buttons.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('swaps Edit for View (Eye) when the role can open but not edit', async () => {
|
||||
await render(agentWith('viewer', ['use', 'pin', 'view']), 'team');
|
||||
const items = await openMenu();
|
||||
const view = items.find((i) => i.textContent === 'agents.view');
|
||||
expect(view).toBeDefined();
|
||||
expect(view?.querySelector('svg')?.getAttribute('class')).toContain(
|
||||
'lucide-eye',
|
||||
);
|
||||
expect(items.map((i) => i.textContent)).not.toContain('agents.edit');
|
||||
});
|
||||
|
||||
it('shows the role on a shared tile as a neutral Users Badge', async () => {
|
||||
await render(agentWith('editor', EDITOR_ACTIONS), 'team');
|
||||
const badge = container.querySelector<HTMLElement>(
|
||||
'[data-testid="role-badge"]',
|
||||
);
|
||||
expect(badge?.dataset.variant).toBe('neutral');
|
||||
expect(badge?.textContent).toBe('teamAccess.editor');
|
||||
});
|
||||
|
||||
it('shows no role badge on an own agent', async () => {
|
||||
await render(agentWith('owner', OWNER_ACTIONS), 'user');
|
||||
expect(container.querySelector('[data-testid="role-badge"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('gives a viewer Pin only', async () => {
|
||||
await render(agentWith('viewer', VIEWER_ACTIONS), 'team');
|
||||
expect(await menuLabels()).toEqual(['agents.card.pin']);
|
||||
});
|
||||
|
||||
it('adds Logs for a viewer when the owner shares logs', async () => {
|
||||
await render(agentWith('viewer', [...VIEWER_ACTIONS, 'view_logs']), 'team');
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.card.pin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows no menu to a viewer of a draft', async () => {
|
||||
await render(
|
||||
{ ...agentWith('viewer', VIEWER_ACTIONS), status: 'draft' },
|
||||
'team',
|
||||
);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="agents.card.actions"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an own agent without access fields as the owner', async () => {
|
||||
const own = {
|
||||
...agentWith('owner', []),
|
||||
access: undefined,
|
||||
allowed_actions: undefined,
|
||||
} as Agent;
|
||||
await render(own, 'user');
|
||||
expect(await menuLabels()).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('hides Logs on an own draft (no runs to show) but keeps the rest', async () => {
|
||||
await render(
|
||||
{ ...agentWith('owner', OWNER_ACTIONS), status: 'draft' },
|
||||
'user',
|
||||
);
|
||||
const labels = await menuLabels();
|
||||
expect(labels).not.toContain('agents.form.buttons.logs');
|
||||
expect(labels).not.toContain('agents.card.pin');
|
||||
expect(labels).toContain('agents.edit');
|
||||
});
|
||||
|
||||
it('gives Discovered cards Pin and Remove; the card itself opens the agent', async () => {
|
||||
await render(
|
||||
{
|
||||
...agentWith('viewer', VIEWER_ACTIONS),
|
||||
shared_token: 'tok',
|
||||
},
|
||||
'shared',
|
||||
);
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.card.pin',
|
||||
'agents.card.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens the chat when a viewer clicks a published card', async () => {
|
||||
await render(agentWith('viewer', VIEWER_ACTIONS), 'team');
|
||||
await act(async () =>
|
||||
container.querySelector<HTMLElement>('[role="button"]')!.click(),
|
||||
);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'preference/setSelectedAgent' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a refused delete in a toast', async () => {
|
||||
mocks.deleteAgent.mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: 'Only the owner can delete' }),
|
||||
});
|
||||
await render(agentWith('owner', OWNER_ACTIONS), 'user');
|
||||
const items = await openMenu();
|
||||
await act(async () =>
|
||||
items
|
||||
.find((i) => i.textContent === 'agents.form.buttons.delete')!
|
||||
.click(),
|
||||
);
|
||||
await act(async () =>
|
||||
container.querySelector<HTMLElement>('[data-testid="confirm"]')!.click(),
|
||||
);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: 'actionToast/showActionToast',
|
||||
payload: { variant: 'destructive', message: 'Only the owner can delete' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Activity,
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Folder,
|
||||
Pencil,
|
||||
Pin,
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import RoleBadge from '../components/RoleBadge';
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card, CardDescription, CardTitle } from '../components/ui/card';
|
||||
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
|
||||
@@ -25,6 +25,7 @@ import { Modal } from '../components/ui/modal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import MoveToFolderModal from '../modals/MoveToFolderModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { useSidebarLevel } from '../navigation/SidebarLevelProvider';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import {
|
||||
@@ -39,6 +40,8 @@ import {
|
||||
agentLogsPath,
|
||||
sharedAgentPath,
|
||||
} from './paths';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import { canAgent } from './agentAccess';
|
||||
import { Agent } from './types';
|
||||
|
||||
type AgentCardProps = {
|
||||
@@ -84,6 +87,64 @@ export default function AgentCard({
|
||||
onClick: () => togglePin(),
|
||||
};
|
||||
|
||||
const ownedMenu: MenuOption[] = [
|
||||
...(canAgent(agent, 'view_logs')
|
||||
? [
|
||||
{
|
||||
icon: Activity,
|
||||
label: t('agents.form.buttons.logs'),
|
||||
onClick: () => goToLevel(agentLogsPath(agent.id)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Opening the editor is `view`; a role that can't `edit` gets it as View.
|
||||
...(can(agent, 'view')
|
||||
? [
|
||||
can(agent, 'edit')
|
||||
? { icon: Pencil, label: t('agents.edit'), onClick: openEditor }
|
||||
: { icon: Eye, label: t('agents.view'), onClick: openEditor },
|
||||
]
|
||||
: []),
|
||||
...(can(agent, 'export')
|
||||
? [
|
||||
{
|
||||
icon: Download,
|
||||
label: t('agents.exportAgent'),
|
||||
onClick: () => handleExport(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(agent, 'share')
|
||||
? [
|
||||
{
|
||||
icon: Users,
|
||||
label: t('agents.shareWithTeam'),
|
||||
onClick: () => setShareModalOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canAgent(agent, 'pin') ? [pinOption] : []),
|
||||
...(can(agent, 'move_folder')
|
||||
? [
|
||||
{
|
||||
icon: Folder,
|
||||
label: t('agents.folders.moveToFolder'),
|
||||
onClick: () => setMoveModalState('ACTIVE'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(agent, 'delete')
|
||||
? [
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('agents.form.buttons.delete'),
|
||||
onClick: () => setDeleteConfirmation('ACTIVE'),
|
||||
variant: 'destructive' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const menuOptionsConfig: Record<string, MenuOption[]> = {
|
||||
template: [
|
||||
{
|
||||
@@ -92,64 +153,14 @@ export default function AgentCard({
|
||||
onClick: () => handleDuplicate(),
|
||||
},
|
||||
],
|
||||
user: [
|
||||
{
|
||||
icon: Activity,
|
||||
label: t('agents.form.buttons.logs'),
|
||||
onClick: () => goToLevel(agentLogsPath(agent.id)),
|
||||
},
|
||||
{
|
||||
icon: Pencil,
|
||||
label: t('agents.edit'),
|
||||
onClick: openEditor,
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
label: t('agents.exportAgent'),
|
||||
onClick: () => handleExport(),
|
||||
},
|
||||
// Sharing is an owner-only action: only show it for agents the user
|
||||
// owns ('user'), not agents shared into their workspace by a team.
|
||||
...(agent.ownership === 'user'
|
||||
? [
|
||||
{
|
||||
icon: Users,
|
||||
label: t('agents.shareWithTeam'),
|
||||
onClick: () => setShareModalOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(agent.status === 'published' ? [pinOption] : []),
|
||||
{
|
||||
icon: Folder,
|
||||
label: t('agents.folders.moveToFolder'),
|
||||
onClick: () => setMoveModalState('ACTIVE'),
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('agents.form.buttons.delete'),
|
||||
onClick: () => setDeleteConfirmation('ACTIVE'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
],
|
||||
// Agents shared with the user via a team. They don't own it, so only
|
||||
// non-destructive, non-owner actions are offered: open the config
|
||||
// (editors can save, viewers see it read-only) and pin for quick access.
|
||||
// Logs / Export / Share / Move-to-folder / Delete stay owner-only.
|
||||
team: [
|
||||
{
|
||||
icon: Pencil,
|
||||
label: t('agents.edit'),
|
||||
onClick: openEditor,
|
||||
},
|
||||
...(agent.status === 'published' ? [pinOption] : []),
|
||||
],
|
||||
// My agents and the Team section share one menu, built from what the
|
||||
// caller's role allows on this agent (`allowed_actions` from the API).
|
||||
// Editors get Logs, Edit, Export and Pin; viewers only Pin. Share, Move
|
||||
// and Delete stay with the owner unless the owner's switches widen them.
|
||||
user: ownedMenu,
|
||||
team: ownedMenu,
|
||||
// Discovered (link-opened) agents: the card itself opens the agent.
|
||||
shared: [
|
||||
{
|
||||
icon: ExternalLink,
|
||||
label: t('agents.card.open'),
|
||||
onClick: () => navigate(sharedAgentPath(agent.shared_token)),
|
||||
},
|
||||
pinOption,
|
||||
{
|
||||
icon: Trash2,
|
||||
@@ -244,13 +255,31 @@ export default function AgentCard({
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
const response = await userService.deleteAgent(agent.id ?? '', token);
|
||||
if (!response.ok) throw new Error('Failed to delete agent');
|
||||
if (!response.ok) {
|
||||
const message = await response
|
||||
.json()
|
||||
.then((data: { message?: string }) => data?.message)
|
||||
.catch(() => null);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: message || t('agents.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const updatedAgents = agents.filter(
|
||||
(prevAgent) => prevAgent.id !== agent.id,
|
||||
);
|
||||
updateAgents?.(updatedAgents);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('agents.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -300,22 +329,17 @@ export default function AgentCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActionMenu
|
||||
options={menuOptions}
|
||||
triggerLabel={t('agents.card.actions')}
|
||||
align="end"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
/>
|
||||
{menuOptions.length > 0 && (
|
||||
<ActionMenu
|
||||
options={menuOptions}
|
||||
triggerLabel={t('agents.card.actions')}
|
||||
align="end"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
/>
|
||||
)}
|
||||
{/* Team access badge — pinned to the top row, left of the ⋯ menu
|
||||
(right-11 clears the 28px trigger at right-3) so the two align. */}
|
||||
{agent.ownership === 'team' && (
|
||||
<Badge variant="neutral" className="absolute top-4 right-11 z-10">
|
||||
<Users aria-hidden="true" />
|
||||
{agent.team_access === 'editor'
|
||||
? t('agents.teamBadge.editor')
|
||||
: t('agents.teamBadge.viewer')}
|
||||
</Badge>
|
||||
)}
|
||||
<RoleBadge item={agent} className="absolute top-4 right-11 z-10" />
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center gap-1 px-1">
|
||||
<Avatar
|
||||
|
||||
@@ -54,6 +54,31 @@ describe('AgentPageHeader sub-nav', () => {
|
||||
expect(tabs[0].getAttribute('data-active')).not.toBe('true');
|
||||
});
|
||||
|
||||
it('shows only the tabs the role allows', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<AgentPageHeader
|
||||
agentId="a1"
|
||||
agentName="Renewals"
|
||||
currentPage="overview"
|
||||
access={{
|
||||
access: 'editor',
|
||||
allowed_actions: ['view', 'view_logs'],
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
const nav = container.querySelector(
|
||||
'nav[aria-label="agents.pageHeader.subnavAriaLabel"]',
|
||||
);
|
||||
expect(Array.from(nav?.children ?? []).map((t) => t.textContent)).toEqual([
|
||||
'agents.pageHeader.tabs.overview',
|
||||
'agents.pageHeader.tabs.logs',
|
||||
]);
|
||||
});
|
||||
|
||||
it('makes the current crumb a button with the avatar and a chevron that opens the details', () => {
|
||||
const onNameClick = vi.fn();
|
||||
act(() => {
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Avatar } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { type AccessFields } from '../utils/accessUtils';
|
||||
import { canAgent } from './agentAccess';
|
||||
import {
|
||||
AGENTS_MANAGE_ROOT,
|
||||
agentEditPath as agentEditPathProp,
|
||||
@@ -47,6 +49,11 @@ type AgentPageHeaderProps = {
|
||||
onNameClick?: () => void;
|
||||
/** A status Badge placed after the crumbs. */
|
||||
status?: ReactNode;
|
||||
/**
|
||||
* The agent's access fields: each tab shows only when the role allows its
|
||||
* page. Omitted (a new workflow, not yet loaded), every tab shows.
|
||||
*/
|
||||
access?: (AccessFields & { status?: string }) | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,6 +76,7 @@ export default function AgentPageHeader({
|
||||
agentImage,
|
||||
onNameClick,
|
||||
status,
|
||||
access,
|
||||
}: AgentPageHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -81,20 +89,26 @@ export default function AgentPageHeader({
|
||||
id: 'overview' as const,
|
||||
label: t('agents.pageHeader.tabs.overview'),
|
||||
href: editPath,
|
||||
action: 'view',
|
||||
},
|
||||
{
|
||||
id: 'logs' as const,
|
||||
label: t('agents.pageHeader.tabs.logs'),
|
||||
href: agentId ? agentLogsPath(agentId) : '#',
|
||||
action: 'view_logs',
|
||||
},
|
||||
{
|
||||
id: 'schedules' as const,
|
||||
label: t('agents.pageHeader.tabs.schedules'),
|
||||
href: agentId ? agentSchedulesPath(agentId) : '#',
|
||||
action: 'manage_schedules',
|
||||
},
|
||||
],
|
||||
[agentId, editPath, t],
|
||||
);
|
||||
const visibleTabs = tabs.filter(
|
||||
(tab) => !access || canAgent(access, tab.action),
|
||||
);
|
||||
|
||||
const currentTabLabel =
|
||||
tabs.find((tab) => tab.id === currentPage)?.label ?? '';
|
||||
@@ -182,7 +196,7 @@ export default function AgentPageHeader({
|
||||
!inline && 'border-border border-b',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
{visibleTabs.map((tab) => {
|
||||
const isActive = tab.id === currentPage;
|
||||
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
|
||||
if (isActive) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
const state = {
|
||||
preference: {
|
||||
token: null,
|
||||
agents: [] as unknown[],
|
||||
sharedAgents: [],
|
||||
selectedAgent: null,
|
||||
},
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getAgent: vi.fn() }));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: { getAgent: mocks.getAgent },
|
||||
}));
|
||||
|
||||
import AgentRouteGuard from './AgentRouteGuard';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const respond = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('AgentRouteGuard', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
state.preference.agents = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.getAgent.mockReset();
|
||||
});
|
||||
|
||||
const render = async (action: string, path = '/agents/manage/logs/a1') => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/manage/:page/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action={action}>
|
||||
<div data-testid="page" />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route path="/agents/manage" element={<div data-testid="list" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const shows = (id: string) =>
|
||||
container.querySelector(`[data-testid="${id}"]`) !== null;
|
||||
|
||||
it('sends a viewer back to the list without showing the page', async () => {
|
||||
let resolve: (v: unknown) => void = () => undefined;
|
||||
mocks.getAgent.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
await render('view', '/agents/manage/edit/a1');
|
||||
// Nothing of the page while the agent loads, only the loading ring.
|
||||
expect(shows('page')).toBe(false);
|
||||
const loading = container.querySelector('[data-slot="loading-state"]');
|
||||
expect(loading?.getAttribute('data-fill')).toBe('parent');
|
||||
expect(loading?.querySelector('[role="status"]')).not.toBeNull();
|
||||
await act(async () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'a1',
|
||||
access: 'viewer',
|
||||
allowed_actions: ['pin', 'use'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(shows('page')).toBe(false);
|
||||
expect(shows('list')).toBe(true);
|
||||
// The redirect itself is silent.
|
||||
expect(container.querySelector('[data-slot="loading-state"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('lets an editor open the page', async () => {
|
||||
mocks.getAgent.mockReturnValue(
|
||||
respond({
|
||||
id: 'a1',
|
||||
access: 'editor',
|
||||
allowed_actions: ['view', 'view_logs'],
|
||||
}),
|
||||
);
|
||||
await render('view_logs');
|
||||
expect(shows('page')).toBe(true);
|
||||
});
|
||||
|
||||
it('decides from the agent list without a fetch when it has the actions', async () => {
|
||||
state.preference.agents = [
|
||||
{ id: 'a1', access: 'viewer', allowed_actions: ['pin', 'use'] },
|
||||
];
|
||||
await render('manage_schedules', '/agents/manage/schedules/a1');
|
||||
expect(mocks.getAgent).not.toHaveBeenCalled();
|
||||
expect(shows('list')).toBe(true);
|
||||
});
|
||||
|
||||
it('sends the caller back when the agent does not load', async () => {
|
||||
mocks.getAgent.mockReturnValue(respond({}, false));
|
||||
await render('view_logs');
|
||||
expect(shows('list')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an owner in when the record has no access fields', async () => {
|
||||
mocks.getAgent.mockReturnValue(respond({ id: 'a1' }));
|
||||
await render('view');
|
||||
expect(shows('page')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { LoadingState } from '../components/ui/loading-state';
|
||||
import {
|
||||
selectAgents,
|
||||
selectSelectedAgent,
|
||||
selectSharedAgents,
|
||||
selectToken,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import { canAgent } from './agentAccess';
|
||||
import { agentsListPath } from './paths';
|
||||
import type { Agent } from './types';
|
||||
|
||||
type AgentRouteGuardProps = {
|
||||
/** The action the page needs (`view`, `view_logs`, `manage_schedules`). */
|
||||
action: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an agent's page only for a role that may use it.
|
||||
*
|
||||
* Decides from the agent already in the store when that record carries the
|
||||
* server's `allowed_actions`, else fetches the agent. Nothing of the page
|
||||
* renders until it knows (a loading ring stands in), so a viewer never sees
|
||||
* a flash of the edit form.
|
||||
* A caller who may not open the page, or an agent that does not load, goes
|
||||
* back to the agent list.
|
||||
*
|
||||
* @param action The agent action the wrapped page needs.
|
||||
* @param children The page.
|
||||
*/
|
||||
export default function AgentRouteGuard({
|
||||
action,
|
||||
children,
|
||||
}: AgentRouteGuardProps) {
|
||||
const { agentId } = useParams();
|
||||
const token = useSelector(selectToken);
|
||||
const agents = useSelector(selectAgents);
|
||||
const sharedAgents = useSelector(selectSharedAgents);
|
||||
const selectedAgent = useSelector(selectSelectedAgent);
|
||||
|
||||
const stored = [
|
||||
...(agents ?? []),
|
||||
...(sharedAgents ?? []),
|
||||
...(selectedAgent ? [selectedAgent] : []),
|
||||
].find((agent) => agent.id === agentId && agent.allowed_actions);
|
||||
|
||||
const [fetched, setFetched] = useState<{
|
||||
id: string;
|
||||
agent: Agent | null;
|
||||
} | null>(null);
|
||||
|
||||
const needsFetch = Boolean(agentId) && !stored;
|
||||
useEffect(() => {
|
||||
if (!needsFetch || !agentId) return;
|
||||
let cancelled = false;
|
||||
userService
|
||||
.getAgent(agentId, token)
|
||||
.then(async (response: Response) => {
|
||||
const agent = response.ok ? ((await response.json()) as Agent) : null;
|
||||
if (!cancelled) setFetched({ id: agentId, agent });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFetched({ id: agentId, agent: null });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, needsFetch, token]);
|
||||
|
||||
if (!agentId) return <>{children}</>;
|
||||
|
||||
let agent: Agent | null | undefined = stored;
|
||||
if (!agent) {
|
||||
if (fetched?.id !== agentId) return <LoadingState fill="parent" />;
|
||||
agent = fetched.agent;
|
||||
}
|
||||
if (!agent || !canAgent(agent, action))
|
||||
return <Navigate to={agentsListPath()} replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => {
|
||||
dispatch: vi.fn(),
|
||||
getAgent: vi.fn(() => jsonResponse({})),
|
||||
createAgent: vi.fn(() => jsonResponse({ message: 'Name is taken' }, false)),
|
||||
deleteAgent: vi.fn(() => jsonResponse({})),
|
||||
guardrailsProps: vi.fn(),
|
||||
};
|
||||
});
|
||||
const { jsonResponse } = mocks;
|
||||
@@ -53,7 +55,7 @@ vi.mock('../api/services/userService', () => ({
|
||||
getAgent: mocks.getAgent,
|
||||
createAgent: mocks.createAgent,
|
||||
updateAgent: () => jsonResponse({}),
|
||||
deleteAgent: () => jsonResponse({}),
|
||||
deleteAgent: mocks.deleteAgent,
|
||||
createPrompt: () => jsonResponse({}),
|
||||
},
|
||||
}));
|
||||
@@ -97,13 +99,29 @@ vi.mock('./workflow/WorkflowBuilder', () => ({ default: () => null }));
|
||||
vi.mock('./AgentPreview', () => ({ default: () => null }));
|
||||
vi.mock('../settings/Prompts', () => ({ default: () => null }));
|
||||
vi.mock('./components/GuardrailsSection', () => ({
|
||||
default: () => null,
|
||||
default: (props: { disabled?: boolean }) => {
|
||||
mocks.guardrailsProps(props);
|
||||
return null;
|
||||
},
|
||||
guardrailsIncomplete: () => false,
|
||||
}));
|
||||
vi.mock('../upload/Upload', () => ({ default: () => null }));
|
||||
vi.mock('../modals/AgentDetailsModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({
|
||||
default: ({
|
||||
modalState,
|
||||
handleSubmit,
|
||||
}: {
|
||||
modalState: string;
|
||||
handleSubmit: () => void;
|
||||
}) =>
|
||||
modalState === 'ACTIVE' ? (
|
||||
<button type="button" data-testid="confirm-delete" onClick={handleSubmit}>
|
||||
confirm
|
||||
</button>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
|
||||
vi.mock('../navigation/SectionPills', () => ({
|
||||
default: () => <div data-testid="section-pills" />,
|
||||
@@ -534,3 +552,178 @@ describe('NewAgent form', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('NewAgent gating by role', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const OWNER = [
|
||||
'delete',
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'manage_settings',
|
||||
'move_folder',
|
||||
'pin',
|
||||
'publish',
|
||||
'share',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
const EDITOR = [
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'pin',
|
||||
'publish',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.getAgent.mockReset();
|
||||
mocks.getAgent.mockImplementation(() => jsonResponse({}));
|
||||
mocks.deleteAgent.mockReset();
|
||||
mocks.deleteAgent.mockImplementation(() => jsonResponse({}));
|
||||
mocks.guardrailsProps.mockClear();
|
||||
});
|
||||
|
||||
const renderEdit = async (access: 'owner' | 'editor', allowed: string[]) => {
|
||||
mocks.getAgent.mockImplementation(() =>
|
||||
jsonResponse({
|
||||
id: 'agent-1',
|
||||
name: 'Deal Desk',
|
||||
description: 'Researches deals',
|
||||
status: 'published',
|
||||
agent_type: 'classic',
|
||||
prompt_id: 'default',
|
||||
ownership: access === 'owner' ? 'user' : 'team',
|
||||
team_access: access === 'owner' ? null : access,
|
||||
access,
|
||||
allowed_actions: allowed,
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/agents/edit/agent-1']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/edit/:agentId"
|
||||
element={<NewAgent mode="edit" />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const buttonByText = (text: string) =>
|
||||
Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes(text),
|
||||
);
|
||||
|
||||
const menuLabels = async () => {
|
||||
const menu = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="agents.form.buttons.moreActions"]',
|
||||
)!;
|
||||
await act(async () => {
|
||||
menu.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((item) => item.textContent);
|
||||
};
|
||||
|
||||
const lastGuardrailsDisabled = () =>
|
||||
mocks.guardrailsProps.mock.calls.at(-1)?.[0].disabled;
|
||||
|
||||
it('gives the owner Share, Access details and the danger zone', async () => {
|
||||
await renderEdit('owner', OWNER);
|
||||
expect(buttonByText('agents.form.dangerZone.deleteButton')).toBeDefined();
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.accessDetails',
|
||||
'agents.shareWithTeam',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Share and Delete from an editor but keeps Access details', async () => {
|
||||
await renderEdit('editor', EDITOR);
|
||||
expect(buttonByText('agents.form.dangerZone.deleteButton')).toBeUndefined();
|
||||
expect(await menuLabels()).toEqual(['agents.form.buttons.accessDetails']);
|
||||
});
|
||||
|
||||
it('lets an editor change guardrails and quotas', async () => {
|
||||
await renderEdit('editor', EDITOR);
|
||||
expect(lastGuardrailsDisabled()).toBe(false);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.sections.advanced')!.click(),
|
||||
);
|
||||
const switches =
|
||||
container.querySelectorAll<HTMLButtonElement>('[role="switch"]');
|
||||
expect(switches.length).toBeGreaterThan(0);
|
||||
for (const s of Array.from(switches)) expect(s.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('locks guardrails and quotas without edit_policy', async () => {
|
||||
await renderEdit(
|
||||
'editor',
|
||||
EDITOR.filter((a) => a !== 'edit_policy'),
|
||||
);
|
||||
expect(lastGuardrailsDisabled()).toBe(true);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.sections.advanced')!.click(),
|
||||
);
|
||||
const token = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="agents.form.placeholders.enterTokenLimit"]',
|
||||
)!;
|
||||
const tokenSwitch = token
|
||||
.closest('[data-slot="setting-row"]')
|
||||
?.querySelector<HTMLButtonElement>('[role="switch"]');
|
||||
expect(tokenSwitch?.disabled).toBe(true);
|
||||
expect(token.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('hides Access details without manage_access_details', async () => {
|
||||
await renderEdit(
|
||||
'editor',
|
||||
EDITOR.filter((a) => a !== 'manage_access_details'),
|
||||
);
|
||||
expect(await menuLabels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a failed delete in a toast instead of throwing', async () => {
|
||||
mocks.deleteAgent.mockImplementation(() =>
|
||||
jsonResponse({ message: 'Only the owner can delete' }, false),
|
||||
);
|
||||
await renderEdit('owner', OWNER);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.dangerZone.deleteButton')!.click(),
|
||||
);
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-testid="confirm-delete"]')!
|
||||
.click(),
|
||||
);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: 'actionToast/showActionToast',
|
||||
payload: { variant: 'destructive', message: 'Only the owner can delete' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import AgentDetailsModal from '../modals/AgentDetailsModal';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState, Prompt } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import {
|
||||
selectAgentFolders,
|
||||
selectSelectedAgent,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
import PromptsModal from '../preferences/PromptsModal';
|
||||
import Prompts from '../settings/Prompts';
|
||||
import { UserToolType } from '../settings/types';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import Upload from '../upload/Upload';
|
||||
import {
|
||||
selectedSourceIdsFromAgent,
|
||||
@@ -78,7 +80,7 @@ import {
|
||||
} from '../utils/sourceUtils';
|
||||
import {
|
||||
getToolDisplayName,
|
||||
isClassicAgentToolVisible,
|
||||
isAgentPickerToolVisible,
|
||||
} from '../utils/toolUtils';
|
||||
import { agentsListPath } from './paths';
|
||||
import GuardrailsSection, {
|
||||
@@ -89,7 +91,8 @@ import { resetPreview, selectPreviewStatus } from './agentPreviewSlice';
|
||||
import AgentPageToolbar, { LastUsedMeta } from './components/AgentPageToolbar';
|
||||
import AgentPreviewSheet from './components/AgentPreviewSheet';
|
||||
import SectionShell from '../navigation/SectionShell';
|
||||
import { Agent, ToolSummary } from './types';
|
||||
import SponsoredResourcesNotice from './components/SponsoredResourcesNotice';
|
||||
import { Agent, ResourceSponsor, ToolSummary } from './types';
|
||||
import WorkflowBuilder from './workflow/WorkflowBuilder';
|
||||
|
||||
import type { Model } from '../models/types';
|
||||
@@ -280,6 +283,26 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
[agent.source_details, sourceDocs, t],
|
||||
);
|
||||
|
||||
// Name of a tool/source/prompt that runs with an editor's access, from the
|
||||
// same owner-agnostic details the pickers show.
|
||||
const resolveSponsoredName = useCallback(
|
||||
(sponsor: ResourceSponsor): string => {
|
||||
if (sponsor.type === 'source') return resolveSourceLabel(sponsor.id);
|
||||
if (sponsor.type === 'prompt') {
|
||||
return (
|
||||
prompts.find((prompt) => prompt.id === sponsor.id)?.name ||
|
||||
agent.prompt_name ||
|
||||
t('agents.form.sponsors.unknownItem')
|
||||
);
|
||||
}
|
||||
const tool = selectedTools.find((item) => item.id === sponsor.id);
|
||||
return tool
|
||||
? getToolDisplayName(tool)
|
||||
: t('agents.form.sponsors.unknownItem');
|
||||
},
|
||||
[agent.prompt_name, prompts, resolveSourceLabel, selectedTools, t],
|
||||
);
|
||||
|
||||
const sourceItems = useMemo(() => {
|
||||
const items = toSourcePickerItems(
|
||||
sourceDocs,
|
||||
@@ -359,9 +382,27 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
};
|
||||
|
||||
const handleDelete = async (agentId: string) => {
|
||||
const response = await userService.deleteAgent(agentId, token);
|
||||
if (!response.ok) throw new Error('Failed to delete agent');
|
||||
navigateBackToAgents();
|
||||
try {
|
||||
const response = await userService.deleteAgent(agentId, token);
|
||||
if (!response.ok) {
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: await extractApiError(response, t('agents.deleteFailed')),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigateBackToAgents();
|
||||
} catch (error) {
|
||||
console.error('Error deleting agent:', error);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('agents.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
@@ -588,7 +629,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
// Hide workflow-only builtins (e.g. read_document) from the classic
|
||||
// agent picker; they belong to the workflow-node picker only.
|
||||
const visibleTools = (data.tools as UserToolType[]).filter(
|
||||
isClassicAgentToolVisible,
|
||||
isAgentPickerToolVisible,
|
||||
);
|
||||
const devicesById = new Map<
|
||||
string,
|
||||
@@ -829,6 +870,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
}, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText]);
|
||||
|
||||
const isPublished = agent.status === 'published';
|
||||
// What the caller's role allows on this agent (`allowed_actions` from the
|
||||
// API). A new agent carries no access fields, so it reads as the owner's.
|
||||
const canEditPolicy = can(agent, 'edit_policy');
|
||||
// Save on a published agent is an edit; on a draft or a new agent the main
|
||||
// button publishes it.
|
||||
const canSubmit = can(agent, effectiveMode === 'edit' ? 'edit' : 'publish');
|
||||
const agentDisplayName =
|
||||
agent.name?.trim() || t('agents.pageHeader.fallbackName');
|
||||
|
||||
@@ -845,7 +892,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
onClick: () => setPreviewOpen(true),
|
||||
},
|
||||
]),
|
||||
...(modeConfig[effectiveMode].showAccessDetails
|
||||
...(modeConfig[effectiveMode].showAccessDetails &&
|
||||
can(agent, 'manage_access_details')
|
||||
? [
|
||||
{
|
||||
label: t('agents.form.buttons.accessDetails'),
|
||||
@@ -853,10 +901,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Sharing is owner-only — hidden for agents shared into the workspace by
|
||||
// a team (ownership === 'team').
|
||||
// Sharing is the owner's, unless the owner lets editors share.
|
||||
...(modeConfig[effectiveMode].showAccessDetails &&
|
||||
agent.ownership !== 'team' &&
|
||||
can(agent, 'share') &&
|
||||
agent.id
|
||||
? [
|
||||
{
|
||||
@@ -882,7 +929,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.buttons.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{modeConfig[effectiveMode].showSaveDraft && (
|
||||
{modeConfig[effectiveMode].showSaveDraft && can(agent, 'edit') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -907,17 +954,19 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.sections.preview')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
disabled={!isPublishable() || !hasChanges}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
{modeConfig[effectiveMode].buttonText}
|
||||
</Button>
|
||||
{canSubmit && (
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
disabled={!isPublishable() || !hasChanges}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
{modeConfig[effectiveMode].buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1157,6 +1206,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.buttons.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<SponsoredResourcesNotice
|
||||
agent={agent}
|
||||
resolveName={resolveSponsoredName}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="subtle" padding="lg" className="gap-5">
|
||||
@@ -1369,7 +1422,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
disabled={!agent.limited_token_mode}
|
||||
disabled={!agent.limited_token_mode || !canEditPolicy}
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.enterTokenLimit',
|
||||
)}
|
||||
@@ -1381,6 +1434,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<Switch
|
||||
id={tokenLimitSwitchId}
|
||||
checked={agent.limited_token_mode}
|
||||
disabled={!canEditPolicy}
|
||||
onCheckedChange={(checked) => {
|
||||
setAgent({
|
||||
...agent,
|
||||
@@ -1411,7 +1465,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
disabled={!agent.limited_request_mode}
|
||||
disabled={!agent.limited_request_mode || !canEditPolicy}
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.enterRequestLimit',
|
||||
)}
|
||||
@@ -1423,6 +1477,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<Switch
|
||||
id={requestLimitSwitchId}
|
||||
checked={agent.limited_request_mode}
|
||||
disabled={!canEditPolicy}
|
||||
onCheckedChange={(checked) => {
|
||||
setAgent({
|
||||
...agent,
|
||||
@@ -1459,16 +1514,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<GuardrailsSection
|
||||
value={agent.config?.guardrails}
|
||||
token={token}
|
||||
// Guardrails are the owner's policy: the update route drops
|
||||
// ``config`` for team members, editors included. Leaving the
|
||||
// controls live for editors let them save a change the server
|
||||
// silently discarded, and the success toast said it had worked.
|
||||
disabled={Boolean(agent.team_access)}
|
||||
disabledNotice={
|
||||
agent.team_access
|
||||
? t('agents.form.guardrails.ownerOnly')
|
||||
: undefined
|
||||
}
|
||||
// Guardrails are policy (`edit_policy`): editors and the owner
|
||||
// change them; anyone else sees them read-only.
|
||||
disabled={!canEditPolicy}
|
||||
onChange={(guardrails) =>
|
||||
setAgent({
|
||||
...agent,
|
||||
@@ -1476,29 +1524,31 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
})
|
||||
}
|
||||
/>
|
||||
{modeConfig[effectiveMode].showDelete && agent.id && (
|
||||
<Card
|
||||
tone="destructive"
|
||||
padding="lg"
|
||||
className="flex-row flex-wrap items-start justify-between"
|
||||
>
|
||||
<SectionHeader
|
||||
{modeConfig[effectiveMode].showDelete &&
|
||||
agent.id &&
|
||||
can(agent, 'delete') && (
|
||||
<Card
|
||||
tone="destructive"
|
||||
title={t('agents.form.dangerZone.heading')}
|
||||
description={t('agents.form.dangerZone.description')}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmation('ACTIVE')}
|
||||
className="shrink-0"
|
||||
padding="lg"
|
||||
className="flex-row flex-wrap items-start justify-between"
|
||||
>
|
||||
{t('agents.form.dangerZone.deleteButton')}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
<SectionHeader
|
||||
tone="destructive"
|
||||
title={t('agents.form.dangerZone.heading')}
|
||||
description={t('agents.form.dangerZone.description')}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmation('ACTIVE')}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t('agents.form.dangerZone.deleteButton')}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
message={t('agents.deleteConfirmation')}
|
||||
@@ -1595,16 +1645,18 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
title={t('agents.form.preview.publishTitle')}
|
||||
description={t('agents.form.preview.publishDescription')}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={!isPublishable()}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{t('agents.form.buttons.publish')}
|
||||
</Button>
|
||||
can(agent, 'publish') ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={!isPublishable()}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{t('agents.form.buttons.publish')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { canAgent, canOpenAgentEditor } from './agentAccess';
|
||||
import type { Agent } from './types';
|
||||
|
||||
const agent = (fields: Partial<Agent>) => ({ id: 'a1', ...fields }) as Agent;
|
||||
|
||||
describe('canOpenAgentEditor', () => {
|
||||
it('follows the list copy of the agent when there is one', () => {
|
||||
const listed = agent({ access: 'editor', allowed_actions: ['view'] });
|
||||
expect(canOpenAgentEditor(agent({}), [listed])).toBe(true);
|
||||
const viewer = agent({ access: 'viewer', allowed_actions: ['use'] });
|
||||
expect(canOpenAgentEditor(agent({}), [viewer])).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the selected agent when it carries the actions', () => {
|
||||
expect(
|
||||
canOpenAgentEditor(
|
||||
agent({ access: 'viewer', allowed_actions: ['pin', 'use'] }),
|
||||
[],
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canOpenAgentEditor(agent({ access: 'owner', allowed_actions: ['view'] })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses an unlisted agent with no access fields', () => {
|
||||
expect(canOpenAgentEditor(agent({}), [])).toBe(false);
|
||||
expect(canOpenAgentEditor(null, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAgent', () => {
|
||||
const all = ['view', 'view_logs', 'manage_schedules', 'pin'];
|
||||
it('follows allowed_actions on a published agent', () => {
|
||||
const a = agent({
|
||||
status: 'published',
|
||||
access: 'editor',
|
||||
allowed_actions: all,
|
||||
});
|
||||
expect(all.every((x) => canAgent(a, x))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops Logs, Schedules and Pin on a draft, keeps the editor', () => {
|
||||
const a = agent({ status: 'draft', access: 'owner', allowed_actions: all });
|
||||
expect(canAgent(a, 'view')).toBe(true);
|
||||
expect(canAgent(a, 'view_logs')).toBe(false);
|
||||
expect(canAgent(a, 'manage_schedules')).toBe(false);
|
||||
expect(canAgent(a, 'pin')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an agent with no status yet as published', () => {
|
||||
expect(canAgent(agent({ allowed_actions: all }), 'view_logs')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { can, type AccessFields } from '../utils/accessUtils';
|
||||
import type { Agent } from './types';
|
||||
|
||||
/**
|
||||
* Whether the chat header and the phone top bar offer Edit for an agent.
|
||||
*
|
||||
* Prefers the agent list's copy, which carries the server's access fields.
|
||||
* An agent that is not in the list (a template, a link-shared agent) must
|
||||
* carry `allowed_actions` itself; without them it gets no Edit, so a record
|
||||
* with no access fields is never taken for the caller's own.
|
||||
*
|
||||
* @param agent The agent the chat is with.
|
||||
* @param agents The caller's agent list (own and team-shared).
|
||||
* @returns True when the role may open the agent's edit page.
|
||||
*/
|
||||
export function canOpenAgentEditor(
|
||||
agent: Agent | null | undefined,
|
||||
agents?: Agent[] | null,
|
||||
): boolean {
|
||||
if (!agent?.id) return false;
|
||||
const listed = agents?.find((a) => a.id === agent.id);
|
||||
if (listed) return can(listed, 'view');
|
||||
return Boolean(agent.allowed_actions) && can(agent, 'view');
|
||||
}
|
||||
|
||||
/** Actions that only make sense once an agent is published. */
|
||||
const PUBLISHED_ONLY = new Set(['view_logs', 'manage_schedules', 'pin']);
|
||||
|
||||
/**
|
||||
* `can()` for an agent, minus what a draft can't have: a draft has no runs
|
||||
* to log, no schedule that fires and nothing to pin, so Logs, Schedules and
|
||||
* Pin wait until it's published. An agent without a status (a record still
|
||||
* loading) is treated as published.
|
||||
*
|
||||
* @param agent The agent, with its access fields and status.
|
||||
* @param action The agent action to check.
|
||||
* @returns True when the role allows it and the agent's state makes sense.
|
||||
*/
|
||||
export function canAgent(
|
||||
agent: (AccessFields & { status?: string }) | null | undefined,
|
||||
action: string,
|
||||
): boolean {
|
||||
if (!agent) return false;
|
||||
if (
|
||||
PUBLISHED_ONLY.has(action) &&
|
||||
agent.status &&
|
||||
agent.status !== 'published'
|
||||
)
|
||||
return false;
|
||||
return can(agent, action);
|
||||
}
|
||||
@@ -135,7 +135,6 @@ describe('GuardrailsSection', () => {
|
||||
value={config}
|
||||
onChange={() => undefined}
|
||||
token="tok"
|
||||
disabledNotice="Read only"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
@@ -232,9 +231,11 @@ describe('GuardrailsSection', () => {
|
||||
|
||||
it('shows the section notices as polite or warning alerts', async () => {
|
||||
await render({ disabled: true });
|
||||
// View-only: the shared quiet note, not an announced status.
|
||||
const readOnly = q('guardrails-read-only');
|
||||
expect(readOnly?.getAttribute('role')).toBe('status');
|
||||
expect(readOnly?.textContent).toContain('Read only');
|
||||
expect(readOnly?.getAttribute('role')).toBe('note');
|
||||
expect(readOnly?.dataset.variant).toBe('default');
|
||||
expect(readOnly?.textContent).toBe('common.viewOnlyNotice');
|
||||
const instance = q('guardrails-instance-disabled');
|
||||
expect(instance?.getAttribute('role')).toBe('alert');
|
||||
expect(instance?.className).toContain('text-warning');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { ChevronRight, Info, Lock, TriangleAlert } from 'lucide-react';
|
||||
import { ChevronRight, Info, TriangleAlert } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ViewOnlyNotice from '@/components/ViewOnlyNotice';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -101,9 +102,11 @@ type Props = {
|
||||
value?: GuardrailsConfig;
|
||||
onChange: (next: GuardrailsConfig) => void;
|
||||
token: string | null;
|
||||
/**
|
||||
* The caller's role can't change the policy: the controls still show its
|
||||
* state, disabled, under the shared view-only notice.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/** Why the controls are read-only, shown in place of a silent lockout. */
|
||||
disabledNotice?: string;
|
||||
};
|
||||
|
||||
export default function GuardrailsSection({
|
||||
@@ -111,7 +114,6 @@ export default function GuardrailsSection({
|
||||
onChange,
|
||||
token,
|
||||
disabled = false,
|
||||
disabledNotice,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
@@ -287,12 +289,7 @@ export default function GuardrailsSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{disabled && disabledNotice && (
|
||||
<Alert role="status" data-testid="guardrails-read-only">
|
||||
<Lock aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>{disabledNotice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{disabled && <ViewOnlyNotice data-testid="guardrails-read-only" />}
|
||||
|
||||
{instanceDisabled && (
|
||||
<Alert variant="warning" data-testid="guardrails-instance-disabled">
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import type { Agent, ResourceSponsor } from '../types';
|
||||
import SponsoredResourcesNotice from './SponsoredResourcesNotice';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const baseAgent: Agent = {
|
||||
name: 'A',
|
||||
description: 'd',
|
||||
image: '',
|
||||
source: '',
|
||||
chunks: '6',
|
||||
retriever: '',
|
||||
prompt_id: 'default',
|
||||
tools: [],
|
||||
agent_type: 'classic',
|
||||
status: 'published',
|
||||
};
|
||||
|
||||
const sponsor = (over: Partial<ResourceSponsor>): ResourceSponsor => ({
|
||||
type: 'tool',
|
||||
id: 't1',
|
||||
user_id: 'bob',
|
||||
label: 'bob@example.com',
|
||||
active: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('SponsoredResourcesNotice', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (agent: Agent) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SponsoredResourcesNotice
|
||||
agent={agent}
|
||||
resolveName={(s) => `name-${s.id}`}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const alerts = () =>
|
||||
Array.from(container.querySelectorAll('[data-slot="alert"]'));
|
||||
|
||||
it('renders nothing for the owner with no sponsored items', async () => {
|
||||
await render(baseAgent);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('tells an editor their own items run with their access', async () => {
|
||||
await render({ ...baseAgent, access: 'editor', allowed_actions: ['edit'] });
|
||||
expect(alerts()).toHaveLength(1);
|
||||
expect(container.textContent).toContain('agents.form.sponsors.attachNote');
|
||||
expect(container.textContent).not.toContain('publicLinkNote');
|
||||
});
|
||||
|
||||
it('adds the public-link warning when the agent has a link', async () => {
|
||||
await render({
|
||||
...baseAgent,
|
||||
shared: true,
|
||||
access: 'editor',
|
||||
allowed_actions: ['edit'],
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
'agents.form.sponsors.publicLinkNote',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show the attach note to a viewer', async () => {
|
||||
await render({ ...baseAgent, access: 'viewer', allowed_actions: ['use'] });
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('joins names for the app language and does not escape them', async () => {
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: 'jp',
|
||||
resources: {
|
||||
jp: {
|
||||
translation: {
|
||||
agents: {
|
||||
form: { sponsors: { addedBy: '{{person}}: {{names}}' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await render({
|
||||
...baseAgent,
|
||||
resource_sponsors: [
|
||||
sponsor({ id: 'docs/a' }),
|
||||
sponsor({ id: 'docs/b' }),
|
||||
],
|
||||
});
|
||||
const expected = new Intl.ListFormat('ja', {
|
||||
type: 'conjunction',
|
||||
}).format(['name-docs/a', 'name-docs/b']);
|
||||
expect(container.textContent).toBe(`bob@example.com: ${expected}`);
|
||||
} finally {
|
||||
await i18n.changeLanguage('en');
|
||||
}
|
||||
});
|
||||
|
||||
it('splits running and no-longer-running items', async () => {
|
||||
await render({
|
||||
...baseAgent,
|
||||
resource_sponsors: [
|
||||
sponsor({ id: 't1' }),
|
||||
sponsor({ id: 's1', type: 'source', active: false }),
|
||||
],
|
||||
});
|
||||
const [running, stopped] = alerts();
|
||||
expect(running.getAttribute('role')).toBe('note');
|
||||
expect(running.textContent).toContain('agents.form.sponsors.addedBy');
|
||||
expect(stopped.getAttribute('data-variant')).toBe('warning');
|
||||
expect(stopped.textContent).toContain('agents.form.sponsors.unavailable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Info, TriangleAlert, UserRound } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
import { can, isOwner } from '../../utils/accessUtils';
|
||||
import { intlLocale } from '../../utils/dateTimeUtils';
|
||||
import type { Agent, ResourceSponsor } from '../types';
|
||||
|
||||
type SponsoredResourcesNoticeProps = {
|
||||
agent: Agent;
|
||||
/** Display name of a sponsored tool, source or prompt. */
|
||||
resolveName: (sponsor: ResourceSponsor) => string;
|
||||
};
|
||||
|
||||
/** Sponsored items grouped by the person who added them, in first-seen order. */
|
||||
function groupByPerson(sponsors: ResourceSponsor[]) {
|
||||
const groups = new Map<string, ResourceSponsor[]>();
|
||||
for (const sponsor of sponsors) {
|
||||
const key = sponsor.label || sponsor.user_id;
|
||||
groups.set(key, [...(groups.get(key) ?? []), sponsor]);
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools, sources and prompts on the agent that run with an editor's access
|
||||
* rather than the owner's (`resource_sponsors` from GET /api/get_agent).
|
||||
*
|
||||
* An editor sees up front that what they attach runs with their access for
|
||||
* everyone who uses the agent. Everyone who may view the config sees who
|
||||
* added what, and which items no longer run because that person lost access.
|
||||
*/
|
||||
export default function SponsoredResourcesNotice({
|
||||
agent,
|
||||
resolveName,
|
||||
}: SponsoredResourcesNoticeProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const sponsors = agent.resource_sponsors ?? [];
|
||||
const showAttachNote = !isOwner(agent) && can(agent, 'edit');
|
||||
const active = groupByPerson(sponsors.filter((s) => s.active));
|
||||
const inactive = groupByPerson(sponsors.filter((s) => !s.active));
|
||||
|
||||
if (!showAttachNote && sponsors.length === 0) return null;
|
||||
|
||||
const listFormat = new Intl.ListFormat(intlLocale(i18n.language), {
|
||||
type: 'conjunction',
|
||||
});
|
||||
const names = (items: ResourceSponsor[]) =>
|
||||
listFormat.format(items.map((item) => resolveName(item)));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:col-span-2">
|
||||
{showAttachNote && (
|
||||
<Alert role="note">
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
{t('agents.form.sponsors.attachNote')}
|
||||
{agent.shared ? ` ${t('agents.form.sponsors.publicLinkNote')}` : ''}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{active.length > 0 && (
|
||||
<Alert role="note">
|
||||
<UserRound />
|
||||
<AlertDescription>
|
||||
{active.map(([person, items]) => (
|
||||
<p key={person}>
|
||||
{t('agents.form.sponsors.addedBy', {
|
||||
interpolation: { escapeValue: false },
|
||||
person,
|
||||
names: names(items),
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{inactive.length > 0 && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertDescription>
|
||||
{inactive.map(([person, items]) => (
|
||||
<p key={person}>
|
||||
{t('agents.form.sponsors.unavailable', {
|
||||
interpolation: { escapeValue: false },
|
||||
person,
|
||||
names: names(items),
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
import AgentLogs from './AgentLogs';
|
||||
import AgentRouteGuard from './AgentRouteGuard';
|
||||
import AgentsList from './AgentsList';
|
||||
import NewAgent from './NewAgent';
|
||||
import { AGENTS_MANAGE_ROOT } from './paths';
|
||||
@@ -31,13 +32,40 @@ export default function Agents() {
|
||||
<Route path="manage/team" element={<AgentsList />} />
|
||||
<Route path="manage/discovered" element={<AgentsList />} />
|
||||
<Route path="manage/new" element={<NewAgent mode="new" />} />
|
||||
<Route path="manage/edit/:agentId" element={<NewAgent mode="edit" />} />
|
||||
<Route path="manage/logs/:agentId" element={<AgentLogs />} />
|
||||
<Route path="manage/schedules/:agentId" element={<SchedulesView />} />
|
||||
{/* An agent's pages open only for a role that may use them; the
|
||||
guard sends anyone else back to the list. */}
|
||||
<Route
|
||||
path="manage/edit/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action="view">
|
||||
<NewAgent mode="edit" />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage/logs/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action="view_logs">
|
||||
<AgentLogs />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage/schedules/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action="manage_schedules">
|
||||
<SchedulesView />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route path="manage/workflow/new" element={<WorkflowBuilder />} />
|
||||
<Route
|
||||
path="manage/workflow/edit/:agentId"
|
||||
element={<WorkflowBuilder />}
|
||||
element={
|
||||
<AgentRouteGuard action="view">
|
||||
<WorkflowBuilder />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Using an agent someone shared. */}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import type { AccessFields } from '../../utils/accessUtils';
|
||||
|
||||
export type ToolSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
};
|
||||
|
||||
/** A tool, source or prompt that runs with the editor's access who added it. */
|
||||
export type ResourceSponsor = {
|
||||
type: 'tool' | 'source' | 'prompt';
|
||||
id: string;
|
||||
user_id: string;
|
||||
/** The person's email when on file, else their user id. */
|
||||
label: string;
|
||||
/** False once that person can no longer edit the agent or use the item. */
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type Agent = {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -30,12 +43,18 @@ export type Agent = {
|
||||
// sharing with a team) are gated on 'user'.
|
||||
ownership?: 'user' | 'team';
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
/** The caller's role and the actions it allows (see `utils/accessUtils`). */
|
||||
access?: AccessFields['access'];
|
||||
allowed_actions?: AccessFields['allowed_actions'];
|
||||
// Owner-agnostic display names resolved server-side (GET /api/get_agent) so a
|
||||
// team member viewing a shared agent sees the owner's prompt/source names
|
||||
// instead of a blank prompt / "External KB" (the client can only resolve
|
||||
// names for resources the caller themselves owns).
|
||||
prompt_name?: string | null;
|
||||
source_details?: { id: string; name: string | null }[];
|
||||
// Resources the owner can't use that run as the editor who attached them
|
||||
// (GET /api/get_agent, callers who may view the config).
|
||||
resource_sponsors?: ResourceSponsor[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
last_used_at?: string;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import { CircleAlert, Link, Pencil, Play, Trash2, X } from 'lucide-react';
|
||||
import {
|
||||
CircleAlert,
|
||||
Link,
|
||||
Pencil,
|
||||
Play,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -31,6 +39,8 @@ import userService from '../../api/services/userService';
|
||||
import AgentDetailsModal from '../../modals/AgentDetailsModal';
|
||||
import ConfirmationModal from '../../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../../models/misc';
|
||||
import ShareToTeamModal from '../../teams/ShareToTeamModal';
|
||||
import { can } from '../../utils/accessUtils';
|
||||
import {
|
||||
selectSourceDocs,
|
||||
selectToken,
|
||||
@@ -85,6 +95,7 @@ import {
|
||||
validateJsonSchemaConfig,
|
||||
} from './workflowHelpers';
|
||||
import { selectWorkflowPreviewStatus } from './workflowPreviewSlice';
|
||||
import { canAddToolToOwn } from '../../utils/toolUtils';
|
||||
|
||||
import type { Model } from '../../models/types';
|
||||
|
||||
@@ -219,6 +230,7 @@ function WorkflowBuilderInner() {
|
||||
const [deleteConfirmation, setDeleteConfirmation] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [agentDetails, setAgentDetails] = useState<ActiveState>('INACTIVE');
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
|
||||
const [currentAgent, setCurrentAgent] = useState<Agent>(
|
||||
createEmptyWorkflowAgent(),
|
||||
@@ -789,7 +801,10 @@ function WorkflowBuilderInner() {
|
||||
const toolsResponse = await userService.getUserTools(token);
|
||||
if (toolsResponse.ok) {
|
||||
const toolsData = await toolsResponse.json();
|
||||
setAvailableTools(toolsData.tools);
|
||||
// Shared tools the caller can't add to their own agents stay out.
|
||||
setAvailableTools(
|
||||
(toolsData.tools as UserTool[]).filter(canAddToolToOwn),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models or tools:', error);
|
||||
@@ -1517,6 +1532,10 @@ function WorkflowBuilderInner() {
|
||||
});
|
||||
}, [detailsSaveRequested, persistWorkflow]);
|
||||
|
||||
// Save on a saved workflow is an edit; the first save publishes it. A new
|
||||
// workflow has no access fields, so it reads as the owner's.
|
||||
// Without it the Save/Publish button isn't rendered at all.
|
||||
const canSubmit = can(currentAgent, canManageAgent ? 'edit' : 'publish');
|
||||
const isPrimaryActionDisabled =
|
||||
isPublishing || (canManageAgent && !hasSavableChanges);
|
||||
const primaryActionLabel = canManageAgent
|
||||
@@ -1642,6 +1661,7 @@ function WorkflowBuilderInner() {
|
||||
agentEditPath={agentEditPath(effectiveAgentId, true)}
|
||||
agentImage={currentAgentImage}
|
||||
currentPage="overview"
|
||||
access={canManageAgent ? currentAgent : undefined}
|
||||
onNameClick={openDetails}
|
||||
status={
|
||||
canManageAgent && currentAgent.status !== 'draft' ? (
|
||||
@@ -1678,16 +1698,18 @@ function WorkflowBuilderInner() {
|
||||
<Play />
|
||||
{t('agents.form.sections.preview')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isPrimaryActionDisabled}
|
||||
loading={showPrimaryActionSpinner}
|
||||
size="field"
|
||||
shape="pill"
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
{canSubmit && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isPrimaryActionDisabled}
|
||||
loading={showPrimaryActionSpinner}
|
||||
size="field"
|
||||
shape="pill"
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
<ActionMenu
|
||||
size="toolbar"
|
||||
triggerLabel={t('agents.form.buttons.moreActions')}
|
||||
@@ -1697,13 +1719,26 @@ function WorkflowBuilderInner() {
|
||||
icon: Pencil,
|
||||
onClick: openDetails,
|
||||
},
|
||||
...(canManageAgent
|
||||
...(canManageAgent && can(currentAgent, 'manage_access_details')
|
||||
? [
|
||||
{
|
||||
label: t('agents.form.buttons.accessDetails'),
|
||||
icon: Link,
|
||||
onClick: () => setAgentDetails('ACTIVE'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canManageAgent && can(currentAgent, 'share')
|
||||
? [
|
||||
{
|
||||
label: t('agents.shareWithTeam'),
|
||||
icon: Users,
|
||||
onClick: () => setShareModalOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canManageAgent && can(currentAgent, 'delete')
|
||||
? [
|
||||
{
|
||||
label: t('agents.form.buttons.delete'),
|
||||
icon: Trash2,
|
||||
@@ -1927,6 +1962,14 @@ function WorkflowBuilderInner() {
|
||||
cancelLabel={t('agents.form.buttons.cancel')}
|
||||
variant="destructive"
|
||||
/>
|
||||
{shareModalOpen && effectiveAgentId && (
|
||||
<ShareToTeamModal
|
||||
resourceType="agent"
|
||||
resourceId={effectiveAgentId}
|
||||
resourceName={workflowName}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{canManageAgent && (
|
||||
<AgentDetailsModal
|
||||
agent={agentForDetails}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type TFunction } from 'i18next';
|
||||
|
||||
import { ConditionCase } from '../types/workflow';
|
||||
import { FilePassing } from './documentConfig';
|
||||
import type { AccessFields } from '../../utils/accessUtils';
|
||||
|
||||
// Names and handles are the user's own text: React escapes on render, so
|
||||
// i18next must not escape them first.
|
||||
@@ -32,6 +33,10 @@ export interface UserTool {
|
||||
// Workflow-only builtins (e.g. read_document) are kept here; the classic
|
||||
// agent picker filters them out.
|
||||
workflow_only?: boolean;
|
||||
/** Team sharing: shared tools the caller can't use in their own agents are hidden. */
|
||||
access?: AccessFields['access'];
|
||||
allowed_actions?: string[];
|
||||
ownership?: AccessFields['ownership'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,7 @@ const endpoints = {
|
||||
TEAM_TRANSFER_OWNER: (id: string) => `/api/teams/${id}/transfer_owner`,
|
||||
RESOURCE_SHARES: (resourceType: string, resourceId: string) =>
|
||||
`/api/resource_shares?resource_type=${resourceType}&resource_id=${resourceId}`,
|
||||
RESOURCE_SETTINGS: '/api/resource_settings',
|
||||
ALL_TEAMS: '/api/admin/teams',
|
||||
PROMPTS: '/api/get_prompts',
|
||||
CREATE_PROMPT: '/api/create_prompt',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const get = vi.fn();
|
||||
const post = vi.fn();
|
||||
const put = vi.fn();
|
||||
const del = vi.fn();
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => get(...args),
|
||||
post: (...args: unknown[]) => post(...args),
|
||||
put: (...args: unknown[]) => put(...args),
|
||||
delete: (...args: unknown[]) => del(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import teamsService, { TeamsApiError } from './teamsService';
|
||||
|
||||
const response = (status: number, body: unknown) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
}) as unknown as Response;
|
||||
|
||||
describe('teamsService', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
put.mockReset();
|
||||
del.mockReset();
|
||||
});
|
||||
|
||||
it('returns the parsed body on 2xx', async () => {
|
||||
get.mockResolvedValue(response(200, { success: true, teams: [] }));
|
||||
await expect(teamsService.list('t')).resolves.toEqual({
|
||||
success: true,
|
||||
teams: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws with the server message on 403', async () => {
|
||||
del.mockResolvedValue(
|
||||
response(403, { success: false, message: 'Only the owner can delete' }),
|
||||
);
|
||||
const error = await teamsService.remove('team-1', 't').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(TeamsApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.message).toBe('Only the owner can delete');
|
||||
});
|
||||
|
||||
it('throws on a non-2xx with no JSON body', async () => {
|
||||
get.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.reject(new Error('not json')),
|
||||
});
|
||||
const error = await teamsService.list('t').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(TeamsApiError);
|
||||
expect(error.status).toBe(500);
|
||||
});
|
||||
|
||||
it('reads and writes resource settings', async () => {
|
||||
get.mockResolvedValue(response(200, { success: true, settings: [] }));
|
||||
await teamsService.getResourceSettings('agent', 'a 1', 't');
|
||||
expect(get.mock.calls[0][0]).toBe(
|
||||
'/api/resource_settings?resource_type=agent&resource_id=a%201',
|
||||
);
|
||||
put.mockResolvedValue(response(200, { success: true, settings: [] }));
|
||||
await teamsService.updateResourceSettings(
|
||||
'agent',
|
||||
'a1',
|
||||
{ editors_can_share: true },
|
||||
't',
|
||||
);
|
||||
expect(put.mock.calls[0][0]).toBe('/api/resource_settings');
|
||||
expect(put.mock.calls[0][1]).toEqual({
|
||||
resource_type: 'agent',
|
||||
resource_id: 'a1',
|
||||
settings: { editors_can_share: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,13 +25,73 @@ export type ResourceShare = {
|
||||
team_slug?: string;
|
||||
access_level: AccessLevel;
|
||||
target_user_id?: string | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
// A grant row from GET /api/teams/<id>/grants. The server resolves names and
|
||||
// labels, plus the caller's own access to the resource (`caller`).
|
||||
export type TeamGrant = {
|
||||
resource_type: ResourceType;
|
||||
resource_id: string;
|
||||
access_level: AccessLevel;
|
||||
target_user_id?: string | null;
|
||||
resource_name?: string | null;
|
||||
owner_id?: string | null;
|
||||
owner_label?: string | null;
|
||||
target_user_label?: string | null;
|
||||
created_at?: string | null;
|
||||
granted_by?: string | null;
|
||||
granted_by_label?: string | null;
|
||||
caller?: {
|
||||
access: 'owner' | 'editor' | 'viewer';
|
||||
allowed_actions: string[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
// One owner switch on a resource (`resource_share_settings`).
|
||||
export type ResourceSetting = { key: string; value: boolean; default: boolean };
|
||||
|
||||
export type ResourceSettingsResponse = {
|
||||
success: boolean;
|
||||
resource_type: ResourceType;
|
||||
resource_id: string;
|
||||
settings: ResourceSetting[];
|
||||
access?: 'owner' | 'editor' | 'viewer' | null;
|
||||
allowed_actions?: string[];
|
||||
};
|
||||
|
||||
/** A non-2xx teams API response; `message` is the server's own message. */
|
||||
export class TeamsApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'TeamsApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// apiClient resolves to the raw fetch Response (the app convention); services
|
||||
// consumed by slices/components parse the JSON here so callers get plain data.
|
||||
// A non-2xx status rejects with the server's message, so callers never mistake
|
||||
// a 403/404 for success.
|
||||
const json = async (response: Response | unknown) => {
|
||||
const r = response as Response;
|
||||
if (!r || !('json' in r) || typeof r.json !== 'function') return r as unknown;
|
||||
if (typeof r.ok === 'boolean' && !r.ok) {
|
||||
let message = `Request failed (${r.status})`;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (body && typeof body.message === 'string' && body.message) {
|
||||
message = body.message;
|
||||
} else if (body && typeof body.error === 'string' && body.error) {
|
||||
message = body.error;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: keep the generic message.
|
||||
}
|
||||
throw new TeamsApiError(r.status, message);
|
||||
}
|
||||
return r.json();
|
||||
};
|
||||
|
||||
@@ -154,6 +214,37 @@ const teamsService = {
|
||||
),
|
||||
),
|
||||
|
||||
getResourceSettings: async (
|
||||
resourceType: ResourceType,
|
||||
resourceId: string,
|
||||
token: string | null,
|
||||
): Promise<ResourceSettingsResponse> =>
|
||||
json(
|
||||
await apiClient.get(
|
||||
`${endpoints.USER.RESOURCE_SETTINGS}?resource_type=${resourceType}&resource_id=${encodeURIComponent(
|
||||
resourceId,
|
||||
)}`,
|
||||
token,
|
||||
),
|
||||
) as Promise<ResourceSettingsResponse>,
|
||||
updateResourceSettings: async (
|
||||
resourceType: ResourceType,
|
||||
resourceId: string,
|
||||
settings: Record<string, boolean>,
|
||||
token: string | null,
|
||||
): Promise<ResourceSettingsResponse> =>
|
||||
json(
|
||||
await apiClient.put(
|
||||
endpoints.USER.RESOURCE_SETTINGS,
|
||||
{
|
||||
resource_type: resourceType,
|
||||
resource_id: resourceId,
|
||||
settings,
|
||||
},
|
||||
token,
|
||||
),
|
||||
) as Promise<ResourceSettingsResponse>,
|
||||
|
||||
listAll: async (token: string | null): Promise<any> =>
|
||||
json(await apiClient.get(endpoints.USER.ALL_TEAMS, token)),
|
||||
};
|
||||
|
||||
@@ -495,7 +495,8 @@ describe('Chunks', () => {
|
||||
ok: true,
|
||||
json: async () => ({ chunk_id: 'c1-new' }),
|
||||
}));
|
||||
await render({ embedded: true });
|
||||
const onOpenChunkChange = vi.fn();
|
||||
await render({ embedded: true, onOpenChunkChange });
|
||||
await act(async () => tile()!.click());
|
||||
await act(async () => buttonByText('modals.chunk.edit')!.click());
|
||||
// After the save, the open position holds nothing (or another chunk).
|
||||
@@ -509,6 +510,8 @@ describe('Chunks', () => {
|
||||
expect(container.querySelector('h2')?.textContent).toBe('Rewritten');
|
||||
expect(container.textContent).toContain('chunkPositionUnplaced');
|
||||
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
|
||||
// An embedding host draws the crumb: still open, but with no number.
|
||||
expect(onOpenChunkChange).toHaveBeenLastCalledWith('unplaced');
|
||||
});
|
||||
|
||||
it('deleting the only chunk on the last page lands on the page before', async () => {
|
||||
@@ -840,4 +843,39 @@ describe('Chunks', () => {
|
||||
await act(async () => buttonByText('retry')!.click());
|
||||
expect(tile()).not.toBeNull();
|
||||
});
|
||||
const openReaderMenu = async () => {
|
||||
const trigger = buttonByLabel('settings.sources.menuAlt')!;
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((el) => el.textContent);
|
||||
};
|
||||
|
||||
it('read-only (canEdit false): no Add chunk, Edit or Delete; reading stays', async () => {
|
||||
await render({ embedded: true, canEdit: false });
|
||||
expect(buttonByText('settings.sources.addChunk')).toBeUndefined();
|
||||
await act(async () => tile()!.click());
|
||||
expect(container.querySelector('h2')?.textContent).toBe(
|
||||
'Late pickup clause',
|
||||
);
|
||||
expect(buttonByText('modals.chunk.edit')).toBeUndefined();
|
||||
expect(buttonByLabel('settings.sources.nextChunk')).not.toBeNull();
|
||||
expect(await openReaderMenu()).toEqual(['settings.sources.copyText']);
|
||||
});
|
||||
|
||||
it('an editor (canEdit true) keeps Add chunk, Edit and Delete', async () => {
|
||||
await render({ embedded: true, canEdit: true });
|
||||
expect(buttonByText('settings.sources.addChunk')).toBeDefined();
|
||||
await act(async () => tile()!.click());
|
||||
expect(buttonByText('modals.chunk.edit')).toBeDefined();
|
||||
expect(await openReaderMenu()).toEqual([
|
||||
'settings.sources.copyText',
|
||||
'modals.chunk.delete',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,13 @@ import { Pagination } from './ui/pagination';
|
||||
/** Chunks per page: divisible by 2, 3 and 4 columns, so a page fills the grid. */
|
||||
const PAGE_SIZE_OPTIONS = [12, 24, 48];
|
||||
|
||||
/**
|
||||
* Where the open chunk is, as reported to an embedding host: its 1-based
|
||||
* position, 'unplaced' while it is open but its place in the list is unknown
|
||||
* (a save moved it off every probed position), or null while the grid shows.
|
||||
*/
|
||||
export type OpenChunkPosition = number | 'unplaced' | null;
|
||||
|
||||
/** Lets the host's crumbs close the open chunk (see TreeBrowser). */
|
||||
export interface ChunksController {
|
||||
closeChunk: () => void;
|
||||
@@ -79,9 +86,14 @@ interface ChunksProps {
|
||||
* (`onOpenChunkChange`) and close it (`controllerRef`).
|
||||
*/
|
||||
embedded?: boolean;
|
||||
/** The open chunk's position (1-based), or null while the grid shows. */
|
||||
onOpenChunkChange?: (position: number | null) => void;
|
||||
/** Where the open chunk is; see {@link OpenChunkPosition}. */
|
||||
onOpenChunkChange?: (position: OpenChunkPosition) => void;
|
||||
controllerRef?: React.MutableRefObject<ChunksController | null>;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`). False
|
||||
* hides Add chunk, Edit and Delete; reading, copying and paging stay.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
type SheetMode = 'edit' | 'add';
|
||||
@@ -96,6 +108,7 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
embedded = false,
|
||||
onOpenChunkChange,
|
||||
controllerRef,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
@@ -296,7 +309,11 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
closeChunk: () => closeChunk(),
|
||||
}));
|
||||
|
||||
const openChunkPosition = openChunk ? openPosition : null;
|
||||
const openChunkPosition: OpenChunkPosition = !openChunk
|
||||
? null
|
||||
: positionLost
|
||||
? 'unplaced'
|
||||
: openPosition;
|
||||
const onOpenChunkChangeRef = useRef(onOpenChunkChange);
|
||||
useEffect(() => {
|
||||
onOpenChunkChangeRef.current = onOpenChunkChange;
|
||||
@@ -545,15 +562,17 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
className="sm:ml-auto"
|
||||
onClick={() => openSheet('add')}
|
||||
>
|
||||
{t('settings.sources.addChunk')}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
className="sm:ml-auto"
|
||||
onClick={() => openSheet('add')}
|
||||
>
|
||||
{t('settings.sources.addChunk')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -664,16 +683,18 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
disabled={!canGoNext}
|
||||
onClick={() => goToChunk(openPosition + 1)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openSheet('edit')}
|
||||
>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openSheet('edit')}
|
||||
>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
<ActionMenu
|
||||
size="toolbar"
|
||||
triggerLabel={t('settings.sources.menuAlt')}
|
||||
@@ -691,12 +712,16 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('modals.chunk.delete'),
|
||||
variant: 'destructive',
|
||||
onClick: () => confirmDeleteChunk(chunk),
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('modals.chunk.delete'),
|
||||
variant: 'destructive' as const,
|
||||
onClick: () => confirmDeleteChunk(chunk),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ConfigFields from './ConfigFields';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('ConfigFields secrets', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (hasEncryptedCredentials: boolean) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ConfigFields
|
||||
configRequirements={{
|
||||
api_key: {
|
||||
type: 'string',
|
||||
label: 'API key',
|
||||
description: 'Your provider key',
|
||||
secret: true,
|
||||
},
|
||||
}}
|
||||
values={{}}
|
||||
onChange={() => undefined}
|
||||
isEditing
|
||||
hasEncryptedCredentials={hasEncryptedCredentials}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
it('a saved secret stays empty, with the saved state as the hint', async () => {
|
||||
await render(true);
|
||||
const input = container.querySelector('input');
|
||||
expect(input?.type).toBe('password');
|
||||
expect(input?.value).toBe('');
|
||||
expect(input?.placeholder).not.toContain('•');
|
||||
expect(container.textContent).toContain('common.savedSecretHint');
|
||||
});
|
||||
|
||||
it('an unsaved secret has no saved hint', async () => {
|
||||
await render(false);
|
||||
expect(container.textContent).not.toContain('common.savedSecretHint');
|
||||
expect(container.querySelector('input')?.placeholder).toBe(
|
||||
'Your provider key',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ConfigRequirements } from '../modals/types';
|
||||
import { FormField, type FormFieldProps } from './ui/form-field';
|
||||
@@ -43,6 +44,7 @@ export default function ConfigFields({
|
||||
hasEncryptedCredentials = false,
|
||||
labelSurface,
|
||||
}: ConfigFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const sortedFields = useMemo(
|
||||
() =>
|
||||
Object.entries(configRequirements).sort(
|
||||
@@ -59,9 +61,10 @@ export default function ConfigFields({
|
||||
if (!shouldShowField(spec, values)) return null;
|
||||
|
||||
const value = values[key] ?? spec.default ?? '';
|
||||
// A saved secret never comes back: the field stays empty and the
|
||||
// hint says it is saved.
|
||||
const hasEncrypted =
|
||||
isEditing && spec.secret && hasEncryptedCredentials;
|
||||
const placeholder = hasEncrypted ? '••••••••' : '';
|
||||
const error = errors[key] || undefined;
|
||||
|
||||
if (spec.enum) {
|
||||
@@ -98,6 +101,7 @@ export default function ConfigFields({
|
||||
key={key}
|
||||
label={spec.label || key}
|
||||
required={!!spec.required}
|
||||
hint={hasEncrypted ? t('common.savedSecretHint') : undefined}
|
||||
error={error}
|
||||
labelSurface={labelSurface}
|
||||
>
|
||||
@@ -122,7 +126,7 @@ export default function ConfigFields({
|
||||
onChange(key, v);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || spec.description || ''}
|
||||
placeholder={spec.description || ''}
|
||||
min={spec.type === 'number' ? 1 : undefined}
|
||||
max={
|
||||
spec.type === 'number' && key === 'timeout' ? 300 : undefined
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const { tree } = vi.hoisted(() => ({
|
||||
tree: {
|
||||
structure: {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
} as Record<string, unknown>,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
@@ -24,10 +33,7 @@ vi.mock('../api/services/userService', () => ({
|
||||
getDirectoryStructure: vi.fn(async () => ({
|
||||
json: async () => ({
|
||||
provider: 'google_drive',
|
||||
directory_structure: {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
},
|
||||
directory_structure: tree.structure,
|
||||
}),
|
||||
})),
|
||||
getDocumentChunks: vi.fn(async () => ({
|
||||
@@ -144,4 +150,85 @@ describe('ConnectorTree and FileTree headers', () => {
|
||||
);
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Files', 'a.docx']);
|
||||
});
|
||||
const openFirstRowMenu = async () => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'tbody button[aria-label="settings.sources.menuAlt"]',
|
||||
)!;
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((el) => el.textContent);
|
||||
};
|
||||
|
||||
it('read-only ConnectorTree hides Sync, keeps headerAction', async () => {
|
||||
await render(
|
||||
<ConnectorTree
|
||||
docId="doc"
|
||||
sourceName="Drive"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
canEdit={false}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('retrieval');
|
||||
expect(container.textContent).not.toContain('settings.sources.sync');
|
||||
});
|
||||
|
||||
it('read-only FileTree hides Add file and the row Delete', async () => {
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
canEdit={false}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('retrieval');
|
||||
expect(container.textContent).not.toContain('settings.sources.addFile');
|
||||
expect(await openFirstRowMenu()).not.toContain('convTile.delete');
|
||||
});
|
||||
|
||||
it('an editable FileTree keeps Add file and the row Delete', async () => {
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
canEdit
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('settings.sources.addFile');
|
||||
expect(await openFirstRowMenu()).toContain('convTile.delete');
|
||||
});
|
||||
|
||||
it('a read-only one-file FileTree has no header menu and no Add chunk', async () => {
|
||||
tree.structure = { 'a.docx': { type: 'docx' } };
|
||||
try {
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
canEdit={false}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'button[aria-label="settings.sources.menuAlt"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(container.textContent).not.toContain('settings.sources.addChunk');
|
||||
} finally {
|
||||
tree.structure = {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,11 @@ interface ConnectorTreeProps {
|
||||
initialPath?: string;
|
||||
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
|
||||
onCrumbsChange?: (crumbs: Crumb[]) => void;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`).
|
||||
* False hides Sync and the chunk writes; browsing stays.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
// Provider names are brand names, so they are not translated.
|
||||
@@ -64,6 +69,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -145,28 +151,30 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
const topRightAction = (
|
||||
<>
|
||||
{embedded ? null : headerAction}
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
onClick={() => setSyncConfirmationModal('ACTIVE')}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{syncDone ? (
|
||||
<Check />
|
||||
) : isSyncing ? (
|
||||
// The busy state shows its percentage, so it keeps the label and
|
||||
// draws the app's ring spinner at icon size (DESIGN.md, Button).
|
||||
<Spinner size="xs" label={t('settings.sources.syncing')} />
|
||||
) : (
|
||||
<RefreshCw />
|
||||
)}
|
||||
{isSyncing
|
||||
? `${syncProgress}%`
|
||||
: syncDone
|
||||
? t('settings.sources.syncDone')
|
||||
: t('settings.sources.sync')}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
onClick={() => setSyncConfirmationModal('ACTIVE')}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{syncDone ? (
|
||||
<Check />
|
||||
) : isSyncing ? (
|
||||
// The busy state shows its percentage, so it keeps the label and
|
||||
// draws the app's ring spinner at icon size (DESIGN.md, Button).
|
||||
<Spinner size="xs" label={t('settings.sources.syncing')} />
|
||||
) : (
|
||||
<RefreshCw />
|
||||
)}
|
||||
{isSyncing
|
||||
? `${syncProgress}%`
|
||||
: syncDone
|
||||
? t('settings.sources.syncDone')
|
||||
: t('settings.sources.sync')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -188,6 +196,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
embedded={embedded}
|
||||
onCrumbsChange={onCrumbsChange}
|
||||
canEdit={canEdit}
|
||||
actionsTarget={actionsTarget}
|
||||
initialPath={initialPath}
|
||||
badge={
|
||||
|
||||
@@ -38,6 +38,11 @@ interface FileTreeProps {
|
||||
initialPath?: string;
|
||||
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
|
||||
onCrumbsChange?: (crumbs: Crumb[]) => void;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`).
|
||||
* False hides Add file, file and folder Delete (row and header menus) and the chunk writes; browsing stays.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
const FileTree: React.FC<FileTreeProps> = ({
|
||||
@@ -49,6 +54,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -226,6 +232,8 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
isFile,
|
||||
defaultViewOption,
|
||||
}: RowMenuContext): MenuOption[] => {
|
||||
// Read-only: View only, so a one-file source draws no header menu.
|
||||
if (!canEdit) return [defaultViewOption];
|
||||
return [
|
||||
defaultViewOption,
|
||||
{
|
||||
@@ -248,7 +256,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
const topRightAction = (
|
||||
<>
|
||||
{embedded ? null : headerAction}
|
||||
{!isProcessing ? (
|
||||
{canEdit && !isProcessing ? (
|
||||
<Button type="button" size="field" shape="pill" onClick={handleAddFile}>
|
||||
{t('settings.sources.addFile')}
|
||||
</Button>
|
||||
@@ -281,6 +289,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
embedded={embedded}
|
||||
onCrumbsChange={onCrumbsChange}
|
||||
canEdit={canEdit}
|
||||
actionsTarget={actionsTarget}
|
||||
initialPath={initialPath}
|
||||
columnOrder="size-first"
|
||||
|
||||
@@ -79,6 +79,8 @@ interface GraphViewProps {
|
||||
active?: boolean;
|
||||
/** Show a chunk's file on the Files tab (the chunk drawer's "Open in Files"). */
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
type PositionedNode = NodeObject<GraphNode> & { x?: number; y?: number };
|
||||
@@ -110,6 +112,7 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
onSelect,
|
||||
active = true,
|
||||
onOpenInFiles,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDesktop } = useMediaQuery();
|
||||
@@ -590,6 +593,7 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
overview={data}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={nodeDetail.reload}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ import {
|
||||
} from '../constants/fileUpload';
|
||||
import { UserToolType } from '../settings/types';
|
||||
import { sourceItemId, toSourcePickerItems } from '../utils/sourceUtils';
|
||||
import { isChatToolVisible } from '../utils/toolUtils';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { isOwner } from '../utils/accessUtils';
|
||||
import { isChatPickerToolVisible, toolInChat } from '../utils/toolUtils';
|
||||
|
||||
const generateId = (): string =>
|
||||
`${Date.now()}-${Math.random().toString(36).substring(2)}`;
|
||||
@@ -1551,7 +1553,7 @@ export default function MessageInput({
|
||||
.getUserTools(token)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const filtered = (data.tools || []).filter(isChatToolVisible);
|
||||
const filtered = (data.tools || []).filter(isChatPickerToolVisible);
|
||||
setUserTools(filtered);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -1568,25 +1570,53 @@ export default function MessageInput({
|
||||
id: tool.id,
|
||||
label: tool.customName || tool.displayName,
|
||||
icon: <ToolIcon name={tool.name} className="size-5" />,
|
||||
description:
|
||||
!isOwner(tool) && tool.shared_via
|
||||
? t('settings.tools.sharedBy', {
|
||||
interpolation: { escapeValue: false },
|
||||
team: tool.shared_via,
|
||||
})
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const selectedToolIds = userTools
|
||||
.filter((tool) => tool.status)
|
||||
.filter((tool) => toolInChat(tool))
|
||||
.map((tool) => tool.id);
|
||||
|
||||
// Ticks at once and unticks again when the server refuses it.
|
||||
const setToolInChat = (id: string, value: boolean) =>
|
||||
setUserTools((prev) =>
|
||||
prev.map((tool) =>
|
||||
tool.id !== id
|
||||
? tool
|
||||
: isOwner(tool)
|
||||
? { ...tool, status: value, in_chat: value }
|
||||
: { ...tool, in_chat: value },
|
||||
),
|
||||
);
|
||||
|
||||
const handleToggleTool = (id: string) => {
|
||||
const tool = userTools.find((t) => t.id === id);
|
||||
if (!tool) return;
|
||||
const newStatus = !tool.status;
|
||||
const newStatus = !toolInChat(tool);
|
||||
setToolInChat(id, newStatus);
|
||||
const fail = () => {
|
||||
setToolInChat(id, !newStatus);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('settings.tools.statusUpdateFailed'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
userService
|
||||
.updateToolStatus({ id, status: newStatus }, token)
|
||||
.then(() => {
|
||||
setUserTools((prev) =>
|
||||
prev.map((t) => (t.id === id ? { ...t, status: newStatus } : t)),
|
||||
);
|
||||
.then((response: Response) => {
|
||||
if (!response.ok) fail();
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to update tool status:', error);
|
||||
fail();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import RoleBadge from './RoleBadge';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('RoleBadge', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('shows the role as a neutral Badge with the Users icon first', async () => {
|
||||
await act(async () =>
|
||||
root.render(<RoleBadge item={{ access: 'editor' }} />),
|
||||
);
|
||||
const badge = container.querySelector<HTMLElement>('[data-slot="badge"]');
|
||||
expect(badge?.dataset.variant ?? badge?.className).toMatch(/neutral|muted/);
|
||||
const icon = badge?.firstElementChild;
|
||||
expect(icon?.getAttribute('class')).toContain('lucide-users');
|
||||
expect(icon?.getAttribute('class')).not.toContain('size-3');
|
||||
expect(badge?.textContent).toBe('teamAccess.editor');
|
||||
});
|
||||
|
||||
it('reads the legacy team fields through roleOf', async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<RoleBadge item={{ ownership: 'team', team_access: null }} />,
|
||||
),
|
||||
);
|
||||
expect(container.textContent).toBe('teamAccess.viewer');
|
||||
});
|
||||
|
||||
it('renders nothing for the caller’s own item', async () => {
|
||||
await act(async () => root.render(<RoleBadge item={{}} />));
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { roleOf, type AccessFields } from '../utils/accessUtils';
|
||||
import { Badge } from './ui/badge';
|
||||
|
||||
/**
|
||||
* The caller's role on a team-shared asset tile (agent, source, tool): a
|
||||
* neutral Badge with the Users icon and Editor or Viewer. Nothing for the
|
||||
* caller's own items.
|
||||
*/
|
||||
export default function RoleBadge({
|
||||
item,
|
||||
className,
|
||||
}: {
|
||||
item: AccessFields;
|
||||
/** Placement only (a tile pins it beside its menu). */
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const role = roleOf(item);
|
||||
if (role === 'owner') return null;
|
||||
return (
|
||||
<Badge variant="neutral" className={className} data-testid="role-badge">
|
||||
<Users aria-hidden="true" />
|
||||
{role === 'editor' ? t('teamAccess.editor') : t('teamAccess.viewer')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ViewOnlyNotice from './ViewOnlyNotice';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('ViewOnlyNotice', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('is a quiet default Alert (role="note") with the shared sentence', async () => {
|
||||
await act(async () => root.render(<ViewOnlyNotice />));
|
||||
const alert = container.querySelector('[data-slot="alert"]');
|
||||
expect(alert?.getAttribute('role')).toBe('note');
|
||||
expect(alert?.getAttribute('data-variant')).toBe('default');
|
||||
expect(alert?.querySelector('svg')).not.toBeNull();
|
||||
expect(alert?.textContent).toBe('common.viewOnlyNotice');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Lock } from 'lucide-react';
|
||||
import type React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
|
||||
/**
|
||||
* The first child of a form the caller's role can only view: the same
|
||||
* fields, disabled, under one quiet note. Every view-only form shows this
|
||||
* one sentence, so a role reads the same everywhere. `message` replaces it
|
||||
* only where part of an editable form is locked (a tool's credentials).
|
||||
*/
|
||||
export default function ViewOnlyNotice({
|
||||
message,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Alert>, 'children' | 'variant'> & {
|
||||
message?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Alert role="note" {...props}>
|
||||
<Lock />
|
||||
<AlertDescription>
|
||||
{message ?? t('common.viewOnlyNotice')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ interface GraphChunkSheetProps {
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Called after a saved edit, to refetch the node detail. */
|
||||
onSaved?: () => void;
|
||||
/** Whether the read drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +47,7 @@ export default function GraphChunkSheet({
|
||||
onClose,
|
||||
onOpenInFiles,
|
||||
onSaved,
|
||||
canEdit = true,
|
||||
}: GraphChunkSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
@@ -91,6 +94,8 @@ export default function GraphChunkSheet({
|
||||
? t('settings.sources.graphrag.view.chunkTokens', { tokens })
|
||||
: '';
|
||||
|
||||
const showOpenInFiles = !!onOpenInFiles && !!path;
|
||||
|
||||
const close = () => {
|
||||
setEditing(false);
|
||||
setSaveFailed(false);
|
||||
@@ -173,32 +178,44 @@ export default function GraphChunkSheet({
|
||||
highlight={highlight}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-end gap-3 px-6 py-4">
|
||||
{onOpenInFiles && path ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenInFiles(path);
|
||||
}}
|
||||
>
|
||||
{t('settings.sources.graphrag.view.openInFiles')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" size="lg" shape="pill" onClick={startEdit}>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* A reader with nothing to open or edit gets no action row. */}
|
||||
{showOpenInFiles || canEdit ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex justify-end gap-3 px-6 py-4">
|
||||
{showOpenInFiles ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenInFiles?.(path);
|
||||
}}
|
||||
>
|
||||
{t('settings.sources.graphrag.view.openInFiles')}
|
||||
</Button>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={startEdit}
|
||||
>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<SourceEditSheet
|
||||
open={editing}
|
||||
open={canEdit && editing}
|
||||
onClose={leaveEdit}
|
||||
title={t('settings.sources.graphrag.view.editChunk')}
|
||||
description={meta || undefined}
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function GraphEntities({
|
||||
onShowInGraph,
|
||||
overview,
|
||||
onOpenInFiles,
|
||||
canEdit = true,
|
||||
}: {
|
||||
docId: string;
|
||||
fold: FoldedGraphTypes;
|
||||
@@ -70,6 +71,8 @@ export default function GraphEntities({
|
||||
overview?: ForceGraphData;
|
||||
/** Show a chunk's file on the Files tab. */
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -157,6 +160,7 @@ export default function GraphEntities({
|
||||
overview={overview}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={nodeDetail.reload}
|
||||
canEdit={canEdit}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -69,6 +69,8 @@ interface GraphNodePanelProps {
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Refetch the detail after a chunk edit, keeping it on screen. */
|
||||
onChunkSaved?: () => void;
|
||||
/** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +107,7 @@ export default function GraphNodePanel({
|
||||
overview,
|
||||
onOpenInFiles,
|
||||
onChunkSaved,
|
||||
canEdit = true,
|
||||
}: GraphNodePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const name = detail?.name ?? node.name;
|
||||
@@ -166,6 +169,7 @@ export default function GraphNodePanel({
|
||||
overview={overview}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={onChunkSaved}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -193,6 +197,7 @@ function NodeDetailBody({
|
||||
overview,
|
||||
onOpenInFiles,
|
||||
onChunkSaved,
|
||||
canEdit,
|
||||
}: {
|
||||
docId: string;
|
||||
detail: GraphNodeDetail;
|
||||
@@ -201,6 +206,7 @@ function NodeDetailBody({
|
||||
overview?: ForceGraphData;
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
onChunkSaved?: () => void;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [descriptionOpen, setDescriptionOpen] = useState(false);
|
||||
@@ -413,6 +419,7 @@ function NodeDetailBody({
|
||||
onClose={() => setOpenChunk(null)}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onSaved={onChunkSaved}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock('../FileTree', async () => {
|
||||
return {
|
||||
default: (props: {
|
||||
embedded?: boolean;
|
||||
canEdit?: boolean;
|
||||
initialPath?: string;
|
||||
actionsTarget?: HTMLElement | null;
|
||||
onCrumbsChange?: (crumbs: { label: string }[]) => void;
|
||||
@@ -51,6 +52,7 @@ vi.mock('../FileTree', async () => {
|
||||
return (
|
||||
<div
|
||||
data-testid="file-tree"
|
||||
data-can-edit={String(props.canEdit)}
|
||||
data-initial-path={props.initialPath ?? ''}
|
||||
>
|
||||
{props.embedded ? 'embedded' : 'page'}
|
||||
@@ -72,14 +74,15 @@ vi.mock('../Chunks', async () => {
|
||||
return {
|
||||
default: (props: {
|
||||
embedded?: boolean;
|
||||
canEdit?: boolean;
|
||||
documentId: string;
|
||||
onOpenChunkChange?: (position: number | null) => void;
|
||||
onOpenChunkChange?: (position: number | 'unplaced' | null) => void;
|
||||
controllerRef?: { current: { closeChunk: () => boolean } | null };
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState<false | 2 | 'unplaced'>(false);
|
||||
const { onOpenChunkChange, controllerRef } = props;
|
||||
useEffect(() => {
|
||||
onOpenChunkChange?.(open ? 2 : null);
|
||||
onOpenChunkChange?.(open || null);
|
||||
}, [open, onOpenChunkChange]);
|
||||
if (controllerRef) {
|
||||
controllerRef.current = {
|
||||
@@ -90,18 +93,27 @@ vi.mock('../Chunks', async () => {
|
||||
};
|
||||
}
|
||||
return (
|
||||
<div data-testid="chunks" data-doc={props.documentId}>
|
||||
<div
|
||||
data-testid="chunks"
|
||||
data-doc={props.documentId}
|
||||
data-can-edit={String(props.canEdit)}
|
||||
>
|
||||
{props.embedded ? 'embedded' : 'page'}
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
<button type="button" onClick={() => setOpen(2)}>
|
||||
OPEN CHUNK
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen('unplaced')}>
|
||||
LOSE PLACE
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('../ConnectorTree', () => ({
|
||||
default: () => <div data-testid="connector-tree" />,
|
||||
default: (props: { canEdit?: boolean }) => (
|
||||
<div data-testid="connector-tree" data-can-edit={String(props.canEdit)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/services/userService', () => ({
|
||||
@@ -204,6 +216,7 @@ describe('GraphSourceView', () => {
|
||||
sourceType?: string,
|
||||
onBack = vi.fn(),
|
||||
isNested?: boolean,
|
||||
canEdit?: boolean,
|
||||
) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -212,6 +225,7 @@ describe('GraphSourceView', () => {
|
||||
sourceName="Key Accounts"
|
||||
sourceType={sourceType}
|
||||
isNested={isNested}
|
||||
canEdit={canEdit}
|
||||
onBackToDocuments={onBack}
|
||||
headerAction={<button type="button">Test retrieval</button>}
|
||||
/>,
|
||||
@@ -463,6 +477,22 @@ describe('GraphSourceView', () => {
|
||||
).find((b) => b.textContent === 'Key Accounts')!;
|
||||
await act(async () => source.click());
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Key Accounts']);
|
||||
|
||||
// A saved chunk whose place is unknown keeps an unnumbered crumb, and the
|
||||
// source crumb still closes it.
|
||||
await act(async () => buttonByText('LOSE PLACE')!.click());
|
||||
expect(crumbs()).toEqual([
|
||||
'settings.sources.label',
|
||||
'Key Accounts',
|
||||
'settings.sources.chunkCrumbUnplaced',
|
||||
]);
|
||||
const again = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-slot="breadcrumb-link"]',
|
||||
),
|
||||
).find((b) => b.textContent === 'Key Accounts')!;
|
||||
await act(async () => again.click());
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Key Accounts']);
|
||||
});
|
||||
|
||||
it('uses the connector tree for a connector source', async () => {
|
||||
@@ -535,6 +565,75 @@ describe('GraphSourceView', () => {
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('passes canEdit to every Files view', async () => {
|
||||
const canEditOf = (testId: string) =>
|
||||
container
|
||||
.querySelector(`[data-testid="${testId}"]`)
|
||||
?.getAttribute('data-can-edit');
|
||||
await render(undefined, vi.fn(), true, false);
|
||||
await openTab('settings.sources.graphrag.view.tabs.files');
|
||||
expect(canEditOf('file-tree')).toBe('false');
|
||||
await render('connector:file', vi.fn(), true, false);
|
||||
expect(canEditOf('connector-tree')).toBe('false');
|
||||
await render(undefined, vi.fn(), false, false);
|
||||
expect(canEditOf('chunks')).toBe('false');
|
||||
await render(undefined, vi.fn(), false, true);
|
||||
expect(canEditOf('chunks')).toBe('true');
|
||||
});
|
||||
|
||||
const openEntityChunk = async () => {
|
||||
service.getSourceGraphNode.mockResolvedValue(
|
||||
ok({
|
||||
node: {
|
||||
id: 'n',
|
||||
name: 'Nordhaven',
|
||||
type: 'Company',
|
||||
degree: 9,
|
||||
relationships: [],
|
||||
chunks: [
|
||||
{
|
||||
chunk_id: 'c1',
|
||||
text: 'Nordhaven runs the lane.',
|
||||
metadata: { source: 'briefs/Nordhaven.md' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await openTab('settings.sources.graphrag.view.tabs.entities');
|
||||
const row = Array.from(container.querySelectorAll('tbody tr')).find((r) =>
|
||||
r.textContent?.includes('Nordhaven'),
|
||||
) as HTMLTableRowElement;
|
||||
await act(async () => row.click());
|
||||
await flush();
|
||||
const tile = Array.from(
|
||||
container.querySelectorAll('button[data-slot="card"]'),
|
||||
).find((b) =>
|
||||
b.textContent?.includes('Nordhaven runs the lane.'),
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => tile.click());
|
||||
};
|
||||
|
||||
const drawerButtons = () =>
|
||||
Array.from(document.body.querySelectorAll('[role="dialog"] button')).map(
|
||||
(b) => b.textContent,
|
||||
);
|
||||
|
||||
it("a read-only graph's chunk drawer has Open in Files but no Edit", async () => {
|
||||
await render(undefined, vi.fn(), true, false);
|
||||
await openEntityChunk();
|
||||
expect(drawerButtons()).toContain(
|
||||
'settings.sources.graphrag.view.openInFiles',
|
||||
);
|
||||
expect(drawerButtons()).not.toContain('modals.chunk.edit');
|
||||
});
|
||||
|
||||
it("an editor's graph chunk drawer keeps Edit", async () => {
|
||||
await render(undefined, vi.fn(), true, true);
|
||||
await openEntityChunk();
|
||||
expect(drawerButtons()).toContain('modals.chunk.edit');
|
||||
});
|
||||
|
||||
it('a new entity filter fetches page 1 once, not the old page first', async () => {
|
||||
service.getSourceGraphNodes.mockImplementation(async () =>
|
||||
ok({
|
||||
|
||||
@@ -13,7 +13,10 @@ import { useSelector } from 'react-redux';
|
||||
import userService from '../../api/services/userService';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
import { formatCount } from '../../utils/dateTimeUtils';
|
||||
import Chunks, { type ChunksController } from '../Chunks';
|
||||
import Chunks, {
|
||||
type ChunksController,
|
||||
type OpenChunkPosition,
|
||||
} from '../Chunks';
|
||||
import ConnectorTree from '../ConnectorTree';
|
||||
import FileTree from '../FileTree';
|
||||
import GraphView, { type GraphLoadStatus } from '../GraphView';
|
||||
@@ -47,6 +50,11 @@ interface GraphSourceViewProps {
|
||||
onBackToDocuments: () => void;
|
||||
/** Extra header control (Test retrieval), right-aligned in the title row. */
|
||||
headerAction?: ReactNode;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`). False
|
||||
* hides the Files tab's writes and the graph chunk drawer's Edit.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,6 +71,7 @@ export default function GraphSourceView({
|
||||
isNested = true,
|
||||
onBackToDocuments,
|
||||
headerAction,
|
||||
canEdit = true,
|
||||
}: GraphSourceViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -80,9 +89,8 @@ export default function GraphSourceView({
|
||||
// header shows it while that tab is open.
|
||||
const [filesCrumbs, setFilesCrumbs] = useState<Crumb[]>([]);
|
||||
// A flat source's open chunk (its crumb), reported by the chunk list.
|
||||
const [openChunkPosition, setOpenChunkPosition] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [openChunkPosition, setOpenChunkPosition] =
|
||||
useState<OpenChunkPosition>(null);
|
||||
const chunksControllerRef = useRef<ChunksController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -157,7 +165,10 @@ export default function GraphSourceView({
|
||||
...(openChunkPosition !== null
|
||||
? [
|
||||
{
|
||||
label: t('settings.sources.chunkCrumb', { n: openChunkPosition }),
|
||||
label:
|
||||
openChunkPosition === 'unplaced'
|
||||
? t('settings.sources.chunkCrumbUnplaced')
|
||||
: t('settings.sources.chunkCrumb', { n: openChunkPosition }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -167,6 +178,7 @@ export default function GraphSourceView({
|
||||
const files = !isNested ? (
|
||||
<Chunks
|
||||
embedded
|
||||
canEdit={canEdit}
|
||||
documentId={docId}
|
||||
documentName={sourceName}
|
||||
handleGoBack={onBackToDocuments}
|
||||
@@ -176,6 +188,7 @@ export default function GraphSourceView({
|
||||
) : sourceType === 'connector:file' ? (
|
||||
<ConnectorTree
|
||||
embedded
|
||||
canEdit={canEdit}
|
||||
docId={docId}
|
||||
sourceName={sourceName}
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
@@ -186,6 +199,7 @@ export default function GraphSourceView({
|
||||
) : (
|
||||
<FileTree
|
||||
embedded
|
||||
canEdit={canEdit}
|
||||
docId={docId}
|
||||
sourceName={sourceName}
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
@@ -272,6 +286,7 @@ export default function GraphSourceView({
|
||||
onSelect={setSelected}
|
||||
active={tab === 'graph'}
|
||||
onOpenInFiles={openInFiles}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="entities" className="mt-4">
|
||||
@@ -281,6 +296,7 @@ export default function GraphSourceView({
|
||||
onShowInGraph={showInGraph}
|
||||
overview={data}
|
||||
onOpenInFiles={openInFiles}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="mt-4">
|
||||
|
||||
@@ -42,6 +42,10 @@ vi.mock('../../api/services/userService', () => ({
|
||||
chunks: state.chunks,
|
||||
}),
|
||||
})),
|
||||
updateChunk: vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ chunk_id: 'c1' }),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -252,6 +256,40 @@ describe('TreeBrowser', () => {
|
||||
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'readme.md']);
|
||||
});
|
||||
|
||||
it('a saved chunk whose place is unknown keeps an unnumbered crumb', async () => {
|
||||
state.chunks = [{ doc_id: 'c1', text: '# Rates', metadata: {} }];
|
||||
await render(NESTED);
|
||||
await openRow('readme.md');
|
||||
await act(async () => tile()!.click());
|
||||
const button = (text: string) =>
|
||||
Array.from(document.body.querySelectorAll('button')).find(
|
||||
(el) => el.textContent?.trim() === text,
|
||||
)!;
|
||||
await act(async () => button('modals.chunk.edit').click());
|
||||
// After the save, the probed positions no longer hold the chunk.
|
||||
state.chunks = [];
|
||||
const field = document.body.querySelector<HTMLTextAreaElement>(
|
||||
'[role="dialog"] textarea',
|
||||
)!;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
'value',
|
||||
)!.set!.call(field, '# Rates edited');
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await act(async () => button('modals.chunk.save').click());
|
||||
expect(crumbs()).toEqual([
|
||||
SOURCES,
|
||||
'Contracts',
|
||||
'readme.md',
|
||||
'settings.sources.chunkCrumbUnplaced',
|
||||
]);
|
||||
|
||||
await clickCrumb('readme.md');
|
||||
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'readme.md']);
|
||||
});
|
||||
|
||||
it('a one-file source opens straight on its chunk list', async () => {
|
||||
await render({ 'report.pdf': { type: 'pdf', display_name: 'Report' } });
|
||||
expect(
|
||||
@@ -299,6 +337,17 @@ describe('TreeBrowser', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('canEdit false reaches the chunk list: no Add chunk', async () => {
|
||||
await render({ 'report.pdf': { type: 'pdf' } }, { canEdit: false });
|
||||
expect(chunkListOpen()).toBe(true);
|
||||
expect(container.textContent).not.toContain('settings.sources.addChunk');
|
||||
});
|
||||
|
||||
it('an editable tree keeps Add chunk on the chunk list', async () => {
|
||||
await render({ 'report.pdf': { type: 'pdf' } });
|
||||
expect(container.textContent).toContain('settings.sources.addChunk');
|
||||
});
|
||||
|
||||
it('a failed load says so and retries', async () => {
|
||||
const getDirectoryStructure = vi.mocked(userService.getDirectoryStructure);
|
||||
getDirectoryStructure.mockImplementationOnce(async () => {
|
||||
|
||||
@@ -17,7 +17,10 @@ import { Eye, File, Folder } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { EmptyState } from '../ui/empty-state';
|
||||
import { useLoaderState } from '../../hooks';
|
||||
import Chunks, { type ChunksController } from '../Chunks';
|
||||
import Chunks, {
|
||||
type ChunksController,
|
||||
type OpenChunkPosition,
|
||||
} from '../Chunks';
|
||||
import PathHeader, { type Crumb } from './PathHeader';
|
||||
import SourceNavigator from './SourceNavigator';
|
||||
import SkeletonLoader from '../SkeletonLoader';
|
||||
@@ -111,6 +114,11 @@ export interface TreeBrowserProps {
|
||||
* changes.
|
||||
*/
|
||||
initialPath?: string;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`).
|
||||
* False hides the chunk list's Add, Edit and Delete; browsing stays.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,6 +199,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useLoaderState(true, 500);
|
||||
@@ -207,9 +216,8 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
} | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
// The open file's open chunk (its crumb), reported by Chunks.
|
||||
const [openChunkPosition, setOpenChunkPosition] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [openChunkPosition, setOpenChunkPosition] =
|
||||
useState<OpenChunkPosition>(null);
|
||||
const chunksControllerRef = useRef<ChunksController | null>(null);
|
||||
|
||||
useEffect(
|
||||
@@ -587,7 +595,10 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
...(chunkOpen
|
||||
? [
|
||||
{
|
||||
label: t('settings.sources.chunkCrumb', { n: openChunkPosition }),
|
||||
label:
|
||||
openChunkPosition === 'unplaced'
|
||||
? t('settings.sources.chunkCrumbUnplaced')
|
||||
: t('settings.sources.chunkCrumb', { n: openChunkPosition }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -683,6 +694,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
fileName={file.name}
|
||||
controllerRef={chunksControllerRef}
|
||||
onOpenChunkChange={setOpenChunkPosition}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -45,4 +45,15 @@ describe('ListRow', () => {
|
||||
expect(html).not.toContain('px-4 py-3');
|
||||
expect(html).toContain('focus-visible:ring-inset');
|
||||
});
|
||||
|
||||
it('keeps the brand tint on a selected row, hover included', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ListRow interactive selected asChild title="Carrier Rates MCP">
|
||||
<button type="button" />
|
||||
</ListRow>,
|
||||
);
|
||||
expect(html).toContain('bg-secondary hover:bg-secondary');
|
||||
expect(html).not.toContain('hover:bg-accent');
|
||||
expect(html).toContain('aria-current="true"');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,12 @@ type ListRowProps = Omit<React.ComponentProps<'li'>, 'title'> & {
|
||||
trailing?: React.ReactNode;
|
||||
/** The whole row is a target: hover fill and an inset focus ring. */
|
||||
interactive?: boolean;
|
||||
/**
|
||||
* The row whose detail is open beside the list (the team page's shared
|
||||
* resources drawer): the `bg-secondary` brand tint, kept on hover, and
|
||||
* `aria-current`, like a selected TableRow.
|
||||
*/
|
||||
selected?: boolean;
|
||||
/**
|
||||
* `sm` is the dense row of a narrow side panel (the graph node panel's
|
||||
* relationships): 6px / 8px padding, rounded, top-aligned so a small
|
||||
@@ -45,6 +51,7 @@ function ListRow({
|
||||
description,
|
||||
trailing,
|
||||
interactive = false,
|
||||
selected = false,
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
className,
|
||||
@@ -59,7 +66,9 @@ function ListRow({
|
||||
// Inset, because a row list usually sits in an overflow-hidden rounded
|
||||
// box that would clip an outer ring.
|
||||
interactive &&
|
||||
'hover:bg-accent focus-visible:ring-ring/50 w-full text-left transition-colors outline-none focus-visible:ring-3 focus-visible:ring-inset',
|
||||
'focus-visible:ring-ring/50 w-full text-left transition-colors outline-none focus-visible:ring-3 focus-visible:ring-inset',
|
||||
interactive && !selected && 'hover:bg-accent',
|
||||
selected && 'bg-secondary hover:bg-secondary',
|
||||
!asChild && className,
|
||||
);
|
||||
const content = (
|
||||
@@ -80,7 +89,10 @@ function ListRow({
|
||||
if (asChild) {
|
||||
return (
|
||||
<li data-slot="list-row" className={className} {...props}>
|
||||
<Slot.Root className={rowClass}>
|
||||
<Slot.Root
|
||||
className={rowClass}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
>
|
||||
{React.isValidElement(children)
|
||||
? React.cloneElement(
|
||||
children as React.ReactElement<{ children?: React.ReactNode }>,
|
||||
@@ -94,7 +106,12 @@ function ListRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<li data-slot="list-row" className={rowClass} {...props}>
|
||||
<li
|
||||
data-slot="list-row"
|
||||
className={rowClass}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { canOpenAgentEditor } from '../agents/agentAccess';
|
||||
import SharedAgentCard from '../agents/SharedAgentCard';
|
||||
import { Agent } from '../agents/types';
|
||||
import ArtifactSidebar from '../components/ArtifactSidebar';
|
||||
@@ -12,6 +13,7 @@ import MessageInput from '../components/MessageInput';
|
||||
import { agentChatPath, agentEditPathFor } from '../agents/paths';
|
||||
import { useMediaQuery } from '../hooks';
|
||||
import {
|
||||
selectAgents,
|
||||
selectConversationId,
|
||||
selectSelectedAgent,
|
||||
selectToken,
|
||||
@@ -61,6 +63,7 @@ export default function Conversation() {
|
||||
const status = useSelector(selectStatus);
|
||||
const conversationId = useSelector(selectConversationId);
|
||||
const selectedAgent = useSelector(selectSelectedAgent);
|
||||
const agents = useSelector(selectAgents);
|
||||
const completedAttachments = useSelector(selectCompletedAttachments);
|
||||
const attachments = useSelector(selectAttachments);
|
||||
// A direct send (hero card) that must wait for pending attachments is
|
||||
@@ -401,7 +404,8 @@ export default function Conversation() {
|
||||
<SharedAgentCard
|
||||
agent={selectedAgent}
|
||||
onEdit={
|
||||
selectedAgent.id
|
||||
// Only a role that may open the edit page gets Edit.
|
||||
canOpenAgentEditor(selectedAgent, agents)
|
||||
? () => navigate(agentEditPathFor(selectedAgent))
|
||||
: undefined
|
||||
}
|
||||
|
||||
+236
-17
@@ -10,6 +10,12 @@
|
||||
"loading": "Wird geladen...",
|
||||
"retry": "Erneut versuchen",
|
||||
"cancel": "Abbrechen",
|
||||
"common": {
|
||||
"close": "Schließen",
|
||||
"viewOnlyNotice": "Du kannst dies ansehen, aber deine Rolle kann es nicht ändern.",
|
||||
"credentialsLockedNotice": "Nur der Eigentümer kann die Zugangsdaten dieses Tools ändern.",
|
||||
"savedSecretHint": "Gespeichert. Leer lassen, um es zu behalten."
|
||||
},
|
||||
"help": "Hilfe",
|
||||
"emailUs": "E-Mail senden",
|
||||
"documentation": "Dokumentation",
|
||||
@@ -92,7 +98,10 @@
|
||||
"edit": "Prompt bearbeiten",
|
||||
"view": "Prompt anzeigen",
|
||||
"duplicate": "Prompt duplizieren",
|
||||
"delete": "Prompt löschen"
|
||||
"delete": "Prompt löschen",
|
||||
"deleteFailed": "Dieser Prompt konnte nicht gelöscht werden.",
|
||||
"saveFailed": "Dieser Prompt konnte nicht gespeichert werden.",
|
||||
"editConflict": "Jemand anderes hat diesen Prompt geändert. Öffne ihn erneut, um dessen Version zu sehen."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -393,7 +402,6 @@
|
||||
"subtitle": "Konfiguriere, wie \"{{name}}\" in Chunks aufgeteilt und abgerufen wird.",
|
||||
"subtitleGeneric": "Konfiguriere, wie diese Quelle in Chunks aufgeteilt und abgerufen wird.",
|
||||
"save": "Speichern",
|
||||
"readOnly": "Du hast nur Lesezugriff auf diese geteilte Quelle und kannst ihre Konfiguration nicht ändern.",
|
||||
"chunkingChangeHint": "Du hast eine Chunking-Einstellung geändert. Nach dem Speichern muss diese Quelle erneut indexiert werden, damit die Änderung wirkt.",
|
||||
"prescreenInvalidHint": "Prüfe die Vorfilter-Werte: Die abzurufenden Kandidaten müssen mindestens der Chunk-Anzahl entsprechen, und die zu behaltenden Chunks dürfen die Kandidatenzahl nicht überschreiten.",
|
||||
"reingestRequired": "Deine Änderungen wurden gespeichert. Die geänderten Chunking-Einstellungen wirken erst nach einem erneuten Indexieren. Jetzt erneut indexieren?",
|
||||
@@ -437,7 +445,15 @@
|
||||
"editChunk": "Chunk bearbeiten",
|
||||
"editChunkDescription": "{{file}} · Chunk {{n}} · {{tokens}} Tokens",
|
||||
"previousChunk": "Vorheriger Chunk",
|
||||
"nextChunk": "Nächster Chunk"
|
||||
"nextChunk": "Nächster Chunk",
|
||||
"viewConfig": "Quelleneinstellungen ansehen",
|
||||
"errors": {
|
||||
"forbidden": "Dazu hast du für diese Quelle keine Berechtigung.",
|
||||
"delete": "Die Quelle konnte nicht gelöscht werden.",
|
||||
"sync": "Die Quelle konnte nicht synchronisiert werden.",
|
||||
"syncFrequency": "Die Synchronisierungshäufigkeit konnte nicht geändert werden.",
|
||||
"reingest": "Die Neuaufnahme konnte nicht gestartet werden."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analytik",
|
||||
@@ -660,9 +676,10 @@
|
||||
"createTeamError": "Team konnte nicht erstellt werden.",
|
||||
"noTeams": "Noch keine Teams.",
|
||||
"noDescription": "Keine Beschreibung",
|
||||
"memberCountOne": "{{count}} Mitglied",
|
||||
"memberCountOther": "{{count}} Mitglieder",
|
||||
"sharedCount": "{{count}} geteilt",
|
||||
"memberCount_one": "{{formatted}} Mitglied",
|
||||
"memberCount_other": "{{formatted}} Mitglieder",
|
||||
"sharedCount_one": "{{formatted}} geteilt",
|
||||
"sharedCount_other": "{{formatted}} geteilt",
|
||||
"loadError": "Teams konnten nicht geladen werden.",
|
||||
"roleAdmin": "Admin",
|
||||
"roleMember": "Mitglied",
|
||||
@@ -729,7 +746,186 @@
|
||||
"teamLabel": "Team",
|
||||
"viaTeam": "über {{team}}",
|
||||
"removeAccess": "Zugriff entfernen",
|
||||
"access": "Zugriff"
|
||||
"access": "Zugriff",
|
||||
"showAll": "Alle {{formatted}} anzeigen",
|
||||
"andMore_one": "und {{formatted}} weitere",
|
||||
"andMore_other": "und {{formatted}} weitere",
|
||||
"back": "Zurück",
|
||||
"allSummary": "{{name}} · Teams: {{teams}} · Personen: {{people}}",
|
||||
"searchAccess": "Personen und Teams suchen…",
|
||||
"filterLabel": "Personen mit Zugriff filtern",
|
||||
"filter": {
|
||||
"all": "Alle",
|
||||
"teams": "Teams",
|
||||
"people": "Personen",
|
||||
"editors": "Bearbeiter"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "Zugriff konnte nicht geändert werden.",
|
||||
"accessSettings": {
|
||||
"title": "Zugriffseinstellungen",
|
||||
"saveError": "Die Zugriffseinstellung konnte nicht gespeichert werden.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Bearbeiter dürfen löschen",
|
||||
"description": "Den Agenten für alle löschen."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Bearbeiter dürfen Zugangsdaten verwalten",
|
||||
"description": "API-Schlüssel, Webhook und öffentlicher Link."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Betrachter sehen Protokolle",
|
||||
"description": "Unterhaltungen und Analysen dieses Agenten."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Bearbeiter dürfen löschen",
|
||||
"description": "Die Quelle für alle löschen."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Betrachter sehen Einstellungen",
|
||||
"description": "Chunking-, Retriever- und Sync-Einstellungen, nur lesend."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Bearbeiter dürfen Anmeldedaten und Verbindung ändern",
|
||||
"description": "Sie ersetzen gespeicherte Geheimnisse; niemand kann sie auslesen."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Betrachter dürfen es in eigenen Agenten nutzen",
|
||||
"description": "Es läuft mit deinen Anmeldedaten."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Betrachter dürfen duplizieren",
|
||||
"description": "Eine eigene Kopie zum Bearbeiten anlegen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Bearbeiter können ihn ändern, einschließlich Protokollen, Zeitplänen und Zugangsdaten.",
|
||||
"agentNoAccessDetails": "Bearbeiter können ihn ändern, einschließlich Protokollen und Zeitplänen, aber nicht die Zugangsdaten.",
|
||||
"source": "Bearbeiter können Chunks und Dateien bearbeiten, synchronisieren und Einstellungen ändern.",
|
||||
"tool": "Bearbeiter können Aktionen ändern und Anmeldedaten ersetzen, aber keine Geheimnisse lesen.",
|
||||
"toolNoCredentials": "Bearbeiter können Aktionen ändern, aber keine Anmeldedaten.",
|
||||
"prompt": "Bearbeiter können den Text ändern.",
|
||||
"noShareNoDelete": "Sie können es weder teilen noch löschen.",
|
||||
"shareOnly": "Sie können es auch teilen, aber nicht löschen.",
|
||||
"deleteOnly": "Sie können es auch löschen, aber nicht teilen.",
|
||||
"shareAndDelete": "Sie können es auch teilen und löschen.",
|
||||
"noShare": "Sie können es weder teilen noch löschen.",
|
||||
"share": "Sie können es auch teilen, aber nicht löschen."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Betrachter: damit chatten und ihn anheften",
|
||||
"editors": "Bearbeiter: bearbeiten, veröffentlichen, Protokolle sehen und Zeitpläne verwalten"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Betrachter: durchsuchen und in eigenen Agenten nutzen",
|
||||
"editors": "Bearbeiter: Chunks und Dateien bearbeiten, synchronisieren, Einstellungen ändern"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Betrachter: in den Agenten des Eigentümers nutzen",
|
||||
"editors": "Bearbeiter: Aktionen und Freigaben ändern"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Betrachter: lesen und in eigenen Agenten nutzen",
|
||||
"editors": "Bearbeiter: den Text ändern"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Bearbeiter können es teilen",
|
||||
"off": "Bearbeiter können es nicht teilen"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Bearbeiter können es löschen",
|
||||
"off": "Bearbeiter können es nicht löschen"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Bearbeiter verwalten API-Schlüssel, Webhook und öffentlichen Link",
|
||||
"off": "Bearbeiter verwalten weder API-Schlüssel noch Webhook oder öffentlichen Link"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Betrachter sehen Protokolle",
|
||||
"off": "Betrachter sehen keine Protokolle"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Betrachter sehen die Einstellungen",
|
||||
"off": "Betrachter sehen die Einstellungen nicht"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Bearbeiter können Anmeldedaten ersetzen",
|
||||
"off": "Bearbeiter können Anmeldedaten nicht ändern"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Betrachter können es in eigenen Agenten nutzen",
|
||||
"off": "Betrachter können es nicht in eigenen Agenten nutzen"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Betrachter können es duplizieren",
|
||||
"off": "Betrachter können es nicht duplizieren"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors_one": "{{level}} · +{{formatted}} Bearbeiter",
|
||||
"badgeWithEditors_other": "{{level}} · +{{formatted}} Bearbeiter",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Geteilte Ressourcen filtern",
|
||||
"filter": {
|
||||
"all": "Alle",
|
||||
"agent": "Agenten",
|
||||
"source": "Quellen",
|
||||
"tool": "Werkzeuge",
|
||||
"prompt": "Prompts"
|
||||
},
|
||||
"search": "Geteilte suchen…",
|
||||
"noMatches": "Keine Treffer."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Schließen",
|
||||
"subtitle": "{{type}} · Eigentümer: {{owner}}",
|
||||
"open": "{{type}} öffnen",
|
||||
"manageSharing": "Freigabe verwalten",
|
||||
"owner": "Eigentümer",
|
||||
"shared": "Geteilt",
|
||||
"sharedOnBy": "{{date}} von {{name}}",
|
||||
"yourAccess": "Dein Zugriff",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Eigentümer",
|
||||
"editor": "Bearbeiter",
|
||||
"viewer": "Betrachter",
|
||||
"none": "Kein Zugriff"
|
||||
},
|
||||
"accessIn": "Zugriff in {{team}}",
|
||||
"everyone": "Alle in {{team}}",
|
||||
"teamGrant": "Ganzes Team",
|
||||
"memberGrant": "Nur diese Person",
|
||||
"removeGrant": "Zugriff entfernen",
|
||||
"otherTeamsHint": "Auch mit anderen Teams geteilt? Diese Freigaben verwaltest du unter „Freigabe verwalten“.",
|
||||
"whatPeopleCanDo": "Was Personen hier dürfen",
|
||||
"capabilitiesHint": "Aus den Zugriffseinstellungen des Eigentümers. Hier nur lesend."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -803,7 +999,6 @@
|
||||
"addServer": "MCP-Server hinzufügen",
|
||||
"editServer": "Server bearbeiten",
|
||||
"reconnectServer": "Server erneut verbinden",
|
||||
"reenterCredentials": "Gib deine Zugangsdaten erneut ein, um die Verbindung zu testen und zu aktualisieren.",
|
||||
"serverName": "Servername",
|
||||
"serverUrl": "Server-URL",
|
||||
"headerName": "Header-Name",
|
||||
@@ -848,7 +1043,18 @@
|
||||
"oauthFailed": "OAuth-Prozess fehlgeschlagen oder abgebrochen",
|
||||
"oauthTimeout": "OAuth-Prozess abgelaufen, bitte erneut versuchen",
|
||||
"timeoutRange": "Timeout muss zwischen 1 und 300 Sekunden liegen"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Geteilt von {{owner}} · du bist Bearbeiter",
|
||||
"aTeammate": "einem Teammitglied",
|
||||
"sharedCredentialsNotice": "Gespeicherte Zugangsdaten bleiben verborgen. Was du eingibst, ersetzt sie für alle, die dieses Tool nutzen.",
|
||||
"serverChangedNotice": "Der Server hat sich geändert, daher werden die gespeicherten Zugangsdaten ({{credential}}) gelöscht. Gib sie für den neuen Server ein, um zu speichern.",
|
||||
"credentialNames": {
|
||||
"apiKey": "API-Schlüssel",
|
||||
"bearer": "Token",
|
||||
"password": "Passwort"
|
||||
},
|
||||
"savedKeyHint": "Ein Schlüssel ist gespeichert. Leer lassen, um ihn zu behalten (nur solange der Server unverändert ist).",
|
||||
"sharedOAuthOwnerOnly": "Nur der Eigentümer kann die Verbindung dieses Servers ändern oder die Anmeldung neu verbinden. Du kannst ihn weiterhin umbenennen und seine Aktionen bearbeiten."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}} ist erforderlich",
|
||||
@@ -856,7 +1062,14 @@
|
||||
"maxTimeout": "Das maximale Timeout beträgt 300 Sekunden"
|
||||
},
|
||||
"headerValuePlaceholder": "z. B. application/json",
|
||||
"toolIconTitle": "{{name}}-Symbol"
|
||||
"toolIconTitle": "{{name}}-Symbol",
|
||||
"view": "Ansehen",
|
||||
"inMyChats": "In meinen Chats",
|
||||
"useInMyChatsAria": "{{toolName}} in meinen Chats verwenden",
|
||||
"statusUpdateFailed": "Konnte nicht ändern, ob dieses Tool in deinen Chats ist.",
|
||||
"deleteFailed": "Dieses Tool konnte nicht gelöscht werden.",
|
||||
"sharedBy": "Geteilt von {{team}}",
|
||||
"savedSecretPlaceholder": "Gespeichert · neuen Wert eingeben zum Ersetzen"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Geräte",
|
||||
@@ -1353,7 +1566,9 @@
|
||||
"test": "Testen",
|
||||
"learnMore": "Mehr erfahren",
|
||||
"resetKey": "Schlüssel zurücksetzen",
|
||||
"resetKeyConfirm": "Möchten Sie den API-Schlüssel wirklich zurücksetzen? Der aktuelle Schlüssel funktioniert sofort nicht mehr und diese Aktion kann nicht rückgängig gemacht werden."
|
||||
"resetKeyConfirm": "Möchten Sie den API-Schlüssel wirklich zurücksetzen? Der aktuelle Schlüssel funktioniert sofort nicht mehr und diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"actionFailed": "Das hat nicht funktioniert. Bitte versuche es erneut.",
|
||||
"apiKeyAfterPublish": "Veröffentliche den Agenten, um seinen API-Schlüssel zu erstellen."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "API-Spezifikation importieren",
|
||||
@@ -1571,6 +1786,7 @@
|
||||
"agents": {
|
||||
"title": "Agenten",
|
||||
"edit": "Bearbeiten",
|
||||
"view": "Ansehen",
|
||||
"card": {
|
||||
"pin": "Agent anheften",
|
||||
"unpin": "Agent lösen",
|
||||
@@ -1609,10 +1825,6 @@
|
||||
"team": "Team",
|
||||
"shared": "Mit mir geteilt"
|
||||
},
|
||||
"teamBadge": {
|
||||
"editor": "Bearbeiter",
|
||||
"viewer": "Betrachter"
|
||||
},
|
||||
"sections": {
|
||||
"template": {
|
||||
"title": "Von DocsGPT",
|
||||
@@ -1774,7 +1986,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1799,6 +2010,13 @@
|
||||
},
|
||||
"status": {
|
||||
"published": "Veröffentlicht"
|
||||
},
|
||||
"sponsors": {
|
||||
"attachNote": "Tools, Quellen und Prompts, die du aus deiner eigenen Bibliothek hinzufügst, laufen für alle, die diesen Agenten nutzen, mit deinem Zugriff.",
|
||||
"publicLinkNote": "Dieser Agent hat einen öffentlichen Link, daher kann jeder mit dem Link Antworten daraus erhalten.",
|
||||
"addedBy": "Hinzugefügt von {{person}}: {{names}}",
|
||||
"unavailable": "Nicht aktiv: {{names}}. {{person}} hat sie hinzugefügt und hat keinen Zugriff mehr. Entferne sie oder wähle welche, die der Eigentümer nutzen kann.",
|
||||
"unknownItem": "Unbenanntes Element"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -2234,7 +2452,8 @@
|
||||
"classicDescription": "Erstelle einen Standard-KI-Agenten mit einem Modell, Tools und Wissensquellen",
|
||||
"workflowTitle": "Workflow-Agent",
|
||||
"workflowDescription": "Entwirf komplexe mehrstufige Workflows mit verschiedenen Modellen, bedingter Logik und Zustandsverwaltung"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "Der Agent konnte nicht gelöscht werden. Bitte versuche es erneut."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+236
-17
@@ -10,6 +10,12 @@
|
||||
"loading": "Loading...",
|
||||
"retry": "Retry",
|
||||
"cancel": "Cancel",
|
||||
"common": {
|
||||
"close": "Close",
|
||||
"viewOnlyNotice": "You can view this, but your role can't change it.",
|
||||
"credentialsLockedNotice": "Only the owner can change this tool's credentials.",
|
||||
"savedSecretHint": "Saved. Leave empty to keep it."
|
||||
},
|
||||
"errorBoundary": {
|
||||
"message": "Something went wrong displaying this content.",
|
||||
"tryAgain": "Try again"
|
||||
@@ -96,7 +102,10 @@
|
||||
"edit": "Edit prompt",
|
||||
"view": "View prompt",
|
||||
"duplicate": "Duplicate prompt",
|
||||
"delete": "Delete prompt"
|
||||
"delete": "Delete prompt",
|
||||
"deleteFailed": "Couldn't delete this prompt.",
|
||||
"saveFailed": "Couldn't save this prompt.",
|
||||
"editConflict": "Someone else changed this prompt. Reopen it to see their version."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -398,7 +407,6 @@
|
||||
"subtitle": "Configure how \"{{name}}\" is chunked and retrieved.",
|
||||
"subtitleGeneric": "Configure how this source is chunked and retrieved.",
|
||||
"save": "Save",
|
||||
"readOnly": "You have view-only access to this shared source and cannot change its config.",
|
||||
"chunkingChangeHint": "You changed a chunking setting. Saving will require re-ingesting this source for it to take effect.",
|
||||
"prescreenInvalidHint": "Check the prescreen values: candidates to fetch must be at least the number of chunks, and chunks to keep must not exceed candidates to fetch.",
|
||||
"reingestRequired": "Your changes were saved. The chunking settings you changed only take effect after a re-ingest. Re-ingest now?",
|
||||
@@ -442,7 +450,15 @@
|
||||
"editChunk": "Edit chunk",
|
||||
"editChunkDescription": "{{file}} · chunk {{n}} · {{tokens}} tokens",
|
||||
"previousChunk": "Previous chunk",
|
||||
"nextChunk": "Next chunk"
|
||||
"nextChunk": "Next chunk",
|
||||
"viewConfig": "View source settings",
|
||||
"errors": {
|
||||
"forbidden": "You don't have permission to do that on this source.",
|
||||
"delete": "Couldn't delete the source.",
|
||||
"sync": "Couldn't sync the source.",
|
||||
"syncFrequency": "Couldn't change the sync frequency.",
|
||||
"reingest": "Couldn't start the reingest."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analytics",
|
||||
@@ -666,9 +682,10 @@
|
||||
"createTeamError": "Could not create team.",
|
||||
"noTeams": "No teams yet.",
|
||||
"noDescription": "No description",
|
||||
"memberCountOne": "{{count}} member",
|
||||
"memberCountOther": "{{count}} members",
|
||||
"sharedCount": "{{count}} shared",
|
||||
"memberCount_one": "{{formatted}} member",
|
||||
"memberCount_other": "{{formatted}} members",
|
||||
"sharedCount_one": "{{formatted}} shared",
|
||||
"sharedCount_other": "{{formatted}} shared",
|
||||
"loadError": "Failed to load teams.",
|
||||
"roleAdmin": "admin",
|
||||
"roleMember": "member",
|
||||
@@ -735,7 +752,186 @@
|
||||
"teamLabel": "Team",
|
||||
"viaTeam": "via {{team}}",
|
||||
"removeAccess": "Remove access",
|
||||
"access": "Access"
|
||||
"access": "Access",
|
||||
"showAll": "Show all {{formatted}}",
|
||||
"andMore_one": "and {{formatted}} more",
|
||||
"andMore_other": "and {{formatted}} more",
|
||||
"back": "Back",
|
||||
"allSummary": "{{name}} · Teams: {{teams}} · People: {{people}}",
|
||||
"searchAccess": "Search people and teams…",
|
||||
"filterLabel": "Filter people with access",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"teams": "Teams",
|
||||
"people": "People",
|
||||
"editors": "Editors"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "Could not change access.",
|
||||
"accessSettings": {
|
||||
"title": "Access settings",
|
||||
"saveError": "Could not save the access setting.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Editors can delete",
|
||||
"description": "Delete the agent for everyone."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Editors can manage access details",
|
||||
"description": "API key, webhook and public link."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Viewers can see logs",
|
||||
"description": "Conversations and analytics for this agent."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Editors can delete",
|
||||
"description": "Delete the source for everyone."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Viewers can see settings",
|
||||
"description": "Chunking, retriever and sync settings, read only."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Editors can change credentials and connection",
|
||||
"description": "They replace saved secrets; nobody can read them back."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Viewers can use it in their own agents",
|
||||
"description": "It runs with your credentials."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Viewers can duplicate",
|
||||
"description": "Make their own copy to edit."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Editors can change it, including logs, schedules and access details.",
|
||||
"agentNoAccessDetails": "Editors can change it, including logs and schedules, but not access details.",
|
||||
"source": "Editors can edit chunks and files, sync and change settings.",
|
||||
"tool": "Editors can change actions and replace credentials, but can’t read secrets.",
|
||||
"toolNoCredentials": "Editors can change actions, but not credentials.",
|
||||
"prompt": "Editors can change the text.",
|
||||
"noShareNoDelete": "They can’t share or delete it.",
|
||||
"shareOnly": "They can also share it, but can’t delete it.",
|
||||
"deleteOnly": "They can also delete it, but can’t share it.",
|
||||
"shareAndDelete": "They can also share and delete it.",
|
||||
"noShare": "They can’t share or delete it.",
|
||||
"share": "They can also share it, but can’t delete it."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Viewers: chat with it and pin it",
|
||||
"editors": "Editors: edit it, publish it, see logs and manage schedules"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Viewers: browse, search and use it in their agents",
|
||||
"editors": "Editors: edit chunks and files, sync, change settings"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Viewers: use it inside the owner’s agents",
|
||||
"editors": "Editors: change actions and approvals"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Viewers: read it and use it in their agents",
|
||||
"editors": "Editors: change the text"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Editors can share it",
|
||||
"off": "Editors can’t share it"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Editors can delete it",
|
||||
"off": "Editors can’t delete it"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Editors can manage the API key, webhook and public link",
|
||||
"off": "Editors can’t manage the API key, webhook or public link"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Viewers can see logs",
|
||||
"off": "Viewers can’t see logs"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Viewers can see its settings",
|
||||
"off": "Viewers can’t see its settings"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Editors can replace credentials",
|
||||
"off": "Editors can’t change credentials"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Viewers can use it in their own agents",
|
||||
"off": "Viewers can’t use it in their own agents"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Viewers can duplicate it",
|
||||
"off": "Viewers can’t duplicate it"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors_one": "{{level}} · +{{formatted}} editor",
|
||||
"badgeWithEditors_other": "{{level}} · +{{formatted}} editors",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Filter shared resources",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"agent": "Agents",
|
||||
"source": "Sources",
|
||||
"tool": "Tools",
|
||||
"prompt": "Prompts"
|
||||
},
|
||||
"search": "Search shared…",
|
||||
"noMatches": "Nothing matches."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Close",
|
||||
"subtitle": "{{type}} · owned by {{owner}}",
|
||||
"open": "Open {{type}}",
|
||||
"manageSharing": "Manage sharing",
|
||||
"owner": "Owner",
|
||||
"shared": "Shared",
|
||||
"sharedOnBy": "{{date}} by {{name}}",
|
||||
"yourAccess": "Your access",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Owner",
|
||||
"editor": "Editor",
|
||||
"viewer": "Viewer",
|
||||
"none": "No access"
|
||||
},
|
||||
"accessIn": "Access in {{team}}",
|
||||
"everyone": "Everyone in {{team}}",
|
||||
"teamGrant": "Whole team",
|
||||
"memberGrant": "Only this person",
|
||||
"removeGrant": "Remove access",
|
||||
"otherTeamsHint": "Shared with other teams too? Those grants are managed in “Manage sharing”.",
|
||||
"whatPeopleCanDo": "What people here can do",
|
||||
"capabilitiesHint": "From the owner’s access settings. Read-only here."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -809,7 +1005,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "Reconnect Server",
|
||||
"reenterCredentials": "Re-enter your credentials to test and update the connection.",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -854,7 +1049,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Shared by {{owner}} · you're an editor",
|
||||
"aTeammate": "a teammate",
|
||||
"sharedCredentialsNotice": "Saved credentials stay hidden. Anything you enter replaces them for everyone who uses this tool.",
|
||||
"serverChangedNotice": "The server changed, so the saved {{credential}} will be cleared. Enter it for the new server to save.",
|
||||
"credentialNames": {
|
||||
"apiKey": "API key",
|
||||
"bearer": "token",
|
||||
"password": "password"
|
||||
},
|
||||
"savedKeyHint": "A key is saved. Leave empty to keep it (only while the server is unchanged).",
|
||||
"sharedOAuthOwnerOnly": "Only the owner can change this server's connection or reconnect its sign-in. You can still rename it and edit its actions."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}} is required",
|
||||
@@ -862,7 +1068,14 @@
|
||||
"maxTimeout": "Maximum timeout is 300 seconds"
|
||||
},
|
||||
"headerValuePlaceholder": "e.g., application/json",
|
||||
"toolIconTitle": "{{name}} icon"
|
||||
"toolIconTitle": "{{name}} icon",
|
||||
"view": "View",
|
||||
"inMyChats": "In my chats",
|
||||
"useInMyChatsAria": "Use {{toolName}} in my chats",
|
||||
"statusUpdateFailed": "Couldn't change whether this tool is in your chats.",
|
||||
"deleteFailed": "Couldn't delete this tool.",
|
||||
"sharedBy": "Shared by {{team}}",
|
||||
"savedSecretPlaceholder": "Saved · enter a new value to replace"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Devices",
|
||||
@@ -1359,7 +1572,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "Reset key",
|
||||
"resetKeyConfirm": "Are you sure you want to reset the API key? The current key will stop working immediately and this action cannot be undone."
|
||||
"resetKeyConfirm": "Are you sure you want to reset the API key? The current key will stop working immediately and this action cannot be undone.",
|
||||
"actionFailed": "That didn't work. Please try again.",
|
||||
"apiKeyAfterPublish": "Publish the agent to create its API key."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Import API Specification",
|
||||
@@ -1587,6 +1802,7 @@
|
||||
"agents": {
|
||||
"title": "Agents",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"card": {
|
||||
"pin": "Pin agent",
|
||||
"unpin": "Unpin agent",
|
||||
@@ -1625,10 +1841,6 @@
|
||||
"team": "Team",
|
||||
"shared": "Discovered"
|
||||
},
|
||||
"teamBadge": {
|
||||
"editor": "Editor",
|
||||
"viewer": "Viewer"
|
||||
},
|
||||
"sections": {
|
||||
"template": {
|
||||
"title": "Templates",
|
||||
@@ -1792,7 +2004,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1817,6 +2028,13 @@
|
||||
},
|
||||
"status": {
|
||||
"published": "Published"
|
||||
},
|
||||
"sponsors": {
|
||||
"attachNote": "Tools, sources and prompts you add from your own library run with your access for everyone who uses this agent.",
|
||||
"publicLinkNote": "This agent has a public link, so anyone with the link can get answers from them.",
|
||||
"addedBy": "Added by {{person}}: {{names}}",
|
||||
"unavailable": "Not running: {{names}}. {{person}} added them and no longer has access. Remove them or choose ones the owner can use.",
|
||||
"unknownItem": "Unnamed item"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -2266,7 +2484,8 @@
|
||||
"classicDescription": "Create a standard AI agent with a single model, tools, and knowledge sources",
|
||||
"workflowTitle": "Workflow Agent",
|
||||
"workflowDescription": "Design complex multi-step workflows with different models, conditional logic, and state management"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "Could not delete the agent. Please try again."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+236
-17
@@ -10,6 +10,12 @@
|
||||
"loading": "Cargando...",
|
||||
"retry": "Reintentar",
|
||||
"cancel": "Cancelar",
|
||||
"common": {
|
||||
"close": "Cerrar",
|
||||
"viewOnlyNotice": "Puedes ver esto, pero tu rol no puede cambiarlo.",
|
||||
"credentialsLockedNotice": "Solo el propietario puede cambiar las credenciales de esta herramienta.",
|
||||
"savedSecretHint": "Guardado. Déjalo vacío para conservarlo."
|
||||
},
|
||||
"help": "Asistencia",
|
||||
"emailUs": "Envíanos un correo",
|
||||
"documentation": "Documentación",
|
||||
@@ -92,7 +98,10 @@
|
||||
"edit": "Editar prompt",
|
||||
"view": "Ver prompt",
|
||||
"duplicate": "Duplicar prompt",
|
||||
"delete": "Eliminar prompt"
|
||||
"delete": "Eliminar prompt",
|
||||
"deleteFailed": "No se pudo eliminar este prompt.",
|
||||
"saveFailed": "No se pudo guardar este prompt.",
|
||||
"editConflict": "Otra persona cambió este prompt. Vuelve a abrirlo para ver su versión."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -393,7 +402,6 @@
|
||||
"subtitle": "Configura cómo se fragmenta y recupera \"{{name}}\".",
|
||||
"subtitleGeneric": "Configura cómo se fragmenta y recupera esta fuente.",
|
||||
"save": "Guardar",
|
||||
"readOnly": "Tienes acceso de solo lectura a esta fuente compartida y no puedes cambiar su configuración.",
|
||||
"chunkingChangeHint": "Cambiaste un ajuste de fragmentación. Al guardar será necesario reindexar esta fuente para que surta efecto.",
|
||||
"prescreenInvalidHint": "Revisa los valores de preselección: los candidatos a obtener deben ser al menos el número de fragmentos, y los fragmentos a conservar no deben superar los candidatos a obtener.",
|
||||
"reingestRequired": "Tus cambios se guardaron. Los ajustes de fragmentación que cambiaste solo surten efecto tras reindexar. ¿Reindexar ahora?",
|
||||
@@ -437,7 +445,15 @@
|
||||
"editChunk": "Editar fragmento",
|
||||
"editChunkDescription": "{{file}} · fragmento {{n}} · {{tokens}} tokens",
|
||||
"previousChunk": "Fragmento anterior",
|
||||
"nextChunk": "Fragmento siguiente"
|
||||
"nextChunk": "Fragmento siguiente",
|
||||
"viewConfig": "Ver ajustes de la fuente",
|
||||
"errors": {
|
||||
"forbidden": "No tienes permiso para hacer eso en esta fuente.",
|
||||
"delete": "No se pudo eliminar la fuente.",
|
||||
"sync": "No se pudo sincronizar la fuente.",
|
||||
"syncFrequency": "No se pudo cambiar la frecuencia de sincronización.",
|
||||
"reingest": "No se pudo iniciar la reingesta."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analítica",
|
||||
@@ -660,9 +676,10 @@
|
||||
"createTeamError": "No se pudo crear el equipo.",
|
||||
"noTeams": "Aún no hay equipos.",
|
||||
"noDescription": "Sin descripción",
|
||||
"memberCountOne": "{{count}} miembro",
|
||||
"memberCountOther": "{{count}} miembros",
|
||||
"sharedCount": "{{count}} compartidos",
|
||||
"memberCount_one": "{{formatted}} miembro",
|
||||
"memberCount_other": "{{formatted}} miembros",
|
||||
"sharedCount_one": "{{formatted}} compartido",
|
||||
"sharedCount_other": "{{formatted}} compartidos",
|
||||
"loadError": "No se pudieron cargar los equipos.",
|
||||
"roleAdmin": "admin",
|
||||
"roleMember": "miembro",
|
||||
@@ -729,7 +746,186 @@
|
||||
"teamLabel": "Equipo",
|
||||
"viaTeam": "vía {{team}}",
|
||||
"removeAccess": "Quitar acceso",
|
||||
"access": "Acceso"
|
||||
"access": "Acceso",
|
||||
"showAll": "Ver los {{formatted}}",
|
||||
"andMore_one": "y {{formatted}} más",
|
||||
"andMore_other": "y {{formatted}} más",
|
||||
"back": "Atrás",
|
||||
"allSummary": "{{name}} · Equipos: {{teams}} · Personas: {{people}}",
|
||||
"searchAccess": "Buscar personas y equipos…",
|
||||
"filterLabel": "Filtrar personas con acceso",
|
||||
"filter": {
|
||||
"all": "Todos",
|
||||
"teams": "Equipos",
|
||||
"people": "Personas",
|
||||
"editors": "Editores"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "No se pudo cambiar el acceso.",
|
||||
"accessSettings": {
|
||||
"title": "Ajustes de acceso",
|
||||
"saveError": "No se pudo guardar el ajuste de acceso.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Los editores pueden eliminar",
|
||||
"description": "Eliminar el agente para todos."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Los editores pueden gestionar los datos de acceso",
|
||||
"description": "Clave de API, webhook y enlace público."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Los lectores pueden ver los registros",
|
||||
"description": "Conversaciones y analíticas de este agente."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Los editores pueden eliminar",
|
||||
"description": "Eliminar la fuente para todos."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Los lectores pueden ver los ajustes",
|
||||
"description": "Ajustes de fragmentación, recuperador y sincronización, solo lectura."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Los editores pueden cambiar credenciales y conexión",
|
||||
"description": "Sustituyen los secretos guardados; nadie puede volver a leerlos."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Los lectores pueden usarla en sus propios agentes",
|
||||
"description": "Se ejecuta con tus credenciales."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Los lectores pueden duplicarlo",
|
||||
"description": "Hacer su propia copia para editarla."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Los editores pueden cambiarlo, incluidos los registros, las programaciones y los datos de acceso.",
|
||||
"agentNoAccessDetails": "Los editores pueden cambiarlo, incluidos los registros y las programaciones, pero no los datos de acceso.",
|
||||
"source": "Los editores pueden editar fragmentos y archivos, sincronizar y cambiar ajustes.",
|
||||
"tool": "Los editores pueden cambiar acciones y sustituir credenciales, pero no pueden leer secretos.",
|
||||
"toolNoCredentials": "Los editores pueden cambiar acciones, pero no las credenciales.",
|
||||
"prompt": "Los editores pueden cambiar el texto.",
|
||||
"noShareNoDelete": "No pueden compartirlo ni eliminarlo.",
|
||||
"shareOnly": "También pueden compartirlo, pero no eliminarlo.",
|
||||
"deleteOnly": "También pueden eliminarlo, pero no compartirlo.",
|
||||
"shareAndDelete": "También pueden compartirlo y eliminarlo.",
|
||||
"noShare": "No pueden compartirlo ni eliminarlo.",
|
||||
"share": "También pueden compartirlo, pero no eliminarlo."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Lectores: chatear con él y fijarlo",
|
||||
"editors": "Editores: editarlo, publicarlo, ver registros y gestionar programaciones"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Lectores: explorar, buscar y usarla en sus agentes",
|
||||
"editors": "Editores: editar fragmentos y archivos, sincronizar, cambiar ajustes"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Lectores: usarla dentro de los agentes del propietario",
|
||||
"editors": "Editores: cambiar acciones y aprobaciones"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Lectores: leerlo y usarlo en sus agentes",
|
||||
"editors": "Editores: cambiar el texto"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Los editores pueden compartirlo",
|
||||
"off": "Los editores no pueden compartirlo"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Los editores pueden eliminarlo",
|
||||
"off": "Los editores no pueden eliminarlo"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Los editores pueden gestionar la clave de API, el webhook y el enlace público",
|
||||
"off": "Los editores no pueden gestionar la clave de API, el webhook ni el enlace público"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Los lectores pueden ver los registros",
|
||||
"off": "Los lectores no pueden ver los registros"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Los lectores pueden ver sus ajustes",
|
||||
"off": "Los lectores no pueden ver sus ajustes"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Los editores pueden sustituir credenciales",
|
||||
"off": "Los editores no pueden cambiar credenciales"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Los lectores pueden usarla en sus propios agentes",
|
||||
"off": "Los lectores no pueden usarla en sus propios agentes"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Los lectores pueden duplicarlo",
|
||||
"off": "Los lectores no pueden duplicarlo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors_one": "{{level}} · +{{formatted}} editor",
|
||||
"badgeWithEditors_other": "{{level}} · +{{formatted}} editores",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Filtrar recursos compartidos",
|
||||
"filter": {
|
||||
"all": "Todos",
|
||||
"agent": "Agentes",
|
||||
"source": "Fuentes",
|
||||
"tool": "Herramientas",
|
||||
"prompt": "Prompts"
|
||||
},
|
||||
"search": "Buscar compartidos…",
|
||||
"noMatches": "No hay coincidencias."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Cerrar",
|
||||
"subtitle": "{{type}} · propiedad de {{owner}}",
|
||||
"open": "Abrir {{type}}",
|
||||
"manageSharing": "Gestionar uso compartido",
|
||||
"owner": "Propietario",
|
||||
"shared": "Compartido",
|
||||
"sharedOnBy": "{{date}} por {{name}}",
|
||||
"yourAccess": "Tu acceso",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Propietario",
|
||||
"editor": "Editor",
|
||||
"viewer": "Lector",
|
||||
"none": "Sin acceso"
|
||||
},
|
||||
"accessIn": "Acceso en {{team}}",
|
||||
"everyone": "Todos en {{team}}",
|
||||
"teamGrant": "Todo el equipo",
|
||||
"memberGrant": "Solo esta persona",
|
||||
"removeGrant": "Quitar acceso",
|
||||
"otherTeamsHint": "¿También compartido con otros equipos? Esos accesos se gestionan en «Gestionar uso compartido».",
|
||||
"whatPeopleCanDo": "Qué pueden hacer aquí",
|
||||
"capabilitiesHint": "Según los ajustes de acceso del propietario. Solo lectura aquí."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -803,7 +999,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "Reconectar servidor",
|
||||
"reenterCredentials": "Vuelve a introducir tus credenciales para probar y actualizar la conexión.",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -848,7 +1043,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Compartido por {{owner}} · eres editor",
|
||||
"aTeammate": "un compañero",
|
||||
"sharedCredentialsNotice": "Las credenciales guardadas permanecen ocultas. Lo que introduzcas las reemplaza para todos los que usan esta herramienta.",
|
||||
"serverChangedNotice": "El servidor cambió, así que se borrarán las credenciales guardadas ({{credential}}). Introdúcelas para el nuevo servidor para guardar.",
|
||||
"credentialNames": {
|
||||
"apiKey": "clave de API",
|
||||
"bearer": "token",
|
||||
"password": "contraseña"
|
||||
},
|
||||
"savedKeyHint": "Hay una clave guardada. Déjalo vacío para conservarla (solo mientras el servidor no cambie).",
|
||||
"sharedOAuthOwnerOnly": "Solo el propietario puede cambiar la conexión de este servidor o volver a conectar su inicio de sesión. Aún puedes cambiarle el nombre y editar sus acciones."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}} es obligatorio",
|
||||
@@ -856,7 +1062,14 @@
|
||||
"maxTimeout": "El tiempo de espera máximo es de 300 segundos"
|
||||
},
|
||||
"headerValuePlaceholder": "p. ej., application/json",
|
||||
"toolIconTitle": "Icono de {{name}}"
|
||||
"toolIconTitle": "Icono de {{name}}",
|
||||
"view": "Ver",
|
||||
"inMyChats": "En mis chats",
|
||||
"useInMyChatsAria": "Usar {{toolName}} en mis chats",
|
||||
"statusUpdateFailed": "No se pudo cambiar si esta herramienta está en tus chats.",
|
||||
"deleteFailed": "No se pudo eliminar esta herramienta.",
|
||||
"sharedBy": "Compartido por {{team}}",
|
||||
"savedSecretPlaceholder": "Guardado · introduce un valor nuevo para reemplazarlo"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Dispositivos",
|
||||
@@ -1353,7 +1566,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "Restablecer clave",
|
||||
"resetKeyConfirm": "¿Seguro que quieres restablecer la clave de API? La clave actual dejará de funcionar de inmediato y esta acción no se puede deshacer."
|
||||
"resetKeyConfirm": "¿Seguro que quieres restablecer la clave de API? La clave actual dejará de funcionar de inmediato y esta acción no se puede deshacer.",
|
||||
"actionFailed": "No funcionó. Inténtalo de nuevo.",
|
||||
"apiKeyAfterPublish": "Publica el agente para crear su clave de API."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Importar especificación de API",
|
||||
@@ -1571,6 +1786,7 @@
|
||||
"agents": {
|
||||
"title": "Agentes",
|
||||
"edit": "Editar",
|
||||
"view": "Ver",
|
||||
"card": {
|
||||
"pin": "Fijar agente",
|
||||
"unpin": "Dejar de fijar agente",
|
||||
@@ -1609,10 +1825,6 @@
|
||||
"team": "Equipo",
|
||||
"shared": "Compartidos conmigo"
|
||||
},
|
||||
"teamBadge": {
|
||||
"editor": "Editor",
|
||||
"viewer": "Lector"
|
||||
},
|
||||
"sections": {
|
||||
"template": {
|
||||
"title": "Por DocsGPT",
|
||||
@@ -1774,7 +1986,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1799,6 +2010,13 @@
|
||||
},
|
||||
"status": {
|
||||
"published": "Publicado"
|
||||
},
|
||||
"sponsors": {
|
||||
"attachNote": "Las herramientas, fuentes y prompts que añadas desde tu propia biblioteca se ejecutan con tu acceso para todos los que usen este agente.",
|
||||
"publicLinkNote": "Este agente tiene un enlace público, así que cualquiera con el enlace puede obtener respuestas de ellos.",
|
||||
"addedBy": "Añadido por {{person}}: {{names}}",
|
||||
"unavailable": "Sin ejecutarse: {{names}}. {{person}} los añadió y ya no tiene acceso. Quítalos o elige otros que el propietario pueda usar.",
|
||||
"unknownItem": "Elemento sin nombre"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -2234,7 +2452,8 @@
|
||||
"classicDescription": "Crea un agente de IA estándar con un solo modelo, herramientas y fuentes de conocimiento",
|
||||
"workflowTitle": "Agente de flujo de trabajo",
|
||||
"workflowDescription": "Diseña flujos de trabajo complejos de varios pasos con distintos modelos, lógica condicional y gestión de estado"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "No se pudo eliminar el agente. Inténtalo de nuevo."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+245
-17
@@ -10,6 +10,12 @@
|
||||
"loading": "読み込み中...",
|
||||
"retry": "再試行",
|
||||
"cancel": "キャンセル",
|
||||
"common": {
|
||||
"close": "閉じる",
|
||||
"viewOnlyNotice": "表示はできますが、あなたのロールでは変更できません。",
|
||||
"credentialsLockedNotice": "このツールの認証情報を変更できるのはオーナーだけです。",
|
||||
"savedSecretHint": "保存済みです。空のままにすると保持されます。"
|
||||
},
|
||||
"help": "ヘルプ",
|
||||
"emailUs": "メールを送る",
|
||||
"documentation": "ドキュメント",
|
||||
@@ -92,7 +98,10 @@
|
||||
"edit": "プロンプトを編集",
|
||||
"view": "プロンプトを表示",
|
||||
"duplicate": "プロンプトを複製",
|
||||
"delete": "プロンプトを削除"
|
||||
"delete": "プロンプトを削除",
|
||||
"deleteFailed": "このプロンプトを削除できませんでした。",
|
||||
"saveFailed": "このプロンプトを保存できませんでした。",
|
||||
"editConflict": "他のユーザーがこのプロンプトを変更しました。開き直して最新の内容を確認してください。"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -167,6 +176,7 @@
|
||||
}
|
||||
},
|
||||
"badge": "リビングWiki",
|
||||
"byline_one": "{{pages}} ページ · {{tokens}} トークン",
|
||||
"byline_other": "{{pages}} ページ · {{tokens}} トークン",
|
||||
"explainer": "エージェントは作業しながらこれらのページを読み、書き換えます。",
|
||||
"explainerEditable": "エージェントは作業しながらこれらのページを読み、書き換えます。このソースを使うエージェントは誰でも編集できます。",
|
||||
@@ -225,6 +235,7 @@
|
||||
"byConnections": "(接続数順)",
|
||||
"typeFilter": "種類で絞り込む",
|
||||
"otherTypes": "その他: {{types}}",
|
||||
"otherTypesMore_one": "他{{formatted}}件",
|
||||
"otherTypesMore_other": "他{{formatted}}件",
|
||||
"untyped": "種類なし",
|
||||
"loadFailed": "グラフを読み込めませんでした。",
|
||||
@@ -236,6 +247,7 @@
|
||||
"showMore": "もっと見る",
|
||||
"showLess": "表示を減らす",
|
||||
"relationships": "関係",
|
||||
"relationshipsCapped_one": "{{total}}件の関係のうち、強い順に{{shown}}件を表示しています。",
|
||||
"relationshipsCapped_other": "{{total}}件の関係のうち、強い順に{{shown}}件を表示しています。",
|
||||
"noRelationships": "このエンティティには関係がありません。",
|
||||
"relatedTo": "関連",
|
||||
@@ -244,8 +256,11 @@
|
||||
"sourceChunks": "ソースチャンク",
|
||||
"showInGraph": "グラフで表示",
|
||||
"allTypes": "すべての種類",
|
||||
"chunkCount_one": "{{formatted}}件のチャンク",
|
||||
"chunkCount_other": "{{formatted}}件のチャンク",
|
||||
"entityCount_one": "{{formatted}}件のエンティティ",
|
||||
"entityCount_other": "{{formatted}}件のエンティティ",
|
||||
"relationshipCount_one": "{{formatted}}件の関係",
|
||||
"relationshipCount_other": "{{formatted}}件の関係",
|
||||
"chunk": "チャンク",
|
||||
"chunkMeta": "{{file}} · {{tokens}} トークン",
|
||||
@@ -386,7 +401,6 @@
|
||||
"subtitle": "「{{name}}」のチャンク分割と検索の方法を設定します。",
|
||||
"subtitleGeneric": "このソースのチャンク分割と検索の方法を設定します。",
|
||||
"save": "保存",
|
||||
"readOnly": "この共有ソースには閲覧のみのアクセス権があるため、設定を変更できません。",
|
||||
"chunkingChangeHint": "チャンク分割の設定を変更しました。保存後、反映にはこのソースの再インデックスが必要です。",
|
||||
"prescreenInvalidHint": "プリスクリーニングの値を確認してください。取得する候補数はチャンク数以上、保持するチャンク数は取得する候補数以下である必要があります。",
|
||||
"reingestRequired": "変更を保存しました。変更したチャンク分割の設定は再インデックス後にのみ反映されます。今すぐ再インデックスしますか?",
|
||||
@@ -412,9 +426,11 @@
|
||||
"chunkTitleHint": "回答がこのチャンクを引用するときの名前です。",
|
||||
"files": "ファイル",
|
||||
"filesLoadError": "ファイルを読み込めませんでした",
|
||||
"filesByline_one": "{{files}} ファイル · {{tokens}} トークン",
|
||||
"filesByline_other": "{{files}} ファイル · {{tokens}} トークン",
|
||||
"filterFiles": "ファイルを絞り込む",
|
||||
"searchChunks": "チャンクを検索",
|
||||
"chunkCount_one": "{{formatted}} 件のチャンク",
|
||||
"chunkCount_other": "{{formatted}} 件のチャンク",
|
||||
"editor": {
|
||||
"write": "編集",
|
||||
@@ -428,7 +444,15 @@
|
||||
"editChunk": "チャンクを編集",
|
||||
"editChunkDescription": "{{file}} · チャンク {{n}} · {{tokens}} トークン",
|
||||
"previousChunk": "前のチャンク",
|
||||
"nextChunk": "次のチャンク"
|
||||
"nextChunk": "次のチャンク",
|
||||
"viewConfig": "ソース設定を表示",
|
||||
"errors": {
|
||||
"forbidden": "このソースでその操作を行う権限がありません。",
|
||||
"delete": "ソースを削除できませんでした。",
|
||||
"sync": "ソースを同期できませんでした。",
|
||||
"syncFrequency": "同期頻度を変更できませんでした。",
|
||||
"reingest": "再取り込みを開始できませんでした。"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
@@ -651,9 +675,10 @@
|
||||
"createTeamError": "チームを作成できませんでした。",
|
||||
"noTeams": "チームはまだありません。",
|
||||
"noDescription": "説明なし",
|
||||
"memberCountOne": "{{count}}人のメンバー",
|
||||
"memberCountOther": "{{count}}人のメンバー",
|
||||
"sharedCount": "{{count}}件の共有",
|
||||
"memberCount_one": "{{formatted}}人のメンバー",
|
||||
"memberCount_other": "{{formatted}}人のメンバー",
|
||||
"sharedCount_one": "{{formatted}}件の共有",
|
||||
"sharedCount_other": "{{formatted}}件の共有",
|
||||
"loadError": "チームの読み込みに失敗しました。",
|
||||
"roleAdmin": "管理者",
|
||||
"roleMember": "メンバー",
|
||||
@@ -720,7 +745,186 @@
|
||||
"teamLabel": "チーム",
|
||||
"viaTeam": "{{team}}経由",
|
||||
"removeAccess": "アクセス権を削除",
|
||||
"access": "アクセス"
|
||||
"access": "アクセス",
|
||||
"showAll": "すべて表示({{formatted}})",
|
||||
"andMore_one": "ほか {{formatted}} 件",
|
||||
"andMore_other": "ほか {{formatted}} 件",
|
||||
"back": "戻る",
|
||||
"allSummary": "{{name}} · チーム: {{teams}} · ユーザー: {{people}}",
|
||||
"searchAccess": "ユーザーとチームを検索…",
|
||||
"filterLabel": "アクセス権を持つユーザーを絞り込む",
|
||||
"filter": {
|
||||
"all": "すべて",
|
||||
"teams": "チーム",
|
||||
"people": "ユーザー",
|
||||
"editors": "編集者"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "アクセス権を変更できませんでした。",
|
||||
"accessSettings": {
|
||||
"title": "アクセス設定",
|
||||
"saveError": "アクセス設定を保存できませんでした。",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編集者が削除できる",
|
||||
"description": "全員に対してエージェントを削除します。"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "編集者がアクセス情報を管理できる",
|
||||
"description": "API キー、Webhook、公開リンク。"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "閲覧者がログを見られる",
|
||||
"description": "このエージェントの会話と分析。"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編集者が削除できる",
|
||||
"description": "全員に対してソースを削除します。"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "閲覧者が設定を見られる",
|
||||
"description": "チャンク分割、リトリーバー、同期の設定(読み取り専用)。"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "編集者が認証情報と接続を変更できる",
|
||||
"description": "保存済みのシークレットを置き換えます。誰も読み戻せません。"
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "閲覧者が自分のエージェントで使える",
|
||||
"description": "あなたの認証情報で実行されます。"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "閲覧者が複製できる",
|
||||
"description": "編集用に自分のコピーを作成します。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "編集者はログ、スケジュール、アクセス情報を含めて変更できます。",
|
||||
"agentNoAccessDetails": "編集者はログとスケジュールを含めて変更できますが、アクセス情報は変更できません。",
|
||||
"source": "編集者はチャンクとファイルの編集、同期、設定の変更ができます。",
|
||||
"tool": "編集者はアクションの変更と認証情報の置き換えができますが、シークレットは読めません。",
|
||||
"toolNoCredentials": "編集者はアクションを変更できますが、認証情報は変更できません。",
|
||||
"prompt": "編集者はテキストを変更できます。",
|
||||
"noShareNoDelete": "共有と削除はできません。",
|
||||
"shareOnly": "共有もできますが、削除はできません。",
|
||||
"deleteOnly": "削除もできますが、共有はできません。",
|
||||
"shareAndDelete": "共有と削除もできます。",
|
||||
"noShare": "共有と削除はできません。",
|
||||
"share": "共有もできますが、削除はできません。"
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "閲覧者:チャットとピン留め",
|
||||
"editors": "編集者:編集、公開、ログの閲覧、スケジュールの管理"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "閲覧者:閲覧、検索、自分のエージェントでの利用",
|
||||
"editors": "編集者:チャンクとファイルの編集、同期、設定の変更"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "閲覧者:所有者のエージェント内で利用",
|
||||
"editors": "編集者:アクションと承認の変更"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "閲覧者:閲覧と自分のエージェントでの利用",
|
||||
"editors": "編集者:テキストの変更"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "編集者は共有できます",
|
||||
"off": "編集者は共有できません"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "編集者は削除できます",
|
||||
"off": "編集者は削除できません"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "編集者は API キー、Webhook、公開リンクを管理できます",
|
||||
"off": "編集者は API キー、Webhook、公開リンクを管理できません"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "閲覧者はログを見られます",
|
||||
"off": "閲覧者はログを見られません"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "閲覧者は設定を見られます",
|
||||
"off": "閲覧者は設定を見られません"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "編集者は認証情報を置き換えられます",
|
||||
"off": "編集者は認証情報を変更できません"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "閲覧者は自分のエージェントで使えます",
|
||||
"off": "閲覧者は自分のエージェントで使えません"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "閲覧者は複製できます",
|
||||
"off": "閲覧者は複製できません"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors_one": "{{level}} · +{{formatted}} 編集者",
|
||||
"badgeWithEditors_other": "{{level}} · +{{formatted}} 編集者",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "共有リソースを絞り込む",
|
||||
"filter": {
|
||||
"all": "すべて",
|
||||
"agent": "エージェント",
|
||||
"source": "ソース",
|
||||
"tool": "ツール",
|
||||
"prompt": "プロンプト"
|
||||
},
|
||||
"search": "共有を検索…",
|
||||
"noMatches": "一致するものはありません。"
|
||||
},
|
||||
"drawer": {
|
||||
"close": "閉じる",
|
||||
"subtitle": "{{type}} · 所有者: {{owner}}",
|
||||
"open": "{{type}}を開く",
|
||||
"manageSharing": "共有を管理",
|
||||
"owner": "所有者",
|
||||
"shared": "共有日",
|
||||
"sharedOnBy": "{{date}}({{name}})",
|
||||
"yourAccess": "あなたのアクセス権",
|
||||
"yourAccessLevel": {
|
||||
"owner": "所有者",
|
||||
"editor": "編集者",
|
||||
"viewer": "閲覧者",
|
||||
"none": "アクセス権なし"
|
||||
},
|
||||
"accessIn": "{{team}} でのアクセス権",
|
||||
"everyone": "{{team}} の全員",
|
||||
"teamGrant": "チーム全体",
|
||||
"memberGrant": "この人のみ",
|
||||
"removeGrant": "アクセス権を削除",
|
||||
"otherTeamsHint": "ほかのチームとも共有していますか?それらは「共有を管理」で管理します。",
|
||||
"whatPeopleCanDo": "ここでできること",
|
||||
"capabilitiesHint": "所有者のアクセス設定に基づきます。ここでは読み取り専用です。"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -794,7 +998,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "サーバーに再接続",
|
||||
"reenterCredentials": "接続をテストして更新するには、認証情報を再入力してください。",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -839,7 +1042,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "{{owner}} が共有 · あなたは編集者です",
|
||||
"aTeammate": "チームメンバー",
|
||||
"sharedCredentialsNotice": "保存済みの認証情報は表示されません。入力した内容は、このツールを使うすべての人の認証情報を置き換えます。",
|
||||
"serverChangedNotice": "サーバーが変更されたため、保存済みの{{credential}}は消去されます。保存するには新しいサーバー用に入力してください。",
|
||||
"credentialNames": {
|
||||
"apiKey": "API キー",
|
||||
"bearer": "トークン",
|
||||
"password": "パスワード"
|
||||
},
|
||||
"savedKeyHint": "キーが保存されています。空のままにすると保持されます(サーバーが変わらない場合のみ)。",
|
||||
"sharedOAuthOwnerOnly": "このサーバーの接続を変更したり、サインインを再接続したりできるのはオーナーだけです。名前の変更とアクションの編集は引き続き行えます。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}は必須です",
|
||||
@@ -847,7 +1061,14 @@
|
||||
"maxTimeout": "タイムアウトの上限は300秒です"
|
||||
},
|
||||
"headerValuePlaceholder": "例: application/json",
|
||||
"toolIconTitle": "{{name}}のアイコン"
|
||||
"toolIconTitle": "{{name}}のアイコン",
|
||||
"view": "表示",
|
||||
"inMyChats": "自分のチャットで使用",
|
||||
"useInMyChatsAria": "{{toolName}} を自分のチャットで使用",
|
||||
"statusUpdateFailed": "このツールをチャットで使うかどうかを変更できませんでした。",
|
||||
"deleteFailed": "このツールを削除できませんでした。",
|
||||
"sharedBy": "{{team}} が共有",
|
||||
"savedSecretPlaceholder": "保存済み · 置き換えるには新しい値を入力"
|
||||
},
|
||||
"devices": {
|
||||
"label": "デバイス",
|
||||
@@ -1344,7 +1565,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "キーをリセット",
|
||||
"resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。"
|
||||
"resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。",
|
||||
"actionFailed": "うまくいきませんでした。もう一度お試しください。",
|
||||
"apiKeyAfterPublish": "APIキーを作成するには、エージェントを公開してください。"
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "API仕様のインポート",
|
||||
@@ -1559,6 +1782,7 @@
|
||||
"agents": {
|
||||
"title": "エージェント",
|
||||
"edit": "編集",
|
||||
"view": "表示",
|
||||
"card": {
|
||||
"pin": "エージェントをピン留め",
|
||||
"unpin": "エージェントのピン留めを解除",
|
||||
@@ -1597,10 +1821,6 @@
|
||||
"team": "チーム",
|
||||
"shared": "共有された"
|
||||
},
|
||||
"teamBadge": {
|
||||
"editor": "編集者",
|
||||
"viewer": "閲覧者"
|
||||
},
|
||||
"sections": {
|
||||
"template": {
|
||||
"title": "DocsGPT提供",
|
||||
@@ -1761,7 +1981,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1786,6 +2005,13 @@
|
||||
},
|
||||
"status": {
|
||||
"published": "公開済み"
|
||||
},
|
||||
"sponsors": {
|
||||
"attachNote": "自分のライブラリから追加したツール、ソース、プロンプトは、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。",
|
||||
"publicLinkNote": "このエージェントには公開リンクがあるため、リンクを知っている人は誰でもそれらから回答を得られます。",
|
||||
"addedBy": "{{person}} が追加: {{names}}",
|
||||
"unavailable": "実行されていません: {{names}}。追加した {{person}} はアクセス権を失いました。削除するか、オーナーが使用できるものを選んでください。",
|
||||
"unknownItem": "名前のない項目"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -2221,7 +2447,8 @@
|
||||
"classicDescription": "単一のモデル、ツール、ナレッジソースを持つ標準的な AI エージェントを作成します",
|
||||
"workflowTitle": "ワークフローエージェント",
|
||||
"workflowDescription": "複数のモデル、条件ロジック、状態管理を使った複雑なマルチステップのワークフローを設計します"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "エージェントを削除できませんでした。もう一度お試しください。"
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
@@ -2246,6 +2473,7 @@
|
||||
"empty": "ToDo はまだありません"
|
||||
},
|
||||
"note": {
|
||||
"lines_one": "{{count}} 行",
|
||||
"lines_other": "{{count}} 行",
|
||||
"empty": "空のメモ"
|
||||
},
|
||||
|
||||
+244
-17
@@ -10,6 +10,12 @@
|
||||
"loading": "Загрузка...",
|
||||
"retry": "Повторить",
|
||||
"cancel": "Отмена",
|
||||
"common": {
|
||||
"close": "Закрыть",
|
||||
"viewOnlyNotice": "Вы можете это просматривать, но ваша роль не позволяет это изменить.",
|
||||
"credentialsLockedNotice": "Только владелец может изменить учётные данные этого инструмента.",
|
||||
"savedSecretHint": "Сохранено. Оставьте поле пустым, чтобы сохранить значение."
|
||||
},
|
||||
"help": "Помощь",
|
||||
"emailUs": "Напишите нам",
|
||||
"documentation": "Документация",
|
||||
@@ -92,7 +98,10 @@
|
||||
"edit": "Редактировать промпт",
|
||||
"view": "Просмотреть промпт",
|
||||
"duplicate": "Дублировать промпт",
|
||||
"delete": "Удалить промпт"
|
||||
"delete": "Удалить промпт",
|
||||
"deleteFailed": "Не удалось удалить этот промпт.",
|
||||
"saveFailed": "Не удалось сохранить этот промпт.",
|
||||
"editConflict": "Кто-то другой изменил этот промпт. Откройте его заново, чтобы увидеть новую версию."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -417,7 +426,6 @@
|
||||
"subtitle": "Настройте, как «{{name}}» разбивается на фрагменты и извлекается.",
|
||||
"subtitleGeneric": "Настройте, как этот источник разбивается на фрагменты и извлекается.",
|
||||
"save": "Сохранить",
|
||||
"readOnly": "У вас доступ только для просмотра этого общего источника, вы не можете изменять его настройки.",
|
||||
"chunkingChangeHint": "Вы изменили настройку разбиения на фрагменты. После сохранения потребуется переиндексация источника, чтобы изменения вступили в силу.",
|
||||
"prescreenInvalidHint": "Проверьте значения предварительного отбора: число кандидатов должно быть не меньше числа фрагментов, а число оставляемых фрагментов не должно превышать число кандидатов.",
|
||||
"reingestRequired": "Изменения сохранены. Изменённые настройки разбиения вступят в силу только после переиндексации. Переиндексировать сейчас?",
|
||||
@@ -465,7 +473,15 @@
|
||||
"editChunk": "Редактировать фрагмент",
|
||||
"editChunkDescription": "{{file}} · фрагмент {{n}} · токенов: {{tokens}}",
|
||||
"previousChunk": "Предыдущий фрагмент",
|
||||
"nextChunk": "Следующий фрагмент"
|
||||
"nextChunk": "Следующий фрагмент",
|
||||
"viewConfig": "Посмотреть настройки источника",
|
||||
"errors": {
|
||||
"forbidden": "У вас нет прав на это действие с этим источником.",
|
||||
"delete": "Не удалось удалить источник.",
|
||||
"sync": "Не удалось синхронизировать источник.",
|
||||
"syncFrequency": "Не удалось изменить частоту синхронизации.",
|
||||
"reingest": "Не удалось запустить повторную загрузку."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Аналитика",
|
||||
@@ -702,9 +718,14 @@
|
||||
"createTeamError": "Не удалось создать команду.",
|
||||
"noTeams": "Команд пока нет.",
|
||||
"noDescription": "Без описания",
|
||||
"memberCountOne": "{{count}} участник",
|
||||
"memberCountOther": "Участников: {{count}}",
|
||||
"sharedCount": "Общих ресурсов: {{count}}",
|
||||
"memberCount_one": "{{formatted}} участник",
|
||||
"memberCount_few": "{{formatted}} участника",
|
||||
"memberCount_many": "{{formatted}} участников",
|
||||
"memberCount_other": "{{formatted}} участника",
|
||||
"sharedCount_one": "{{formatted}} общий ресурс",
|
||||
"sharedCount_few": "{{formatted}} общих ресурса",
|
||||
"sharedCount_many": "{{formatted}} общих ресурсов",
|
||||
"sharedCount_other": "{{formatted}} общих ресурса",
|
||||
"loadError": "Не удалось загрузить команды.",
|
||||
"roleAdmin": "администратор",
|
||||
"roleMember": "участник",
|
||||
@@ -771,7 +792,190 @@
|
||||
"teamLabel": "Команда",
|
||||
"viaTeam": "через {{team}}",
|
||||
"removeAccess": "Убрать доступ",
|
||||
"access": "Доступ"
|
||||
"access": "Доступ",
|
||||
"showAll": "Показать все ({{formatted}})",
|
||||
"andMore_one": "и ещё {{formatted}}",
|
||||
"andMore_few": "и ещё {{formatted}}",
|
||||
"andMore_many": "и ещё {{formatted}}",
|
||||
"andMore_other": "и ещё {{formatted}}",
|
||||
"back": "Назад",
|
||||
"allSummary": "{{name}} · Команды: {{teams}} · Люди: {{people}}",
|
||||
"searchAccess": "Поиск людей и команд…",
|
||||
"filterLabel": "Фильтр пользователей с доступом",
|
||||
"filter": {
|
||||
"all": "Все",
|
||||
"teams": "Команды",
|
||||
"people": "Люди",
|
||||
"editors": "Редакторы"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "Не удалось изменить доступ.",
|
||||
"accessSettings": {
|
||||
"title": "Настройки доступа",
|
||||
"saveError": "Не удалось сохранить настройку доступа.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Редакторы могут удалять",
|
||||
"description": "Удалить агента для всех."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Редакторы управляют данными доступа",
|
||||
"description": "API-ключ, вебхук и публичная ссылка."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Читатели видят журналы",
|
||||
"description": "Диалоги и аналитика этого агента."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Редакторы могут удалять",
|
||||
"description": "Удалить источник для всех."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Читатели видят настройки",
|
||||
"description": "Настройки разбиения, ретривера и синхронизации, только чтение."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Редакторы могут менять учётные данные и подключение",
|
||||
"description": "Они заменяют сохранённые секреты; прочитать их не может никто."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Читатели могут использовать его в своих агентах",
|
||||
"description": "Он работает с вашими учётными данными."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Читатели могут создавать копию",
|
||||
"description": "Сделать свою копию для редактирования."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Редакторы могут менять его, включая журналы, расписания и данные доступа.",
|
||||
"agentNoAccessDetails": "Редакторы могут менять его, включая журналы и расписания, но не данные доступа.",
|
||||
"source": "Редакторы могут править фрагменты и файлы, синхронизировать и менять настройки.",
|
||||
"tool": "Редакторы могут менять действия и заменять учётные данные, но не читать секреты.",
|
||||
"toolNoCredentials": "Редакторы могут менять действия, но не учётные данные.",
|
||||
"prompt": "Редакторы могут менять текст.",
|
||||
"noShareNoDelete": "Делиться и удалять они не могут.",
|
||||
"shareOnly": "Они также могут делиться, но не удалять.",
|
||||
"deleteOnly": "Они также могут удалять, но не делиться.",
|
||||
"shareAndDelete": "Они также могут делиться и удалять.",
|
||||
"noShare": "Делиться и удалять они не могут.",
|
||||
"share": "Они также могут делиться, но не удалять."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Читатели: общаться с ним и закреплять его",
|
||||
"editors": "Редакторы: править, публиковать, смотреть журналы и управлять расписаниями"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Читатели: просматривать, искать и использовать в своих агентах",
|
||||
"editors": "Редакторы: править фрагменты и файлы, синхронизировать, менять настройки"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Читатели: использовать в агентах владельца",
|
||||
"editors": "Редакторы: менять действия и подтверждения"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Читатели: читать и использовать в своих агентах",
|
||||
"editors": "Редакторы: менять текст"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Редакторы могут делиться",
|
||||
"off": "Редакторы не могут делиться"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Редакторы могут удалять",
|
||||
"off": "Редакторы не могут удалять"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Редакторы управляют API-ключом, вебхуком и публичной ссылкой",
|
||||
"off": "Редакторы не управляют API-ключом, вебхуком и публичной ссылкой"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Читатели видят журналы",
|
||||
"off": "Читатели не видят журналы"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Читатели видят настройки",
|
||||
"off": "Читатели не видят настройки"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Редакторы могут заменять учётные данные",
|
||||
"off": "Редакторы не могут менять учётные данные"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Читатели могут использовать его в своих агентах",
|
||||
"off": "Читатели не могут использовать его в своих агентах"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Читатели могут создавать копию",
|
||||
"off": "Читатели не могут создавать копию"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors_one": "{{level}} · +{{formatted}} редактор",
|
||||
"badgeWithEditors_few": "{{level}} · +{{formatted}} редактора",
|
||||
"badgeWithEditors_many": "{{level}} · +{{formatted}} редакторов",
|
||||
"badgeWithEditors_other": "{{level}} · +{{formatted}} редактора",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Фильтр общих ресурсов",
|
||||
"filter": {
|
||||
"all": "Все",
|
||||
"agent": "Агенты",
|
||||
"source": "Источники",
|
||||
"tool": "Инструменты",
|
||||
"prompt": "Промпты"
|
||||
},
|
||||
"search": "Поиск в общих…",
|
||||
"noMatches": "Ничего не найдено."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Закрыть",
|
||||
"subtitle": "{{type}} · владелец: {{owner}}",
|
||||
"open": "Открыть: {{type}}",
|
||||
"manageSharing": "Управлять доступом",
|
||||
"owner": "Владелец",
|
||||
"shared": "Открыт",
|
||||
"sharedOnBy": "{{date}}, {{name}}",
|
||||
"yourAccess": "Ваш доступ",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Владелец",
|
||||
"editor": "Редактор",
|
||||
"viewer": "Читатель",
|
||||
"none": "Нет доступа"
|
||||
},
|
||||
"accessIn": "Доступ в {{team}}",
|
||||
"everyone": "Все в {{team}}",
|
||||
"teamGrant": "Вся команда",
|
||||
"memberGrant": "Только этот человек",
|
||||
"removeGrant": "Убрать доступ",
|
||||
"otherTeamsHint": "Доступ есть и у других команд? Им управляют в «Управлять доступом».",
|
||||
"whatPeopleCanDo": "Что здесь можно делать",
|
||||
"capabilitiesHint": "Из настроек доступа владельца. Здесь только для чтения."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -845,7 +1049,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "Переподключить сервер",
|
||||
"reenterCredentials": "Введите учетные данные ещё раз, чтобы проверить и обновить подключение.",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -890,7 +1093,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Предоставил(а) {{owner}} · вы редактор",
|
||||
"aTeammate": "участник команды",
|
||||
"sharedCredentialsNotice": "Сохранённые учётные данные скрыты. Введённые вами данные заменят их для всех, кто пользуется этим инструментом.",
|
||||
"serverChangedNotice": "Сервер изменился, поэтому сохранённый {{credential}} будет удалён. Введите его для нового сервера, чтобы сохранить.",
|
||||
"credentialNames": {
|
||||
"apiKey": "API-ключ",
|
||||
"bearer": "токен",
|
||||
"password": "пароль"
|
||||
},
|
||||
"savedKeyHint": "Ключ сохранён. Оставьте поле пустым, чтобы сохранить его (только пока сервер не изменился).",
|
||||
"sharedOAuthOwnerOnly": "Изменить подключение этого сервера или переподключить вход может только владелец. Вы по-прежнему можете переименовать его и изменять его действия."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "Поле «{{field}}» обязательно",
|
||||
@@ -898,7 +1112,14 @@
|
||||
"maxTimeout": "Максимальный тайм-аут — 300 секунд"
|
||||
},
|
||||
"headerValuePlaceholder": "например, application/json",
|
||||
"toolIconTitle": "Значок {{name}}"
|
||||
"toolIconTitle": "Значок {{name}}",
|
||||
"view": "Просмотр",
|
||||
"inMyChats": "В моих чатах",
|
||||
"useInMyChatsAria": "Использовать {{toolName}} в моих чатах",
|
||||
"statusUpdateFailed": "Не удалось изменить, используется ли этот инструмент в ваших чатах.",
|
||||
"deleteFailed": "Не удалось удалить этот инструмент.",
|
||||
"sharedBy": "Предоставлено: {{team}}",
|
||||
"savedSecretPlaceholder": "Сохранено · введите новое значение для замены"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Устройства",
|
||||
@@ -1409,7 +1630,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "Сбросить ключ",
|
||||
"resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить."
|
||||
"resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить.",
|
||||
"actionFailed": "Не получилось. Попробуйте ещё раз.",
|
||||
"apiKeyAfterPublish": "Опубликуйте агента, чтобы создать его API-ключ."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Импорт спецификации API",
|
||||
@@ -1633,6 +1856,7 @@
|
||||
"agents": {
|
||||
"title": "Агенты",
|
||||
"edit": "Редактировать",
|
||||
"view": "Просмотр",
|
||||
"card": {
|
||||
"pin": "Закрепить агента",
|
||||
"unpin": "Открепить агента",
|
||||
@@ -1671,10 +1895,6 @@
|
||||
"team": "Команда",
|
||||
"shared": "Поделились со мной"
|
||||
},
|
||||
"teamBadge": {
|
||||
"editor": "Редактор",
|
||||
"viewer": "Читатель"
|
||||
},
|
||||
"sections": {
|
||||
"template": {
|
||||
"title": "От DocsGPT",
|
||||
@@ -1838,7 +2058,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1863,6 +2082,13 @@
|
||||
},
|
||||
"status": {
|
||||
"published": "Опубликован"
|
||||
},
|
||||
"sponsors": {
|
||||
"attachNote": "Инструменты, источники и промпты, которые вы добавляете из своей библиотеки, работают с вашим доступом для всех, кто пользуется этим агентом.",
|
||||
"publicLinkNote": "У этого агента есть публичная ссылка, поэтому любой, у кого она есть, может получать ответы на их основе.",
|
||||
"addedBy": "Добавил(а) {{person}}: {{names}}",
|
||||
"unavailable": "Не работают: {{names}}. {{person}} добавил(а) их и больше не имеет доступа. Удалите их или выберите те, которые может использовать владелец.",
|
||||
"unknownItem": "Элемент без названия"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -2310,7 +2536,8 @@
|
||||
"classicDescription": "Создайте стандартного ИИ-агента с одной моделью, инструментами и источниками знаний",
|
||||
"workflowTitle": "Агент рабочего процесса",
|
||||
"workflowDescription": "Создавайте сложные многошаговые рабочие процессы с разными моделями, условной логикой и управлением состоянием"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "Не удалось удалить агента. Попробуйте ещё раз."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+245
-17
@@ -10,6 +10,12 @@
|
||||
"loading": "載入中...",
|
||||
"retry": "重試",
|
||||
"cancel": "取消",
|
||||
"common": {
|
||||
"close": "關閉",
|
||||
"viewOnlyNotice": "你可以檢視此內容,但你的角色無法變更它。",
|
||||
"credentialsLockedNotice": "只有擁有者可以變更此工具的憑證。",
|
||||
"savedSecretHint": "已儲存。留空即可保留。"
|
||||
},
|
||||
"help": "幫助",
|
||||
"emailUs": "給我們發電郵",
|
||||
"documentation": "文件",
|
||||
@@ -92,7 +98,10 @@
|
||||
"edit": "編輯提示詞",
|
||||
"view": "檢視提示詞",
|
||||
"duplicate": "複製提示詞",
|
||||
"delete": "刪除提示詞"
|
||||
"delete": "刪除提示詞",
|
||||
"deleteFailed": "無法刪除此提示詞。",
|
||||
"saveFailed": "無法儲存此提示詞。",
|
||||
"editConflict": "其他人已變更此提示詞。請重新開啟以查看其版本。"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -167,6 +176,7 @@
|
||||
}
|
||||
},
|
||||
"badge": "動態 Wiki",
|
||||
"byline_one": "{{pages}} 個頁面 · {{tokens}} 個 token",
|
||||
"byline_other": "{{pages}} 個頁面 · {{tokens}} 個 token",
|
||||
"explainer": "代理在工作時會閱讀並改寫這些頁面。",
|
||||
"explainerEditable": "代理在工作時會閱讀並改寫這些頁面;任何使用此來源的代理都可以編輯它。",
|
||||
@@ -225,6 +235,7 @@
|
||||
"byConnections": "(依連結數)",
|
||||
"typeFilter": "依類型篩選",
|
||||
"otherTypes": "其他:{{types}}",
|
||||
"otherTypesMore_one": "另外 {{formatted}} 個",
|
||||
"otherTypesMore_other": "另外 {{formatted}} 個",
|
||||
"untyped": "無類型",
|
||||
"loadFailed": "無法載入圖譜。",
|
||||
@@ -236,6 +247,7 @@
|
||||
"showMore": "顯示更多",
|
||||
"showLess": "收合",
|
||||
"relationships": "關係",
|
||||
"relationshipsCapped_one": "顯示 {{total}} 個關係中最強的 {{shown}} 個。",
|
||||
"relationshipsCapped_other": "顯示 {{total}} 個關係中最強的 {{shown}} 個。",
|
||||
"noRelationships": "此實體沒有關係。",
|
||||
"relatedTo": "相關",
|
||||
@@ -244,8 +256,11 @@
|
||||
"sourceChunks": "來源區塊",
|
||||
"showInGraph": "在圖譜中顯示",
|
||||
"allTypes": "所有類型",
|
||||
"chunkCount_one": "{{formatted}} 個區塊",
|
||||
"chunkCount_other": "{{formatted}} 個區塊",
|
||||
"entityCount_one": "{{formatted}} 個實體",
|
||||
"entityCount_other": "{{formatted}} 個實體",
|
||||
"relationshipCount_one": "{{formatted}} 個關係",
|
||||
"relationshipCount_other": "{{formatted}} 個關係",
|
||||
"chunk": "區塊",
|
||||
"chunkMeta": "{{file}} · {{tokens}} Token",
|
||||
@@ -386,7 +401,6 @@
|
||||
"subtitle": "設定「{{name}}」的分塊與檢索方式。",
|
||||
"subtitleGeneric": "設定此來源的分塊與檢索方式。",
|
||||
"save": "儲存",
|
||||
"readOnly": "您對此共享來源僅有檢視權限,無法變更其設定。",
|
||||
"chunkingChangeHint": "您變更了分塊設定。儲存後需重新索引此來源才會生效。",
|
||||
"prescreenInvalidHint": "請檢查預篩選數值:擷取候選數不得少於區塊數,且保留區塊數不得超過擷取候選數。",
|
||||
"reingestRequired": "變更已儲存。您變更的分塊設定需重新索引後才會生效。要立即重新索引嗎?",
|
||||
@@ -412,9 +426,11 @@
|
||||
"chunkTitleHint": "回答引用此文本塊時使用的名稱。",
|
||||
"files": "檔案",
|
||||
"filesLoadError": "無法載入檔案",
|
||||
"filesByline_one": "{{files}} 個檔案 · {{tokens}} Token",
|
||||
"filesByline_other": "{{files}} 個檔案 · {{tokens}} Token",
|
||||
"filterFiles": "篩選檔案",
|
||||
"searchChunks": "搜尋文本塊",
|
||||
"chunkCount_one": "{{formatted}} 個文本塊",
|
||||
"chunkCount_other": "{{formatted}} 個文本塊",
|
||||
"editor": {
|
||||
"write": "編輯",
|
||||
@@ -428,7 +444,15 @@
|
||||
"editChunk": "編輯文本塊",
|
||||
"editChunkDescription": "{{file}} · 第 {{n}} 個文本塊 · {{tokens}} Token",
|
||||
"previousChunk": "上一個文本塊",
|
||||
"nextChunk": "下一個文本塊"
|
||||
"nextChunk": "下一個文本塊",
|
||||
"viewConfig": "檢視來源設定",
|
||||
"errors": {
|
||||
"forbidden": "你沒有權限對此來源執行該操作。",
|
||||
"delete": "無法刪除來源。",
|
||||
"sync": "無法同步來源。",
|
||||
"syncFrequency": "無法變更同步頻率。",
|
||||
"reingest": "無法開始重新匯入。"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
@@ -651,9 +675,10 @@
|
||||
"createTeamError": "無法建立團隊。",
|
||||
"noTeams": "尚無團隊。",
|
||||
"noDescription": "無描述",
|
||||
"memberCountOne": "{{count}} 位成員",
|
||||
"memberCountOther": "{{count}} 位成員",
|
||||
"sharedCount": "已分享 {{count}} 項",
|
||||
"memberCount_one": "{{formatted}} 位成員",
|
||||
"memberCount_other": "{{formatted}} 位成員",
|
||||
"sharedCount_one": "已分享 {{formatted}} 項",
|
||||
"sharedCount_other": "已分享 {{formatted}} 項",
|
||||
"loadError": "載入團隊失敗。",
|
||||
"roleAdmin": "管理員",
|
||||
"roleMember": "成員",
|
||||
@@ -720,7 +745,186 @@
|
||||
"teamLabel": "團隊",
|
||||
"viaTeam": "透過 {{team}}",
|
||||
"removeAccess": "移除存取權",
|
||||
"access": "存取權"
|
||||
"access": "存取權",
|
||||
"showAll": "顯示全部 {{formatted}} 個",
|
||||
"andMore_one": "還有 {{formatted}} 個",
|
||||
"andMore_other": "還有 {{formatted}} 個",
|
||||
"back": "返回",
|
||||
"allSummary": "{{name}} · 團隊:{{teams}} · 人員:{{people}}",
|
||||
"searchAccess": "搜尋人員和團隊…",
|
||||
"filterLabel": "篩選具有存取權的人員",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"teams": "團隊",
|
||||
"people": "人員",
|
||||
"editors": "編輯者"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "無法變更存取權。",
|
||||
"accessSettings": {
|
||||
"title": "存取設定",
|
||||
"saveError": "無法儲存存取設定。",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編輯者可以刪除",
|
||||
"description": "為所有人刪除此代理。"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "編輯者可以管理存取詳細資料",
|
||||
"description": "API 金鑰、Webhook 和公開連結。"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "檢視者可以查看記錄",
|
||||
"description": "此代理的對話和分析。"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編輯者可以刪除",
|
||||
"description": "為所有人刪除此來源。"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "檢視者可以查看設定",
|
||||
"description": "分塊、檢索器和同步設定,唯讀。"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "編輯者可以變更憑證和連線",
|
||||
"description": "他們會取代已儲存的密鑰;任何人都無法讀回。"
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "檢視者可以在自己的代理中使用",
|
||||
"description": "它會以你的憑證執行。"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "檢視者可以複製",
|
||||
"description": "建立自己的副本來編輯。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "編輯者可以修改它,包括記錄、排程和存取詳細資料。",
|
||||
"agentNoAccessDetails": "編輯者可以修改它,包括記錄和排程,但不能修改存取詳細資料。",
|
||||
"source": "編輯者可以編輯分塊和檔案、同步並變更設定。",
|
||||
"tool": "編輯者可以變更動作並取代憑證,但無法讀取密鑰。",
|
||||
"toolNoCredentials": "編輯者可以變更動作,但不能變更憑證。",
|
||||
"prompt": "編輯者可以修改文字。",
|
||||
"noShareNoDelete": "他們不能共用或刪除它。",
|
||||
"shareOnly": "他們也可以共用它,但不能刪除。",
|
||||
"deleteOnly": "他們也可以刪除它,但不能共用。",
|
||||
"shareAndDelete": "他們也可以共用和刪除它。",
|
||||
"noShare": "他們不能共用或刪除它。",
|
||||
"share": "他們也可以共用它,但不能刪除。"
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "檢視者:與其對話並釘選",
|
||||
"editors": "編輯者:編輯、發布、查看記錄和管理排程"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "檢視者:瀏覽、搜尋並在自己的代理中使用",
|
||||
"editors": "編輯者:編輯分塊和檔案、同步、變更設定"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "檢視者:在擁有者的代理中使用",
|
||||
"editors": "編輯者:變更動作和核准"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "檢視者:閱讀並在自己的代理中使用",
|
||||
"editors": "編輯者:修改文字"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "編輯者可以共用",
|
||||
"off": "編輯者不能共用"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "編輯者可以刪除",
|
||||
"off": "編輯者不能刪除"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "編輯者可以管理 API 金鑰、Webhook 和公開連結",
|
||||
"off": "編輯者不能管理 API 金鑰、Webhook 或公開連結"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "檢視者可以查看記錄",
|
||||
"off": "檢視者不能查看記錄"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "檢視者可以查看設定",
|
||||
"off": "檢視者不能查看設定"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "編輯者可以取代憑證",
|
||||
"off": "編輯者不能變更憑證"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "檢視者可以在自己的代理中使用",
|
||||
"off": "檢視者不能在自己的代理中使用"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "檢視者可以複製",
|
||||
"off": "檢視者不能複製"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors_one": "{{level}} · +{{formatted}} 編輯者",
|
||||
"badgeWithEditors_other": "{{level}} · +{{formatted}} 編輯者",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "篩選共用資源",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"agent": "代理",
|
||||
"source": "來源",
|
||||
"tool": "工具",
|
||||
"prompt": "提示"
|
||||
},
|
||||
"search": "搜尋共用…",
|
||||
"noMatches": "沒有相符項目。"
|
||||
},
|
||||
"drawer": {
|
||||
"close": "關閉",
|
||||
"subtitle": "{{type}} · 擁有者:{{owner}}",
|
||||
"open": "開啟{{type}}",
|
||||
"manageSharing": "管理共用",
|
||||
"owner": "擁有者",
|
||||
"shared": "共用時間",
|
||||
"sharedOnBy": "{{date}},由 {{name}}",
|
||||
"yourAccess": "你的存取權",
|
||||
"yourAccessLevel": {
|
||||
"owner": "擁有者",
|
||||
"editor": "編輯者",
|
||||
"viewer": "檢視者",
|
||||
"none": "無存取權"
|
||||
},
|
||||
"accessIn": "{{team}} 中的存取權",
|
||||
"everyone": "{{team}} 的所有人",
|
||||
"teamGrant": "整個團隊",
|
||||
"memberGrant": "僅此人",
|
||||
"removeGrant": "移除存取權",
|
||||
"otherTeamsHint": "也與其他團隊共用了嗎?這些授權在「管理共用」中管理。",
|
||||
"whatPeopleCanDo": "這裡的人員可以做什麼",
|
||||
"capabilitiesHint": "來自擁有者的存取設定。此處為唯讀。"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -794,7 +998,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "重新連線伺服器",
|
||||
"reenterCredentials": "重新輸入您的憑證以測試並更新連線。",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -839,7 +1042,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "由 {{owner}} 分享 · 你是編輯者",
|
||||
"aTeammate": "一位隊友",
|
||||
"sharedCredentialsNotice": "已儲存的憑證不會顯示。你輸入的內容將為所有使用此工具的人取代它們。",
|
||||
"serverChangedNotice": "伺服器已變更,因此已儲存的{{credential}}將被清除。請為新伺服器輸入後再儲存。",
|
||||
"credentialNames": {
|
||||
"apiKey": "API 金鑰",
|
||||
"bearer": "權杖",
|
||||
"password": "密碼"
|
||||
},
|
||||
"savedKeyHint": "已儲存金鑰。留空即可保留(僅在伺服器未變更時)。",
|
||||
"sharedOAuthOwnerOnly": "只有擁有者可以變更此伺服器的連線或重新連結其登入。你仍然可以重新命名並編輯其動作。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}為必填項",
|
||||
@@ -847,7 +1061,14 @@
|
||||
"maxTimeout": "逾時時間最長為 300 秒"
|
||||
},
|
||||
"headerValuePlaceholder": "例如:application/json",
|
||||
"toolIconTitle": "{{name}} 圖示"
|
||||
"toolIconTitle": "{{name}} 圖示",
|
||||
"view": "檢視",
|
||||
"inMyChats": "在我的聊天中",
|
||||
"useInMyChatsAria": "在我的聊天中使用 {{toolName}}",
|
||||
"statusUpdateFailed": "無法變更此工具是否用於你的聊天。",
|
||||
"deleteFailed": "無法刪除此工具。",
|
||||
"sharedBy": "由 {{team}} 分享",
|
||||
"savedSecretPlaceholder": "已儲存 · 輸入新值以取代"
|
||||
},
|
||||
"devices": {
|
||||
"label": "裝置",
|
||||
@@ -1344,7 +1565,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "重設金鑰",
|
||||
"resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。"
|
||||
"resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。",
|
||||
"actionFailed": "操作未成功,請再試一次。",
|
||||
"apiKeyAfterPublish": "發布此代理後即可建立其 API 金鑰。"
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "匯入 API 規格",
|
||||
@@ -1559,6 +1782,7 @@
|
||||
"agents": {
|
||||
"title": "代理",
|
||||
"edit": "編輯",
|
||||
"view": "檢視",
|
||||
"card": {
|
||||
"pin": "釘選代理",
|
||||
"unpin": "取消釘選代理",
|
||||
@@ -1597,10 +1821,6 @@
|
||||
"team": "團隊",
|
||||
"shared": "與我共享"
|
||||
},
|
||||
"teamBadge": {
|
||||
"editor": "編輯者",
|
||||
"viewer": "檢視者"
|
||||
},
|
||||
"sections": {
|
||||
"template": {
|
||||
"title": "由DocsGPT提供",
|
||||
@@ -1761,7 +1981,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1786,6 +2005,13 @@
|
||||
},
|
||||
"status": {
|
||||
"published": "已發佈"
|
||||
},
|
||||
"sponsors": {
|
||||
"attachNote": "你從自己的資料庫新增的工具、來源和提示詞,會以你的存取權限為所有使用此智慧代理的人執行。",
|
||||
"publicLinkNote": "此智慧代理有公開連結,任何擁有該連結的人都可以從中取得回答。",
|
||||
"addedBy": "由 {{person}} 新增:{{names}}",
|
||||
"unavailable": "未執行:{{names}}。新增它們的 {{person}} 已失去存取權限。請移除它們,或選擇擁有者可以使用的項目。",
|
||||
"unknownItem": "未命名項目"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -2221,7 +2447,8 @@
|
||||
"classicDescription": "建立一個使用單一模型、工具和知識來源的標準 AI 代理",
|
||||
"workflowTitle": "工作流程代理",
|
||||
"workflowDescription": "設計包含不同模型、條件邏輯和狀態管理的複雜多步驟工作流程"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "無法刪除此代理,請再試一次。"
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
@@ -2246,6 +2473,7 @@
|
||||
"empty": "尚無待辦事項"
|
||||
},
|
||||
"note": {
|
||||
"lines_one": "{{count}} 行",
|
||||
"lines_other": "{{count}} 行",
|
||||
"empty": "空白筆記"
|
||||
},
|
||||
|
||||
+245
-17
@@ -10,6 +10,12 @@
|
||||
"loading": "加载中...",
|
||||
"retry": "重试",
|
||||
"cancel": "取消",
|
||||
"common": {
|
||||
"close": "关闭",
|
||||
"viewOnlyNotice": "你可以查看此内容,但你的角色无法更改它。",
|
||||
"credentialsLockedNotice": "只有所有者可以更改此工具的凭据。",
|
||||
"savedSecretHint": "已保存。留空即可保留。"
|
||||
},
|
||||
"help": "帮助",
|
||||
"emailUs": "给我们发邮件",
|
||||
"documentation": "文档",
|
||||
@@ -92,7 +98,10 @@
|
||||
"edit": "编辑提示词",
|
||||
"view": "查看提示词",
|
||||
"duplicate": "复制提示词",
|
||||
"delete": "删除提示词"
|
||||
"delete": "删除提示词",
|
||||
"deleteFailed": "无法删除此提示词。",
|
||||
"saveFailed": "无法保存此提示词。",
|
||||
"editConflict": "其他人已更改此提示词。请重新打开以查看其版本。"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -167,6 +176,7 @@
|
||||
}
|
||||
},
|
||||
"badge": "动态 Wiki",
|
||||
"byline_one": "{{pages}} 个页面 · {{tokens}} 个 token",
|
||||
"byline_other": "{{pages}} 个页面 · {{tokens}} 个 token",
|
||||
"explainer": "代理在工作时会阅读并改写这些页面。",
|
||||
"explainerEditable": "代理在工作时会阅读并改写这些页面;任何使用此来源的代理都可以编辑它。",
|
||||
@@ -225,6 +235,7 @@
|
||||
"byConnections": "(按连接数)",
|
||||
"typeFilter": "按类型筛选",
|
||||
"otherTypes": "其他:{{types}}",
|
||||
"otherTypesMore_one": "另外 {{formatted}} 个",
|
||||
"otherTypesMore_other": "另外 {{formatted}} 个",
|
||||
"untyped": "无类型",
|
||||
"loadFailed": "无法加载图谱。",
|
||||
@@ -236,6 +247,7 @@
|
||||
"showMore": "显示更多",
|
||||
"showLess": "收起",
|
||||
"relationships": "关系",
|
||||
"relationshipsCapped_one": "显示 {{total}} 个关系中最强的 {{shown}} 个。",
|
||||
"relationshipsCapped_other": "显示 {{total}} 个关系中最强的 {{shown}} 个。",
|
||||
"noRelationships": "此实体没有关系。",
|
||||
"relatedTo": "相关",
|
||||
@@ -244,8 +256,11 @@
|
||||
"sourceChunks": "来源分块",
|
||||
"showInGraph": "在图谱中显示",
|
||||
"allTypes": "所有类型",
|
||||
"chunkCount_one": "{{formatted}} 个分块",
|
||||
"chunkCount_other": "{{formatted}} 个分块",
|
||||
"entityCount_one": "{{formatted}} 个实体",
|
||||
"entityCount_other": "{{formatted}} 个实体",
|
||||
"relationshipCount_one": "{{formatted}} 个关系",
|
||||
"relationshipCount_other": "{{formatted}} 个关系",
|
||||
"chunk": "分块",
|
||||
"chunkMeta": "{{file}} · {{tokens}} 个令牌",
|
||||
@@ -386,7 +401,6 @@
|
||||
"subtitle": "配置“{{name}}”的分块与检索方式。",
|
||||
"subtitleGeneric": "配置此来源的分块与检索方式。",
|
||||
"save": "保存",
|
||||
"readOnly": "您对此共享来源仅有查看权限,无法更改其配置。",
|
||||
"chunkingChangeHint": "您更改了分块设置。保存后需要重新索引此来源才能生效。",
|
||||
"prescreenInvalidHint": "请检查预筛选参数:获取的候选数量至少应等于文本块数量,且保留的文本块数不得超过获取的候选数量。",
|
||||
"reingestRequired": "您的更改已保存。修改的分块设置需重新索引后才会生效。立即重新索引?",
|
||||
@@ -412,9 +426,11 @@
|
||||
"chunkTitleHint": "回答引用此文本块时使用的名称。",
|
||||
"files": "文件",
|
||||
"filesLoadError": "无法加载文件",
|
||||
"filesByline_one": "{{files}} 个文件 · {{tokens}} 个令牌",
|
||||
"filesByline_other": "{{files}} 个文件 · {{tokens}} 个令牌",
|
||||
"filterFiles": "筛选文件",
|
||||
"searchChunks": "搜索文本块",
|
||||
"chunkCount_one": "{{formatted}} 个文本块",
|
||||
"chunkCount_other": "{{formatted}} 个文本块",
|
||||
"editor": {
|
||||
"write": "编辑",
|
||||
@@ -428,7 +444,15 @@
|
||||
"editChunk": "编辑文本块",
|
||||
"editChunkDescription": "{{file}} · 第 {{n}} 个文本块 · {{tokens}} 个令牌",
|
||||
"previousChunk": "上一个文本块",
|
||||
"nextChunk": "下一个文本块"
|
||||
"nextChunk": "下一个文本块",
|
||||
"viewConfig": "查看来源设置",
|
||||
"errors": {
|
||||
"forbidden": "你无权对此来源执行该操作。",
|
||||
"delete": "无法删除来源。",
|
||||
"sync": "无法同步来源。",
|
||||
"syncFrequency": "无法更改同步频率。",
|
||||
"reingest": "无法开始重新导入。"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
@@ -651,9 +675,10 @@
|
||||
"createTeamError": "无法创建团队。",
|
||||
"noTeams": "暂无团队。",
|
||||
"noDescription": "无描述",
|
||||
"memberCountOne": "{{count}} 名成员",
|
||||
"memberCountOther": "{{count}} 名成员",
|
||||
"sharedCount": "{{count}} 项共享",
|
||||
"memberCount_one": "{{formatted}} 名成员",
|
||||
"memberCount_other": "{{formatted}} 名成员",
|
||||
"sharedCount_one": "{{formatted}} 项共享",
|
||||
"sharedCount_other": "{{formatted}} 项共享",
|
||||
"loadError": "加载团队失败。",
|
||||
"roleAdmin": "管理员",
|
||||
"roleMember": "成员",
|
||||
@@ -720,7 +745,186 @@
|
||||
"teamLabel": "团队",
|
||||
"viaTeam": "通过 {{team}}",
|
||||
"removeAccess": "移除访问权限",
|
||||
"access": "访问权限"
|
||||
"access": "访问权限",
|
||||
"showAll": "显示全部 {{formatted}} 个",
|
||||
"andMore_one": "还有 {{formatted}} 个",
|
||||
"andMore_other": "还有 {{formatted}} 个",
|
||||
"back": "返回",
|
||||
"allSummary": "{{name}} · 团队:{{teams}} · 人员:{{people}}",
|
||||
"searchAccess": "搜索人员和团队…",
|
||||
"filterLabel": "筛选有权访问的人员",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"teams": "团队",
|
||||
"people": "人员",
|
||||
"editors": "编辑者"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "无法更改访问权限。",
|
||||
"accessSettings": {
|
||||
"title": "访问设置",
|
||||
"saveError": "无法保存访问设置。",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "编辑者可以删除",
|
||||
"description": "为所有人删除该代理。"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "编辑者可以管理访问详情",
|
||||
"description": "API 密钥、Webhook 和公开链接。"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "查看者可以查看日志",
|
||||
"description": "该代理的对话和分析。"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "编辑者可以删除",
|
||||
"description": "为所有人删除该来源。"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "查看者可以查看设置",
|
||||
"description": "分块、检索器和同步设置,只读。"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "编辑者可以更改凭据和连接",
|
||||
"description": "他们替换已保存的密钥;任何人都无法读回。"
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "查看者可以在自己的代理中使用",
|
||||
"description": "它使用你的凭据运行。"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "查看者可以复制",
|
||||
"description": "创建自己的副本进行编辑。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "编辑者可以修改它,包括日志、计划和访问详情。",
|
||||
"agentNoAccessDetails": "编辑者可以修改它,包括日志和计划,但不能修改访问详情。",
|
||||
"source": "编辑者可以编辑分块和文件、同步并更改设置。",
|
||||
"tool": "编辑者可以更改操作并替换凭据,但无法读取密钥。",
|
||||
"toolNoCredentials": "编辑者可以更改操作,但不能更改凭据。",
|
||||
"prompt": "编辑者可以修改文本。",
|
||||
"noShareNoDelete": "他们不能共享或删除它。",
|
||||
"shareOnly": "他们还可以共享它,但不能删除。",
|
||||
"deleteOnly": "他们还可以删除它,但不能共享。",
|
||||
"shareAndDelete": "他们还可以共享和删除它。",
|
||||
"noShare": "他们不能共享或删除它。",
|
||||
"share": "他们还可以共享它,但不能删除。"
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "查看者:与其对话并置顶",
|
||||
"editors": "编辑者:编辑、发布、查看日志和管理计划"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "查看者:浏览、搜索并在自己的代理中使用",
|
||||
"editors": "编辑者:编辑分块和文件、同步、更改设置"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "查看者:在所有者的代理中使用",
|
||||
"editors": "编辑者:更改操作和审批"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "查看者:阅读并在自己的代理中使用",
|
||||
"editors": "编辑者:修改文本"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "编辑者可以共享",
|
||||
"off": "编辑者不能共享"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "编辑者可以删除",
|
||||
"off": "编辑者不能删除"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "编辑者可以管理 API 密钥、Webhook 和公开链接",
|
||||
"off": "编辑者不能管理 API 密钥、Webhook 或公开链接"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "查看者可以查看日志",
|
||||
"off": "查看者不能查看日志"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "查看者可以查看设置",
|
||||
"off": "查看者不能查看设置"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "编辑者可以替换凭据",
|
||||
"off": "编辑者不能更改凭据"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "查看者可以在自己的代理中使用",
|
||||
"off": "查看者不能在自己的代理中使用"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "查看者可以复制",
|
||||
"off": "查看者不能复制"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors_one": "{{level}} · +{{formatted}} 编辑者",
|
||||
"badgeWithEditors_other": "{{level}} · +{{formatted}} 编辑者",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "筛选共享资源",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"agent": "代理",
|
||||
"source": "来源",
|
||||
"tool": "工具",
|
||||
"prompt": "提示词"
|
||||
},
|
||||
"search": "搜索共享…",
|
||||
"noMatches": "没有匹配项。"
|
||||
},
|
||||
"drawer": {
|
||||
"close": "关闭",
|
||||
"subtitle": "{{type}} · 所有者:{{owner}}",
|
||||
"open": "打开{{type}}",
|
||||
"manageSharing": "管理共享",
|
||||
"owner": "所有者",
|
||||
"shared": "共享时间",
|
||||
"sharedOnBy": "{{date}},由 {{name}}",
|
||||
"yourAccess": "你的访问权限",
|
||||
"yourAccessLevel": {
|
||||
"owner": "所有者",
|
||||
"editor": "编辑者",
|
||||
"viewer": "查看者",
|
||||
"none": "无访问权限"
|
||||
},
|
||||
"accessIn": "{{team}} 中的访问权限",
|
||||
"everyone": "{{team}} 的所有人",
|
||||
"teamGrant": "整个团队",
|
||||
"memberGrant": "仅此人",
|
||||
"removeGrant": "移除访问权限",
|
||||
"otherTeamsHint": "也与其他团队共享了?这些授权在“管理共享”中管理。",
|
||||
"whatPeopleCanDo": "这里的人员可以做什么",
|
||||
"capabilitiesHint": "来自所有者的访问设置。此处只读。"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -794,7 +998,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "重新连接服务器",
|
||||
"reenterCredentials": "重新输入您的凭据以测试并更新连接。",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -839,7 +1042,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "由 {{owner}} 共享 · 你是编辑者",
|
||||
"aTeammate": "一位队友",
|
||||
"sharedCredentialsNotice": "已保存的凭据不会显示。你输入的内容将为所有使用此工具的人替换它们。",
|
||||
"serverChangedNotice": "服务器已更改,因此已保存的{{credential}}将被清除。请为新服务器输入后再保存。",
|
||||
"credentialNames": {
|
||||
"apiKey": "API 密钥",
|
||||
"bearer": "令牌",
|
||||
"password": "密码"
|
||||
},
|
||||
"savedKeyHint": "已保存密钥。留空即可保留(仅在服务器未更改时)。",
|
||||
"sharedOAuthOwnerOnly": "只有所有者可以更改此服务器的连接或重新连接其登录。你仍然可以重命名它并编辑其操作。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}为必填项",
|
||||
@@ -847,7 +1061,14 @@
|
||||
"maxTimeout": "超时时间最长为 300 秒"
|
||||
},
|
||||
"headerValuePlaceholder": "例如:application/json",
|
||||
"toolIconTitle": "{{name}} 图标"
|
||||
"toolIconTitle": "{{name}} 图标",
|
||||
"view": "查看",
|
||||
"inMyChats": "在我的聊天中",
|
||||
"useInMyChatsAria": "在我的聊天中使用 {{toolName}}",
|
||||
"statusUpdateFailed": "无法更改此工具是否用于你的聊天。",
|
||||
"deleteFailed": "无法删除此工具。",
|
||||
"sharedBy": "由 {{team}} 共享",
|
||||
"savedSecretPlaceholder": "已保存 · 输入新值以替换"
|
||||
},
|
||||
"devices": {
|
||||
"label": "设备",
|
||||
@@ -1344,7 +1565,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "重置密钥",
|
||||
"resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。"
|
||||
"resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。",
|
||||
"actionFailed": "操作未成功,请重试。",
|
||||
"apiKeyAfterPublish": "发布该代理后即可创建其 API 密钥。"
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "导入 API 规范",
|
||||
@@ -1559,6 +1782,7 @@
|
||||
"agents": {
|
||||
"title": "代理",
|
||||
"edit": "编辑",
|
||||
"view": "查看",
|
||||
"card": {
|
||||
"pin": "固定代理",
|
||||
"unpin": "取消固定代理",
|
||||
@@ -1597,10 +1821,6 @@
|
||||
"team": "团队",
|
||||
"shared": "与我共享"
|
||||
},
|
||||
"teamBadge": {
|
||||
"editor": "编辑者",
|
||||
"viewer": "查看者"
|
||||
},
|
||||
"sections": {
|
||||
"template": {
|
||||
"title": "由DocsGPT提供",
|
||||
@@ -1761,7 +1981,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1786,6 +2005,13 @@
|
||||
},
|
||||
"status": {
|
||||
"published": "已发布"
|
||||
},
|
||||
"sponsors": {
|
||||
"attachNote": "你从自己的资料库添加的工具、来源和提示词,会以你的访问权限为所有使用此智能体的人运行。",
|
||||
"publicLinkNote": "此智能体有公开链接,任何拥有该链接的人都可以从中获得回答。",
|
||||
"addedBy": "由 {{person}} 添加:{{names}}",
|
||||
"unavailable": "未运行:{{names}}。添加它们的 {{person}} 已失去访问权限。请移除它们,或选择所有者可以使用的项目。",
|
||||
"unknownItem": "未命名项目"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -2221,7 +2447,8 @@
|
||||
"classicDescription": "创建一个使用单一模型、工具和知识来源的标准 AI 智能体",
|
||||
"workflowTitle": "工作流智能体",
|
||||
"workflowDescription": "设计包含不同模型、条件逻辑和状态管理的复杂多步骤工作流"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "无法删除该代理,请重试。"
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
@@ -2246,6 +2473,7 @@
|
||||
"empty": "暂无待办事项"
|
||||
},
|
||||
"note": {
|
||||
"lines_one": "{{count}} 行",
|
||||
"lines_other": "{{count}} 行",
|
||||
"empty": "空笔记"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
shareAgent: vi.fn(),
|
||||
getAgentWebhook: vi.fn(),
|
||||
regenerateAgentKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) =>
|
||||
selector({ preference: { token: null } }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({ default: mocks }));
|
||||
|
||||
vi.mock('./ConfirmationModal', () => ({
|
||||
default: ({
|
||||
modalState,
|
||||
handleSubmit,
|
||||
}: {
|
||||
modalState: string;
|
||||
handleSubmit: () => void;
|
||||
}) =>
|
||||
modalState === 'ACTIVE' ? (
|
||||
<button type="button" data-testid="confirm-key" onClick={handleSubmit} />
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import type { Agent } from '../agents/types';
|
||||
import AgentDetailsModal from './AgentDetailsModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const respond = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('AgentDetailsModal', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
Object.values(mocks).forEach((m) => m.mockReset());
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (agent: Partial<Agent>) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AgentDetailsModal
|
||||
agent={{ id: 'a1', name: 'Bot', ...agent } as Agent}
|
||||
mode="edit"
|
||||
modalState="ACTIVE"
|
||||
setModalState={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// The three sections each have a Generate button until they hold a value.
|
||||
const generateButtons = () =>
|
||||
Array.from(document.querySelectorAll('button')).filter(
|
||||
(b) => b.textContent === 'modals.agentDetails.generate',
|
||||
);
|
||||
|
||||
it('generates a missing API key through the reset confirmation', async () => {
|
||||
mocks.regenerateAgentKey.mockReturnValue(respond({ key: 'new-key' }));
|
||||
await render({ status: 'published' });
|
||||
const [, apiKey] = generateButtons();
|
||||
await act(async () => apiKey.click());
|
||||
await act(async () =>
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[data-testid="confirm-key"]')!
|
||||
.click(),
|
||||
);
|
||||
expect(mocks.regenerateAgentKey).toHaveBeenCalledWith('a1', null);
|
||||
expect(document.body.textContent).toContain('new-key');
|
||||
});
|
||||
|
||||
it('asks a draft to publish first instead of offering a key', async () => {
|
||||
await render({ status: 'draft' });
|
||||
expect(generateButtons()).toHaveLength(2);
|
||||
expect(document.body.textContent).toContain(
|
||||
'modals.agentDetails.apiKeyAfterPublish',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a refused public link in an alert', async () => {
|
||||
mocks.shareAgent.mockReturnValue(
|
||||
respond({ success: false, message: 'Not allowed' }, false),
|
||||
);
|
||||
await render({ status: 'published' });
|
||||
await act(async () => generateButtons()[0].click());
|
||||
const alert = document.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Not allowed');
|
||||
// Destructive Alerts lead with CircleAlert, like every other one.
|
||||
expect(alert?.querySelector('svg.lucide-circle-alert')).not.toBeNull();
|
||||
expect(alert?.querySelector('svg.lucide-circle-x')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a failed webhook in an alert with a fallback message', async () => {
|
||||
mocks.getAgentWebhook.mockReturnValue(respond({}, false));
|
||||
await render({ status: 'published' });
|
||||
const buttons = generateButtons();
|
||||
await act(async () => buttons[buttons.length - 1].click());
|
||||
const alert = document.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('modals.agentDetails.actionFailed');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { envVar } from '@/env';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { CircleAlert, ExternalLink } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -7,6 +7,7 @@ import { useSelector } from 'react-redux';
|
||||
import { Agent } from '../agents/types';
|
||||
import userService from '../api/services/userService';
|
||||
import CopyButton from '../components/CopyButton';
|
||||
import { Alert, AlertDescription } from '../components/ui/alert';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { SectionHeader } from '../components/ui/section-header';
|
||||
@@ -16,6 +17,18 @@ import ConfirmationModal from './ConfirmationModal';
|
||||
|
||||
const baseURL = envVar('VITE_BASE_URL');
|
||||
|
||||
/** The backend's `message` on a refused call, else null. */
|
||||
const errorMessage = async (response: Response): Promise<string | null> => {
|
||||
try {
|
||||
const body = await response.json();
|
||||
return typeof body?.message === 'string' && body.message.trim()
|
||||
? body.message
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type AgentDetailsModalProps = {
|
||||
agent: Agent;
|
||||
mode: 'new' | 'edit' | 'draft';
|
||||
@@ -41,6 +54,8 @@ export default function AgentDetailsModal({
|
||||
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
|
||||
const [resetKeyConfirmState, setResetKeyConfirmState] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
// A failed generate or reset, shown in the modal until the next attempt.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingStates, setLoadingStates] = useState({
|
||||
publicLink: false,
|
||||
apiKey: false,
|
||||
@@ -54,49 +69,61 @@ export default function AgentDetailsModal({
|
||||
setLoadingStates((prev) => ({ ...prev, [key]: state }));
|
||||
};
|
||||
|
||||
const handleGeneratePublicLink = async () => {
|
||||
setLoading('publicLink', true);
|
||||
const response = await userService.shareAgent(
|
||||
{ id: agent.id ?? '', shared: true },
|
||||
token,
|
||||
);
|
||||
if (!response.ok) {
|
||||
setLoading('publicLink', false);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setSharedToken(data.shared_token);
|
||||
setLoading('publicLink', false);
|
||||
};
|
||||
|
||||
const handleGenerateWebhook = async () => {
|
||||
setLoading('webhook', true);
|
||||
const response = await userService.getAgentWebhook(agent.id ?? '', token);
|
||||
if (!response.ok) {
|
||||
setLoading('webhook', false);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setWebhookUrl(data.webhook_url);
|
||||
setLoading('webhook', false);
|
||||
};
|
||||
|
||||
const handleRegenerateKey = async () => {
|
||||
setLoading('apiKey', true);
|
||||
/**
|
||||
* Runs one of the modal's calls, showing its failure in the Alert.
|
||||
*
|
||||
* @param key Which button shows the spinner.
|
||||
* @param request The call; resolves to the response.
|
||||
* @param onSuccess Receives the parsed body of a successful response.
|
||||
*/
|
||||
const run = async (
|
||||
key: 'publicLink' | 'apiKey' | 'webhook',
|
||||
request: () => Promise<Response>,
|
||||
onSuccess: (data: Record<string, string>) => void,
|
||||
) => {
|
||||
setLoading(key, true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await userService.regenerateAgentKey(
|
||||
agent.id ?? '',
|
||||
token,
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setApiKey(data.key);
|
||||
onKeyRegenerated?.(data.key);
|
||||
const response = await request();
|
||||
if (!response.ok) {
|
||||
setError(
|
||||
(await errorMessage(response)) ??
|
||||
t('modals.agentDetails.actionFailed'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSuccess(await response.json());
|
||||
} catch {
|
||||
setError(t('modals.agentDetails.actionFailed'));
|
||||
} finally {
|
||||
setLoading('apiKey', false);
|
||||
setLoading(key, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeneratePublicLink = () =>
|
||||
run(
|
||||
'publicLink',
|
||||
() => userService.shareAgent({ id: agent.id ?? '', shared: true }, token),
|
||||
(data) => setSharedToken(data.shared_token),
|
||||
);
|
||||
|
||||
const handleGenerateWebhook = () =>
|
||||
run(
|
||||
'webhook',
|
||||
() => userService.getAgentWebhook(agent.id ?? '', token),
|
||||
(data) => setWebhookUrl(data.webhook_url),
|
||||
);
|
||||
|
||||
const handleRegenerateKey = () =>
|
||||
run(
|
||||
'apiKey',
|
||||
() => userService.regenerateAgentKey(agent.id ?? '', token),
|
||||
(data) => {
|
||||
setApiKey(data.key);
|
||||
onKeyRegenerated?.(data.key);
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSharedToken(agent.shared_token ?? null);
|
||||
setApiKey(agent.key ?? null);
|
||||
@@ -111,6 +138,12 @@ export default function AgentDetailsModal({
|
||||
size="md"
|
||||
>
|
||||
<div>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-6">
|
||||
<CircleAlert />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="mt-8 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -216,8 +249,21 @@ export default function AgentDetailsModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : agent.status === 'draft' ? (
|
||||
// A draft has no key yet: the first one is minted on publish.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('modals.agentDetails.apiKeyAfterPublish')}
|
||||
</p>
|
||||
) : (
|
||||
<Button type="button" variant="outline-primary" shape="pill">
|
||||
// No key shown on a published agent: minting one replaces
|
||||
// any key it has, so it goes through the reset confirmation.
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
shape="pill"
|
||||
onClick={() => setResetKeyConfirmState('ACTIVE')}
|
||||
loading={loadingStates.apiKey}
|
||||
>
|
||||
{t('modals.agentDetails.generate')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) =>
|
||||
selector({ notifications: { recentEvents: [] }, preference: {} }),
|
||||
}));
|
||||
vi.mock('../preferences/preferenceSlice', () => ({
|
||||
selectToken: () => 'token',
|
||||
}));
|
||||
vi.mock('../notifications/notificationsSlice', () => ({
|
||||
selectRecentEvents: (state: { notifications: { recentEvents: unknown[] } }) =>
|
||||
state.notifications.recentEvents,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
if (!opts || typeof opts !== 'object') return key;
|
||||
const { defaultValue: _d, interpolation: _i, ...rest } = opts;
|
||||
void _d;
|
||||
void _i;
|
||||
return Object.keys(rest).length ? `${key}:${JSON.stringify(rest)}` : key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const testMCPConnection = vi.fn();
|
||||
const saveMCPServer = vi.fn();
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
testMCPConnection: (...args: unknown[]) => testMCPConnection(...args),
|
||||
saveMCPServer: (...args: unknown[]) => saveMCPServer(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import MCPServerModal from './MCPServerModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const server = {
|
||||
id: 'tool-1',
|
||||
displayName: 'Carrier Rates MCP',
|
||||
server_url: 'https://mcp.dana-tools.dev/sse',
|
||||
auth_type: 'api_key',
|
||||
timeout: 30,
|
||||
oauth_scopes: '',
|
||||
has_encrypted_credentials: true,
|
||||
access: 'owner',
|
||||
owner_label: null as string | null,
|
||||
};
|
||||
|
||||
const json = (body: unknown, ok = true, status = 200) =>
|
||||
Promise.resolve({ ok, status, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('MCPServerModal', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
testMCPConnection.mockReset();
|
||||
saveMCPServer.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (overrides: Partial<typeof server> = {}) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MCPServerModal
|
||||
modalState="ACTIVE"
|
||||
setModalState={() => {}}
|
||||
server={{ ...server, ...overrides }}
|
||||
onServerSaved={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const text = () => document.body.textContent ?? '';
|
||||
const button = (label: string) =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('button'),
|
||||
).find((b) => b.textContent === label)!;
|
||||
const typeInto = async (input: HTMLInputElement, value: string) => {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
const urlInput = () =>
|
||||
document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="https://example.com/mcp"]',
|
||||
)!;
|
||||
|
||||
it('tells an editor whose tool it is and that their entry replaces it for everyone', async () => {
|
||||
await render({ access: 'editor', owner_label: 'Lena Fischer' });
|
||||
expect(text()).toContain(
|
||||
'settings.tools.mcp.sharedByEditor:{"owner":"Lena Fischer"}',
|
||||
);
|
||||
// Informative, not announced: a quiet default Alert with role="note".
|
||||
const note = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((a) =>
|
||||
a.textContent?.includes('settings.tools.mcp.sharedCredentialsNotice'),
|
||||
);
|
||||
expect(note?.getAttribute('role')).toBe('note');
|
||||
expect(note?.dataset.variant).toBe('default');
|
||||
});
|
||||
|
||||
it('masks the API key and bearer token fields', async () => {
|
||||
await render();
|
||||
const key = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="settings.tools.mcp.placeholders.apiKey"]',
|
||||
);
|
||||
expect(key?.type).toBe('password');
|
||||
expect(key?.value).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to "a teammate" without an owner label', async () => {
|
||||
await render({ access: 'editor', owner_label: null });
|
||||
expect(text()).toContain(
|
||||
'settings.tools.mcp.sharedByEditor:{"owner":"settings.tools.mcp.aTeammate"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no sharing notices on the caller's own tool", async () => {
|
||||
await render();
|
||||
expect(text()).not.toContain('settings.tools.mcp.sharedByEditor');
|
||||
expect(text()).not.toContain('settings.tools.mcp.sharedCredentialsNotice');
|
||||
});
|
||||
|
||||
it('hints that a saved key is kept when left empty', async () => {
|
||||
await render();
|
||||
expect(text()).toContain('settings.tools.mcp.savedKeyHint');
|
||||
});
|
||||
|
||||
it('tests with the saved key while the server is unchanged', async () => {
|
||||
testMCPConnection.mockReturnValue(json({ success: true, tools: [] }));
|
||||
await render();
|
||||
await act(async () => button('settings.tools.mcp.testConnection').click());
|
||||
expect(testMCPConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'tool-1' }),
|
||||
'token',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and requires a new key when the server host changes', async () => {
|
||||
await render({ access: 'editor', owner_label: 'Lena' });
|
||||
await typeInto(urlInput(), 'https://evil.example.com/sse');
|
||||
expect(text()).toContain(
|
||||
'settings.tools.mcp.serverChangedNotice:{"credential":"settings.tools.mcp.credentialNames.apiKey"}',
|
||||
);
|
||||
expect(text()).not.toContain('settings.tools.mcp.savedKeyHint');
|
||||
await act(async () => button('settings.tools.mcp.testConnection').click());
|
||||
expect(testMCPConnection).not.toHaveBeenCalled();
|
||||
expect(text()).toContain('settings.tools.mcp.errors.apiKeyRequired');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a downgrade to http', 'http://mcp.dana-tools.dev/sse'],
|
||||
['another port', 'https://mcp.dana-tools.dev:8443/sse'],
|
||||
])('clears the saved key for %s', async (_label, url) => {
|
||||
await render();
|
||||
await typeInto(urlInput(), url);
|
||||
expect(text()).toContain('settings.tools.mcp.serverChangedNotice');
|
||||
});
|
||||
|
||||
it('keeps the saved key when only the default port is spelled out', async () => {
|
||||
await render();
|
||||
await typeInto(urlInput(), 'https://mcp.dana-tools.dev:443/v2/sse');
|
||||
expect(text()).not.toContain('settings.tools.mcp.serverChangedNotice');
|
||||
});
|
||||
|
||||
it('keeps the saved key for a path-only change on the same host', async () => {
|
||||
await render();
|
||||
await typeInto(urlInput(), 'https://mcp.dana-tools.dev/v2/sse');
|
||||
expect(text()).not.toContain('settings.tools.mcp.serverChangedNotice');
|
||||
});
|
||||
|
||||
it("shows the server's save error in the error Alert", async () => {
|
||||
testMCPConnection.mockReturnValue(json({ success: true, tools: [] }));
|
||||
saveMCPServer.mockReturnValue(
|
||||
json({ success: false, message: 'Invalid server URL' }, false, 400),
|
||||
);
|
||||
await render();
|
||||
await act(async () => button('settings.tools.mcp.testConnection').click());
|
||||
await act(async () => button('settings.tools.mcp.save').click());
|
||||
expect(text()).toContain('Invalid server URL');
|
||||
});
|
||||
|
||||
it('keeps a shared OAuth server read-only for an editor', async () => {
|
||||
await render({
|
||||
access: 'editor',
|
||||
owner_label: 'Lena',
|
||||
auth_type: 'oauth',
|
||||
has_encrypted_credentials: false,
|
||||
});
|
||||
expect(text()).toContain('settings.tools.mcp.sharedOAuthOwnerOnly');
|
||||
expect(urlInput().disabled).toBe(true);
|
||||
expect(button('settings.tools.mcp.testConnection')).toBeUndefined();
|
||||
const save = button('settings.tools.mcp.save');
|
||||
expect(save.disabled).toBe(true);
|
||||
await act(async () => save.click());
|
||||
expect(saveMCPServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps OAuth reconnect for the owner', async () => {
|
||||
await render({ auth_type: 'oauth', has_encrypted_credentials: false });
|
||||
expect(urlInput().disabled).toBe(false);
|
||||
expect(button('settings.tools.mcp.testConnection')).toBeDefined();
|
||||
expect(text()).not.toContain('settings.tools.mcp.sharedOAuthOwnerOnly');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react';
|
||||
import { CircleAlert, CircleCheck, Lock, TriangleAlert } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -30,6 +30,26 @@ interface MCPServerModalProps {
|
||||
onServerSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin (scheme, host, port) of a URL, or '' while it doesn't parse.
|
||||
* Saved secrets follow the origin, like the server's rule: the same host over
|
||||
* http would send them in cleartext, and another port can be another service.
|
||||
*/
|
||||
function originOf(url: string): string {
|
||||
try {
|
||||
return new URL(url.trim()).origin.toLowerCase();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// The saved secret each auth type keeps, named for the host-change notice.
|
||||
const SECRET_FIELDS: Record<string, { field: string; nameKey: string }> = {
|
||||
api_key: { field: 'api_key', nameKey: 'apiKey' },
|
||||
bearer: { field: 'bearer_token', nameKey: 'bearer' },
|
||||
basic: { field: 'password', nameKey: 'password' },
|
||||
};
|
||||
|
||||
export default function MCPServerModal({
|
||||
modalState,
|
||||
setModalState,
|
||||
@@ -95,6 +115,25 @@ export default function MCPServerModal({
|
||||
const [oauthCompleted, setOAuthCompleted] = useState(false);
|
||||
const [saveActive, setSaveActive] = useState(false);
|
||||
|
||||
// A tool shared with the caller (an editor reconnecting the owner's
|
||||
// server): its saved secrets stay hidden and a new entry replaces them.
|
||||
const isShared = !!server?.access && server.access !== 'owner';
|
||||
// Only the owner can change or re-run an OAuth server's sign-in (the tokens
|
||||
// are theirs), so the modal is read-only for a teammate. The Tools menu
|
||||
// doesn't offer it to them; this guards any other way in.
|
||||
const oauthOwnerOnly = isShared && server?.auth_type === 'oauth';
|
||||
const savedSecret = SECRET_FIELDS[formData.auth_type];
|
||||
const hasSavedSecret =
|
||||
!!server?.has_encrypted_credentials &&
|
||||
!!savedSecret &&
|
||||
formData.auth_type === server?.auth_type;
|
||||
// The server clears the saved secret when the origin changes, so the key
|
||||
// can't be pointed at another server, port or plain http.
|
||||
const serverChanged =
|
||||
hasSavedSecret &&
|
||||
originOf(formData.server_url) !== originOf(server?.server_url || '');
|
||||
const keepSavedSecret = hasSavedSecret && !serverChanged;
|
||||
|
||||
const cleanupOAuthListener = useCallback(() => {
|
||||
setOauthTaskId(null);
|
||||
handledEventIdsRef.current = new Set();
|
||||
@@ -167,17 +206,17 @@ export default function MCPServerModal({
|
||||
|
||||
const authFieldChecks: { [key: string]: () => void } = {
|
||||
api_key: () => {
|
||||
if (!formData.api_key.trim())
|
||||
if (!formData.api_key.trim() && !keepSavedSecret)
|
||||
newErrors.api_key = t('settings.tools.mcp.errors.apiKeyRequired');
|
||||
},
|
||||
bearer: () => {
|
||||
if (!formData.bearer_token.trim())
|
||||
if (!formData.bearer_token.trim() && !keepSavedSecret)
|
||||
newErrors.bearer_token = t('settings.tools.mcp.errors.tokenRequired');
|
||||
},
|
||||
basic: () => {
|
||||
if (!formData.username.trim())
|
||||
newErrors.username = t('settings.tools.mcp.errors.usernameRequired');
|
||||
if (!formData.password.trim())
|
||||
if (!formData.password.trim() && !keepSavedSecret)
|
||||
newErrors.password = t('settings.tools.mcp.errors.passwordRequired');
|
||||
},
|
||||
};
|
||||
@@ -299,6 +338,7 @@ export default function MCPServerModal({
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: t('settings.tools.mcp.oauthPopupBlocked', {
|
||||
interpolation: { escapeValue: false },
|
||||
defaultValue:
|
||||
'Popup blocked by browser. Click below to authorize:',
|
||||
}),
|
||||
@@ -369,7 +409,11 @@ export default function MCPServerModal({
|
||||
setOAuthCompleted(false);
|
||||
try {
|
||||
const config = buildToolConfig();
|
||||
const response = await userService.testMCPConnection({ config }, token);
|
||||
// The id lets the server test with the saved secret left empty.
|
||||
const response = await userService.testMCPConnection(
|
||||
{ config, ...(server?.id && { id: server.id }) },
|
||||
token,
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (
|
||||
@@ -441,7 +485,10 @@ export default function MCPServerModal({
|
||||
resetForm();
|
||||
} else {
|
||||
setErrors({
|
||||
general: result.error || t('settings.tools.mcp.errors.saveFailed'),
|
||||
general:
|
||||
result.message ||
|
||||
result.error ||
|
||||
t('settings.tools.mcp.errors.saveFailed'),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -460,9 +507,15 @@ export default function MCPServerModal({
|
||||
label={t('settings.tools.mcp.authTypes.apiKey')}
|
||||
required
|
||||
error={errors.api_key}
|
||||
hint={
|
||||
keepSavedSecret
|
||||
? t('settings.tools.mcp.savedKeyHint')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={formData.api_key}
|
||||
onChange={(e) => handleInputChange('api_key', e.target.value)}
|
||||
placeholder={t('settings.tools.mcp.placeholders.apiKey')}
|
||||
@@ -486,9 +539,13 @@ export default function MCPServerModal({
|
||||
label={t('settings.tools.mcp.authTypes.bearer')}
|
||||
required
|
||||
error={errors.bearer_token}
|
||||
hint={
|
||||
keepSavedSecret ? t('settings.tools.mcp.savedKeyHint') : undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={formData.bearer_token}
|
||||
onChange={(e) =>
|
||||
handleInputChange('bearer_token', e.target.value)
|
||||
@@ -516,6 +573,11 @@ export default function MCPServerModal({
|
||||
label={t('settings.tools.mcp.password')}
|
||||
required
|
||||
error={errors.password}
|
||||
hint={
|
||||
keepSavedSecret
|
||||
? t('settings.tools.mcp.savedKeyHint')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -536,6 +598,7 @@ export default function MCPServerModal({
|
||||
handleInputChange('oauth_scopes', e.target.value)
|
||||
}
|
||||
placeholder="read, write"
|
||||
disabled={oauthOwnerOnly}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
@@ -560,21 +623,31 @@ export default function MCPServerModal({
|
||||
})
|
||||
: t('settings.tools.mcp.addServer')
|
||||
}
|
||||
description={
|
||||
isShared
|
||||
? t('settings.tools.mcp.sharedByEditor', {
|
||||
interpolation: { escapeValue: false },
|
||||
owner: server.owner_label || t('settings.tools.mcp.aTeammate'),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
size="lg"
|
||||
mobileVariant="sheet"
|
||||
footer={
|
||||
<ModalActions
|
||||
footerStart={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
size="lg"
|
||||
shape="pill"
|
||||
>
|
||||
{t('settings.tools.mcp.testConnection')}
|
||||
</Button>
|
||||
oauthOwnerOnly ? undefined : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
size="lg"
|
||||
shape="pill"
|
||||
>
|
||||
{t('settings.tools.mcp.testConnection')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
cancelLabel={t('settings.tools.mcp.cancel')}
|
||||
onCancel={() => {
|
||||
@@ -584,23 +657,21 @@ export default function MCPServerModal({
|
||||
submitLabel={t('settings.tools.mcp.save')}
|
||||
onSubmit={handleSave}
|
||||
pending={loading}
|
||||
disabled={!saveActive}
|
||||
disabled={!saveActive || oauthOwnerOnly}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{server?.has_encrypted_credentials &&
|
||||
formData.auth_type !== 'oauth' && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert className="size-4" aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.tools.mcp.reenterCredentials', {
|
||||
defaultValue:
|
||||
'Re-enter your credentials to test and update the connection.',
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isShared && (
|
||||
<Alert role="note">
|
||||
<Lock />
|
||||
<AlertDescription>
|
||||
{t('settings.tools.mcp.sharedCredentialsNotice')}
|
||||
{oauthOwnerOnly &&
|
||||
` ${t('settings.tools.mcp.sharedOAuthOwnerOnly')}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<FormField
|
||||
label={t('settings.tools.mcp.serverName')}
|
||||
required
|
||||
@@ -624,13 +695,28 @@ export default function MCPServerModal({
|
||||
value={formData.server_url}
|
||||
onChange={(e) => handleInputChange('server_url', e.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
disabled={oauthOwnerOnly}
|
||||
/>
|
||||
</FormField>
|
||||
{serverChanged && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.tools.mcp.serverChangedNotice', {
|
||||
interpolation: { escapeValue: false },
|
||||
credential: t(
|
||||
`settings.tools.mcp.credentialNames.${savedSecret.nameKey}`,
|
||||
),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField label={t('settings.tools.mcp.authType')}>
|
||||
<Select
|
||||
value={formData.auth_type}
|
||||
onValueChange={(v) => handleInputChange('auth_type', v)}
|
||||
disabled={oauthOwnerOnly}
|
||||
>
|
||||
<SelectTrigger size="field" className="w-full">
|
||||
<SelectValue placeholder={t('settings.tools.mcp.authType')} />
|
||||
|
||||
@@ -85,6 +85,10 @@ export type Doc = {
|
||||
// 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;
|
||||
// The caller's role and what it allows (sources API); gate UI with
|
||||
// `can(doc, action)` from utils/accessUtils.
|
||||
access?: 'owner' | 'editor' | 'viewer' | null;
|
||||
allowed_actions?: string[];
|
||||
};
|
||||
|
||||
export type GetDocsResponse = {
|
||||
@@ -98,10 +102,16 @@ export type Prompt = {
|
||||
name: string;
|
||||
id: string;
|
||||
type: string;
|
||||
// The caller's role and what it allows (prompts API); gate UI with
|
||||
// `can(prompt, action)` from utils/accessUtils. Absent on presets.
|
||||
access?: 'owner' | 'editor' | 'viewer' | null;
|
||||
allowed_actions?: string[];
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type PromptProps = {
|
||||
prompts: { name: string; id: string; type: string }[];
|
||||
prompts: Prompt[];
|
||||
selectedPrompt: { name: string; id: string; type: string };
|
||||
onSelectPrompt: (name: string, id: string, type: string) => void;
|
||||
setPrompts: (prompts: { name: string; id: string; type: string }[]) => void;
|
||||
|
||||
@@ -142,6 +142,48 @@ describe('buildAgentSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgentSection tabs for a draft', () => {
|
||||
it('shows only Overview until the agent is published', () => {
|
||||
const items = getSectionItems(
|
||||
buildAgentSection('a1', 'Bot', false, {
|
||||
access: 'owner',
|
||||
status: 'draft',
|
||||
allowed_actions: ['view', 'view_logs', 'manage_schedules'],
|
||||
}),
|
||||
).map((item) => item.key);
|
||||
expect(items).toEqual(['overview']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgentSection tabs by role', () => {
|
||||
const keys = (actions?: string[]) =>
|
||||
getSectionItems(
|
||||
buildAgentSection(
|
||||
'a1',
|
||||
'Bot',
|
||||
false,
|
||||
actions ? { access: 'editor', allowed_actions: actions } : undefined,
|
||||
),
|
||||
).map((item) => item.key);
|
||||
|
||||
it('shows every tab before the agent has loaded', () => {
|
||||
expect(keys()).toEqual(['overview', 'logs', 'schedules']);
|
||||
});
|
||||
|
||||
it('shows an editor all three tabs', () => {
|
||||
expect(keys(['view', 'view_logs', 'manage_schedules'])).toEqual([
|
||||
'overview',
|
||||
'logs',
|
||||
'schedules',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a viewer no tabs, or Logs when the owner shares them', () => {
|
||||
expect(keys(['pin', 'use'])).toEqual([]);
|
||||
expect(keys(['pin', 'use', 'view_logs'])).toEqual(['logs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('depthOf', () => {
|
||||
it('puts chats, sections and records on their own level', () => {
|
||||
expect(depthOf(null)).toBe(0);
|
||||
|
||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user