管理员智能体/技能管理 + 跨标签登录同步 + 上游响应异常重试

- 「设置和更多」新增「智能体管理」「技能管理」入口,与定时任务管理同级;
  独立的 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:
吉鑫升andClaude Opus 4.7 committed 2026-05-27 11:13:29 +08:00
1 parent 1642c3e694
commit eda0ccffdb
23 files changed
+660 -115

No files matched your search

@@ -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
@@ -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).
@@ -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: