管理员智能体/技能管理 + 跨标签登录同步 + 上游响应异常重试
- 「设置和更多」新增「智能体管理」「技能管理」入口,与定时任务管理同级; 独立的 AdminAgentsPage/AdminSkillsPage 复用 AgentCard/SkillRow, 管理员可查看并编辑/删除所有用户的智能体与技能。 - 后端 /api/agents、/api/skills 增加 ?admin=true 越权列表;agents 端 放宽 _get_editable_or_raise 让管理员可编辑/删除任意智能体;新增 list_all/update_any/delete_any(agents)与 list_all(skills)。 - 新增 AuthSyncWatcher:监听 localStorage["deerflow.auth"] 的 storage 事件,user_id 变化时自动 reload,修复多标签切换登录后老页面点击 「新对话」偶发 404 的问题。 - LLMErrorHandlingMiddleware 将 bad_response_status_code / JSON decode 等上游网关乱码识别为可重试的 upstream 类故障。 - 侧边栏「任务管理」改名为「定时任务」。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1642c3e694
commit
eda0ccffdb
23 files changed
+660
-115
No files matched your search
@@ -297,17 +297,21 @@ async def _get_editable_or_raise(
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
||||
|
||||
'admin' is granted only when the agent is built-in (user_id IS NULL) and the
|
||||
caller is a system admin. Custom agents owned by other users still 403.
|
||||
Admins may edit any agent — built-in (``user_id IS NULL``) or owned by
|
||||
another user — so the admin console can manage the full agent catalog.
|
||||
Non-admin callers are still restricted to agents they own.
|
||||
"""
|
||||
owned = await store.get_owned(agent_id, user_id)
|
||||
if owned is not None:
|
||||
return owned, "owner"
|
||||
if is_admin:
|
||||
record = await store.get_any(agent_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
return record, "admin"
|
||||
visible = await store.get_visible(agent_id, user_id)
|
||||
if visible is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
if is_admin and visible.get("user_id") is None:
|
||||
return visible, "admin"
|
||||
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
||||
|
||||
|
||||
@@ -321,6 +325,14 @@ async def list_agents(
|
||||
request: Request,
|
||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
|
||||
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
|
||||
admin: bool = Query(
|
||||
default=False,
|
||||
description=(
|
||||
"Admin-only override. When ``true`` and the caller is a system admin, "
|
||||
"every agent is returned (built-in + every user's agents + private-square "
|
||||
"entries) regardless of the usual ownership/visibility filter."
|
||||
),
|
||||
),
|
||||
) -> AgentsListResponse:
|
||||
_require_agents_api_enabled()
|
||||
|
||||
@@ -329,7 +341,12 @@ async def list_agents(
|
||||
tag_store: TagStore = get_tag_store(request)
|
||||
await _sync_legacy_agents(store)
|
||||
user_id = _current_user_id(request)
|
||||
agents = await store.list_visible(user_id)
|
||||
if admin:
|
||||
if not _is_admin_user(request):
|
||||
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
|
||||
agents = await store.list_all()
|
||||
else:
|
||||
agents = await store.list_visible(user_id)
|
||||
responses = [_record_to_response(a, include_soul=True) for a in agents]
|
||||
|
||||
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
||||
@@ -527,7 +544,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
if not update_data:
|
||||
record = current
|
||||
elif edit_mode == "admin":
|
||||
record = await store.update_builtin(agent_id, update_data)
|
||||
# Admins may edit built-in (``user_id IS NULL``) and any user-owned
|
||||
# agent — ``update_any`` skips the ownership check that ``update``
|
||||
# and ``update_builtin`` enforce.
|
||||
record = await store.update_any(agent_id, update_data)
|
||||
else:
|
||||
record = await store.update(agent_id, user_id, update_data)
|
||||
if record is None:
|
||||
@@ -614,7 +634,7 @@ async def delete_agent(agent_id: str, request: Request) -> None:
|
||||
)
|
||||
|
||||
if edit_mode == "admin":
|
||||
deleted = await store.delete_builtin(agent_id)
|
||||
deleted = await store.delete_any(agent_id)
|
||||
else:
|
||||
deleted = await store.delete(agent_id, user_id)
|
||||
if not deleted:
|
||||
|
||||
@@ -695,11 +695,25 @@ async def list_skills(
|
||||
tag_store: TagStore = Depends(get_tag_store),
|
||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
|
||||
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
|
||||
admin: bool = Query(
|
||||
default=False,
|
||||
description=(
|
||||
"Admin-only override. When ``true`` and the caller is a system admin, "
|
||||
"every custom skill is returned (including unpublished skills owned by "
|
||||
"other users) regardless of the usual ownership filter."
|
||||
),
|
||||
),
|
||||
) -> SkillsListResponse:
|
||||
try:
|
||||
user_id = _current_user_id(request)
|
||||
if admin and not _is_admin_user(request):
|
||||
raise HTTPException(status_code=403, detail="Only system admins can list all skills")
|
||||
all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
ownership = await _build_ownership_index(skill_store, user_id)
|
||||
if admin:
|
||||
ownership_records = await skill_store.list_all()
|
||||
ownership = {record["name"]: record for record in ownership_records}
|
||||
else:
|
||||
ownership = await _build_ownership_index(skill_store, user_id)
|
||||
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
|
||||
responses: list[SkillResponse] = []
|
||||
for skill in all_skills:
|
||||
@@ -720,7 +734,11 @@ async def list_skills(
|
||||
continue
|
||||
record = ownership.get(skill.name)
|
||||
if record is None:
|
||||
continue
|
||||
if not admin:
|
||||
continue
|
||||
# Admin sees every custom skill on disk even without an
|
||||
# ownership row (legacy / orphaned), so they can clean it up.
|
||||
record = None
|
||||
responses.append(
|
||||
_skill_to_response(
|
||||
skill,
|
||||
@@ -731,6 +749,8 @@ async def list_skills(
|
||||
)
|
||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||
return SkillsListResponse(skills=responses)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
||||
|
||||
+41
-7
@@ -61,6 +61,21 @@ _AUTH_PATTERNS = (
|
||||
"无权",
|
||||
"未授权",
|
||||
)
|
||||
# Upstream model gateway returned a malformed payload (typically a JSON decode
|
||||
# error wrapped as `bad_response_status_code`, or a truncated/garbled inference
|
||||
# response). These are almost always transient inference-side issues and safe
|
||||
# to retry.
|
||||
_UPSTREAM_GARBAGE_PATTERNS = (
|
||||
"bad_response_status_code",
|
||||
"bad response status",
|
||||
"expecting ',' delimiter",
|
||||
"expecting value",
|
||||
"expecting property name",
|
||||
"unexpected character",
|
||||
"unterminated string",
|
||||
"extra data",
|
||||
"jsondecodeerror",
|
||||
)
|
||||
|
||||
|
||||
class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
@@ -139,9 +154,10 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
detail = _extract_error_detail(exc)
|
||||
lowered = detail.lower()
|
||||
error_code = _extract_error_code(exc)
|
||||
error_code_lower = str(error_code).lower() if error_code is not None else ""
|
||||
status_code = _extract_status_code(exc)
|
||||
|
||||
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(str(error_code).lower(), _QUOTA_PATTERNS):
|
||||
if _matches_any(lowered, _QUOTA_PATTERNS) or _matches_any(error_code_lower, _QUOTA_PATTERNS):
|
||||
return False, "quota"
|
||||
if _matches_any(lowered, _AUTH_PATTERNS):
|
||||
return False, "auth"
|
||||
@@ -159,6 +175,11 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
return True, "transient"
|
||||
if _matches_any(lowered, _BUSY_PATTERNS):
|
||||
return True, "busy"
|
||||
# Treat upstream gateway garbage (malformed JSON, truncated responses)
|
||||
# as a retriable transient — the deterministic char-position errors we
|
||||
# see from vLLM/proxy layers usually clear on a re-roll.
|
||||
if _matches_any(lowered, _UPSTREAM_GARBAGE_PATTERNS) or _matches_any(error_code_lower, _UPSTREAM_GARBAGE_PATTERNS):
|
||||
return True, "upstream"
|
||||
|
||||
return False, "generic"
|
||||
|
||||
@@ -171,21 +192,34 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
|
||||
def _build_retry_message(self, attempt: int, wait_ms: int, reason: str) -> str:
|
||||
seconds = max(1, round(wait_ms / 1000))
|
||||
reason_text = "服务繁忙" if reason == "busy" else "网络/连接异常"
|
||||
if reason == "busy":
|
||||
reason_text = "服务繁忙"
|
||||
elif reason == "upstream":
|
||||
reason_text = "上游响应异常"
|
||||
else:
|
||||
reason_text = "网络/连接异常"
|
||||
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
|
||||
|
||||
def _build_circuit_breaker_message(self) -> str:
|
||||
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
|
||||
|
||||
def _build_user_message(self, exc: BaseException, reason: str) -> str:
|
||||
detail = _extract_error_detail(exc)
|
||||
def _build_user_message(self, reason: str) -> str:
|
||||
if reason == "quota":
|
||||
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
|
||||
if reason == "auth":
|
||||
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
|
||||
if reason in {"busy", "transient"}:
|
||||
return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
|
||||
return f"大模型因「{detail}」原因调用失败。"
|
||||
if reason == "upstream":
|
||||
return "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。"
|
||||
# NOTE: We intentionally do NOT embed the raw exception detail into the
|
||||
# user-facing message. Raw `Error code: 400 - {...}` strings — especially
|
||||
# Python repr-style dict literals — get persisted as AI messages and then
|
||||
# re-fed to the model on the next turn, where their unescaped quotes /
|
||||
# braces can corrupt the upstream JSON payload and cause deterministic
|
||||
# JSONDecodeError loops. Keep the user message clean; full detail is
|
||||
# already in the backend log via `logger.warning(..., exc_info=exc)`.
|
||||
return "大模型本次调用未能成功完成,请稍后再试或更换模型。"
|
||||
|
||||
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
|
||||
try:
|
||||
@@ -249,7 +283,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
)
|
||||
if retriable:
|
||||
self._record_failure()
|
||||
return AIMessage(content=self._build_user_message(exc, reason))
|
||||
return AIMessage(content=self._build_user_message(reason))
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
@@ -295,7 +329,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
|
||||
)
|
||||
if retriable:
|
||||
self._record_failure()
|
||||
return AIMessage(content=self._build_user_message(exc, reason))
|
||||
return AIMessage(content=self._build_user_message(reason))
|
||||
|
||||
|
||||
def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool:
|
||||
|
||||
@@ -90,3 +90,30 @@ class AgentStore(ABC):
|
||||
already verified the admin-configured private-square password.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
"""Return every agent regardless of ownership/visibility.
|
||||
|
||||
Caller is responsible for authorization — typically the caller has
|
||||
already verified ``system_role == "admin"``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update any agent row by id, regardless of ownership.
|
||||
|
||||
Caller is responsible for authorization (admin only). Returns ``None``
|
||||
if the agent does not exist.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_any(self, agent_id: str) -> bool:
|
||||
"""Delete any agent row by id, regardless of ownership.
|
||||
|
||||
Caller is responsible for authorization (admin only). Returns
|
||||
``False`` if the agent does not exist.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
+26
@@ -138,3 +138,29 @@ class MemoryAgentStore(AgentStore):
|
||||
rows = [self._agents[aid] for aid in agent_ids if aid in self._agents and self._is_visible(self._agents[aid], user_id)]
|
||||
rows.sort(key=lambda a: order_map.get(a["id"], 0))
|
||||
return [_copy(a) for a in rows]
|
||||
|
||||
async def list_private(self) -> list[dict[str, Any]]:
|
||||
rows = [_copy(a) for a in self._agents.values() if bool(a.get("is_private"))]
|
||||
rows.sort(key=lambda a: a.get("created_at") or "")
|
||||
return rows
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
rows = [_copy(a) for a in self._agents.values()]
|
||||
rows.sort(key=lambda a: a.get("created_at") or "")
|
||||
return rows
|
||||
|
||||
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
row = self._agents.get(agent_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
row["updated_at"] = _now()
|
||||
return _copy(row)
|
||||
|
||||
async def delete_any(self, agent_id: str) -> bool:
|
||||
if agent_id not in self._agents:
|
||||
return False
|
||||
self._agents.pop(agent_id, None)
|
||||
return True
|
||||
@@ -202,3 +202,31 @@ class AgentRepository(AgentStore):
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
stmt = select(AgentRow).order_by(AgentRow.created_at.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def update_any(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def delete_any(self, agent_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -68,6 +68,14 @@ class SkillStore(ABC):
|
||||
"""Return the number of skills owned by ``user_id`` (owner_user_id IS NOT NULL)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
"""Return every skill record regardless of ownership/visibility.
|
||||
|
||||
Caller is responsible for authorization (admin only).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_published_names(self) -> set[str]:
|
||||
"""Return the set of skill names with ``published = True`` (any owner).
|
||||
|
||||
+6
@@ -134,5 +134,11 @@ class MemorySkillStore(SkillStore):
|
||||
async def count_owned(self, user_id: str) -> int:
|
||||
return sum(1 for s in self._skills.values() if s.get("owner_user_id") == user_id)
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
_with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
for record in self._skills.values()
|
||||
]
|
||||
|
||||
async def list_published_names(self) -> set[str]:
|
||||
return {name for name, record in self._skills.items() if record.get("published")}
|
||||
@@ -149,6 +149,12 @@ class SkillRepository(SkillStore):
|
||||
async with self._sf() as session:
|
||||
return await session.scalar(stmt) or 0
|
||||
|
||||
async def list_all(self) -> list[dict[str, Any]]:
|
||||
stmt = select(SkillRow).order_by(SkillRow.created_at.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def list_published_names(self) -> set[str]:
|
||||
stmt = select(SkillRow.name).where(SkillRow.published.is_(True))
|
||||
async with self._sf() as session:
|
||||
|
||||
Reference in new issue
Block a user