From e6e0832c8a7ad2a35e7205d9db34d26b0039cdbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 4 Jun 2026 19:35:56 +0800 Subject: [PATCH] chore: commit local changes --- .../workspace/admin/admin-user-hub.tsx | 48 +++++++++++++--- frontend-web/src/core/auth/api.ts | 2 + frontend-web/src/pages/LlmMetricsPage.tsx | 2 +- .../app/gateway/auth/local_provider.py | 8 +++ .../app/gateway/auth/repositories/base.py | 18 ++++++ .../app/gateway/auth/repositories/sqlite.py | 27 +++++++++ .../backend/app/gateway/routers/auth.py | 14 ++++- .../app/gateway/routers/llm_metrics.py | 45 +++++++++++---- .../deerflow/persistence/llm_metrics/base.py | 3 + .../deerflow/persistence/llm_metrics/sql.py | 5 +- .../harness/deerflow/persistence/run/sql.py | 15 +++++ .../deerflow/runtime/runs/store/base.py | 9 +++ .../deerflow/runtime/scheduler/html_page.py | 39 +++++++++++++ .../deerflow/runtime/scheduler/service.py | 57 +++++++++++++++++++ .../backend/tests/test_llm_metrics.py | 23 ++++++++ 15 files changed, 291 insertions(+), 24 deletions(-) diff --git a/frontend-web/src/components/workspace/admin/admin-user-hub.tsx b/frontend-web/src/components/workspace/admin/admin-user-hub.tsx index 5000c54c..cc769ccc 100644 --- a/frontend-web/src/components/workspace/admin/admin-user-hub.tsx +++ b/frontend-web/src/components/workspace/admin/admin-user-hub.tsx @@ -1,6 +1,6 @@ "use client"; -import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react"; +import { ArrowDownWideNarrow, Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; @@ -83,6 +83,8 @@ function UserInfoPanel({ user }: { user: UserListItem }) { 普通用户 )} +
问答次数
+
{user.qa_count}
Skills
{user.skill_count}
Agents
@@ -300,21 +302,29 @@ export function AdminUserHub() { const { data: users, isLoading: usersLoading } = useAllUsers(true); const [selectedUserId, setSelectedUserId] = useState(null); const [userQuery, setUserQuery] = useState(""); - const [activeTab, setActiveTab] = useState<"info" | "audit" | "memory">("info"); + // 活动审计 放到前面 → 默认进入审计标签页。 + const [activeTab, setActiveTab] = useState<"audit" | "info" | "memory">("audit"); + // 是否按问答次数(降序)排序用户列表。 + const [sortByQa, setSortByQa] = useState(false); const filteredUsers = useMemo(() => { const needle = userQuery.trim().toLowerCase(); - const list = users ?? []; - return needle - ? list.filter((u) => u.email.toLowerCase().includes(needle)) - : list; - }, [users, userQuery]); + let list = users ?? []; + if (needle) { + list = list.filter((u) => u.email.toLowerCase().includes(needle)); + } + if (sortByQa) { + // Copy before sorting so we never mutate the query cache array. + list = [...list].sort((a, b) => (b.qa_count ?? 0) - (a.qa_count ?? 0)); + } + return list; + }, [users, userQuery, sortByQa]); const selectedUser = users?.find((u) => u.id === selectedUserId) ?? null; function selectUser(id: string) { setSelectedUserId(id); - setActiveTab("info"); + setActiveTab("audit"); } return ( @@ -338,6 +348,20 @@ export function AdminUserHub() { className="pl-8" /> + {usersLoading && (
@@ -369,6 +393,12 @@ export function AdminUserHub() { 管理员 )} + + {u.qa_count ?? 0} + ))}
@@ -388,8 +418,8 @@ export function AdminUserHub() { >
- 用户信息 活动审计 + 用户信息 记忆管理
diff --git a/frontend-web/src/core/auth/api.ts b/frontend-web/src/core/auth/api.ts index ba6258ff..1a2eb5f3 100644 --- a/frontend-web/src/core/auth/api.ts +++ b/frontend-web/src/core/auth/api.ts @@ -9,6 +9,8 @@ export interface UserListItem { system_role: "admin" | "user"; skill_count: number; agent_count: number; + /** 问答次数:用户提问的次数(一次提问算一次)。 */ + qa_count: number; } export async function listAllUsers(): Promise { diff --git a/frontend-web/src/pages/LlmMetricsPage.tsx b/frontend-web/src/pages/LlmMetricsPage.tsx index 06e01bbe..723114d2 100644 --- a/frontend-web/src/pages/LlmMetricsPage.tsx +++ b/frontend-web/src/pages/LlmMetricsPage.tsx @@ -310,7 +310,7 @@ const errorToastDarkOptions = { { setUser(e.target.value); setPage(0); diff --git a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py index ddf38ac6..74527c2e 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py +++ b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py @@ -71,6 +71,14 @@ class LocalAuthProvider(AuthProvider): """ return await self._repo.find_user_by_email_local_part(local_part) + async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]: + """Fuzzy-match users by a substring of their email local part. + + Backs the admin LLM-metrics username filter — returns every account + whose username contains ``substring`` (case-insensitive). + """ + return await self._repo.find_users_by_email_local_part_like(substring, limit=limit) + async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User: """Create a new local user. diff --git a/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py b/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py index ca0949d9..9d71548b 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py +++ b/offline-backend-20260512/backend/app/gateway/auth/repositories/base.py @@ -81,6 +81,24 @@ class UserRepository(ABC): """ raise NotImplementedError + async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list["User"]: + """Fuzzy-match users whose email local part *contains* ``substring``. + + Backs the admin LLM-metrics username filter (按用户名模糊搜索). Unlike + :meth:`find_user_by_email_local_part` (exact prefix → single account), + this returns every matching account. Case-insensitive substring match + against the part before ``@``. Non-overriding repositories return an + empty list. + + Args: + substring: Case-insensitive substring to match within the local part. + limit: Maximum number of users to return. + + Returns: + Matching users (may be empty). + """ + return [] + @abstractmethod async def update_user(self, user: User) -> User: """Update an existing user. diff --git a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py index d498cb8b..dde62309 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py +++ b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py @@ -109,6 +109,33 @@ class SQLiteUserRepository(UserRepository): row = result.scalar_one_or_none() return self._row_to_user(row) if row is not None else None + async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]: + term = (substring or "").strip().lower() + if not term: + return [] + # Escape SQL LIKE wildcards so a literal ``_``/``%`` in the query does + # not turn into a wildcard. ``\`` is the chosen escape char. + escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + pattern = f"%{escaped}%" + # Prefilter in SQL on the whole email (portable across sqlite/postgres/ + # mysql), then keep only rows whose *local part* contains the term so a + # domain-only hit (e.g. searching "gmail") doesn't pull unrelated users. + stmt = ( + select(UserRow) + .where(func.lower(UserRow.email).like(pattern, escape="\\")) + .order_by(UserRow.created_at.asc()) + .limit(max(1, limit)) + ) + async with self._sf() as session: + result = await session.execute(stmt) + rows = result.scalars().all() + out: list[User] = [] + for row in rows: + local = (row.email or "").split("@", 1)[0].lower() + if term in local: + out.append(self._row_to_user(row)) + return out + async def update_user(self, user: User) -> User: async with self._sf() as session: row = await session.get(UserRow, str(user.id)) diff --git a/offline-backend-20260512/backend/app/gateway/routers/auth.py b/offline-backend-20260512/backend/app/gateway/routers/auth.py index c6807432..ab671066 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/auth.py +++ b/offline-backend-20260512/backend/app/gateway/routers/auth.py @@ -22,7 +22,7 @@ from app.gateway.auth import ( from app.gateway.auth.config import get_auth_config from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse from app.gateway.csrf_middleware import is_secure_request -from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_skill_store +from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store logger = logging.getLogger(__name__) @@ -749,6 +749,7 @@ class UserListItem(BaseModel): system_role: Literal["admin", "user"] skill_count: int agent_count: int + qa_count: int = 0 # 问答次数: number of runs (one run = one question) @router.get("/users", response_model=list[UserListItem]) @@ -757,11 +758,19 @@ async def list_all_users( skill_store: SkillStore = Depends(get_skill_store), agent_store: AgentStore = Depends(get_agent_store), ): - """List all users with their skill and agent counts. Admin only.""" + """List all users with their skill, agent and Q&A counts. Admin only.""" user = await get_current_user_from_request(request) if user.system_role != "admin": raise HTTPException(status_code=403, detail="Admin only") users = await get_local_provider().list_users() + # One GROUP BY query for everyone's 问答次数, so the list stays a single + # aggregate read instead of a per-user count. Best-effort: if the run store + # is unavailable the column simply renders as 0 rather than failing the list. + try: + qa_counts = await get_run_store(request).count_by_user() + except Exception: + logger.warning("Failed to load per-user run counts for user list", exc_info=True) + qa_counts = {} result = [] for u in users: uid = str(u.id) @@ -772,6 +781,7 @@ async def list_all_users( system_role=u.system_role, skill_count=await skill_store.count_owned(uid), agent_count=await agent_store.count_owned(uid), + qa_count=qa_counts.get(uid, 0), ) ) return result diff --git a/offline-backend-20260512/backend/app/gateway/routers/llm_metrics.py b/offline-backend-20260512/backend/app/gateway/routers/llm_metrics.py index 58f8b123..f78e690c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/llm_metrics.py +++ b/offline-backend-20260512/backend/app/gateway/routers/llm_metrics.py @@ -121,6 +121,26 @@ async def _resolve_user_name_to_id(name: str | None) -> str | None: return str(user.id) if user is not None else None +async def _resolve_user_names_to_ids(name: str | None) -> list[str]: + """Fuzzy-resolve a username fragment to every matching account's UUID. + + Powers the 按用户名模糊搜索 filter: typing part of a username matches all + users whose email local part contains it. Returns an empty list when the + fragment matches nobody, so the caller can short-circuit to zero rows. + """ + if not name or not name.strip(): + return [] + try: + provider = get_local_provider() + except Exception: + return [] + try: + users = await provider.find_users_by_email_local_part_like(name.strip()) + except Exception: + return [] + return [str(u.id) for u in users] + + async def _attach_usernames(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: """Resolve each row's ``user_id`` UUID to a friendly ``user_name``. @@ -161,9 +181,11 @@ def _build_query( until: str | None, limit: int, offset: int, + user_ids: list[str] | None = None, ) -> LlmMetricsQuery: return LlmMetricsQuery( user_id=user_id or None, + user_ids=user_ids or None, model_name=model_name or None, status=status or None, since=_parse_dt(since), @@ -187,14 +209,15 @@ async def list_llm_metrics( ) -> LlmMetricsListResponse: await _require_admin(request) store = _require_store(request) + user_ids: list[str] | None = None if not user_id and user_name: - resolved = await _resolve_user_name_to_id(user_name) - if resolved is None: - # Username unknown → no rows; short-circuit instead of a global scan. + user_ids = await _resolve_user_names_to_ids(user_name) + if not user_ids: + # No username matches → no rows; short-circuit the global scan. return LlmMetricsListResponse(items=[], total=0, limit=limit, offset=offset) - user_id = resolved query = _build_query( user_id=user_id, + user_ids=user_ids, model_name=model_name, status=status, since=since, @@ -236,14 +259,13 @@ async def export_llm_metrics( """Stream the full filtered metric set as an ``.xlsx`` file.""" await _require_admin(request) store = _require_store(request) + user_ids: list[str] | None = None if not user_id and user_name: - resolved = await _resolve_user_name_to_id(user_name) - if resolved is None: - # No matching user — return an empty xlsx so the user still gets a - # file (matching the list view's empty state). - user_id = "__no_such_user__" - else: - user_id = resolved + user_ids = await _resolve_user_names_to_ids(user_name) + if not user_ids: + # No matching user — sentinel id that matches nothing, so the export + # still produces an empty xlsx (matching the list view's empty state). + user_ids = ["__no_such_user__"] try: from openpyxl import Workbook @@ -252,6 +274,7 @@ async def export_llm_metrics( query = _build_query( user_id=user_id, + user_ids=user_ids, model_name=model_name, status=status, since=since, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/base.py index a634d136..34d10924 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/base.py @@ -34,6 +34,9 @@ class LlmMetricsQuery: """Filter set for the admin list / export endpoints.""" user_id: str | None = None + # Multiple user ids — set when a fuzzy username search resolves to several + # accounts. Takes precedence over ``user_id`` when non-empty. + user_ids: list[str] | None = None model_name: str | None = None status: str | None = None since: datetime | None = None diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/sql.py index f2f285e6..bb3159ba 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/sql.py @@ -58,7 +58,9 @@ class SqlLlmMetricsStore(LlmMetricsStore): logger.warning("Failed to persist llm_call_metrics row %s", metric.id, exc_info=True) def _apply_filters(self, stmt, query: LlmMetricsQuery): - if query.user_id: + if query.user_ids: + stmt = stmt.where(LlmCallMetricRow.user_id.in_(query.user_ids)) + elif query.user_id: stmt = stmt.where(LlmCallMetricRow.user_id == query.user_id) if query.model_name: stmt = stmt.where(LlmCallMetricRow.model_name == query.model_name) @@ -92,6 +94,7 @@ class SqlLlmMetricsStore(LlmMetricsStore): while True: page_query = LlmMetricsQuery( user_id=query.user_id, + user_ids=query.user_ids, model_name=query.model_name, status=query.status, since=query.since, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/run/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/run/sql.py index 7639b977..d3f06074 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/run/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/run/sql.py @@ -205,6 +205,21 @@ class RunRepository(RunStore): await session.execute(update(RunRow).where(RunRow.run_id == run_id).values(**values)) await session.commit() + async def count_by_user(self) -> dict[str, int]: + """Return ``{user_id: run_count}`` via a single GROUP BY query. + + Each run is one user question, so this is the source for the admin + user center's 问答次数 column. Runs without a ``user_id`` are skipped. + """ + stmt = ( + select(RunRow.user_id, func.count().label("cnt")) + .where(RunRow.user_id.is_not(None)) + .group_by(RunRow.user_id) + ) + async with self._sf() as session: + rows = (await session.execute(stmt)).all() + return {r.user_id: r.cnt for r in rows} + async def aggregate_tokens_by_thread(self, thread_id: str) -> dict[str, Any]: """Aggregate token usage via a single SQL GROUP BY query.""" _completed = RunRow.status.in_(("success", "error")) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/store/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/store/base.py index ebfa6584..fe210ec4 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/store/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/store/base.py @@ -93,3 +93,12 @@ class RunStore(abc.ABC): by_caller ({lead_agent, subagent, middleware}). """ pass + + async def count_by_user(self) -> dict[str, int]: + """Return ``{user_id: run_count}`` across all runs. + + One run corresponds to one user submission, so this backs the admin + user center's 问答次数 (Q&A count) column — each question counts once. + Non-overriding stores return an empty mapping (treated as 0 per user). + """ + return {} diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py index cf38335d..83f900c8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py @@ -38,6 +38,11 @@ _DANGEROUS_URL_RE = re.compile(r"^\s*(javascript|vbscript|data:text/html)", re.I # Attributes carrying a URL we must scheme-check. _URL_ATTRS = frozenset({"href", "src", "action", "formaction", "xlink:href", "background", "poster"}) +_LOCAL_DOC_LINK_RE = re.compile( + r"^(?![a-z][a-z0-9+.-]*:)(?!//)(?P[^#?]*\.(?:html?|md|markdown))(?P\?[^#]*)?(?P#.+)?$", + re.IGNORECASE, +) + def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]: """Split a ``---`` YAML frontmatter block from the body of a SKILL.md. @@ -225,6 +230,38 @@ def sanitize_html(html: str) -> tuple[str, list[str]]: return str(soup), removed +def constrain_same_page_anchor_links(html: str) -> tuple[str, list[str]]: + """Keep generated HTML document jumps inside the current HTML file.""" + if not html or not isinstance(html, str): + return "", [] + try: + from bs4 import BeautifulSoup + except Exception: # pragma: no cover - bs4 is a declared dependency + logger.warning("BeautifulSoup unavailable; returning HTML links unconstrained") + return html, [] + + changed: list[str] = [] + soup = BeautifulSoup(html, "html.parser") + for tag in soup.find_all("a"): + href = tag.get("href") + if not isinstance(href, str): + continue + value = href.strip() + if not value or value.startswith("#"): + continue + match = _LOCAL_DOC_LINK_RE.match(value) + if not match: + continue + fragment = match.group("fragment") + if fragment: + tag["href"] = fragment + changed.append(f"{value}->{fragment}") + else: + del tag.attrs["href"] + changed.append(f"{value}->removed") + return str(soup), changed + + def validate_html(html: str) -> list[str]: """Return a list of structural problems with ``html`` (empty == valid). @@ -299,6 +336,7 @@ _GENERATION_TEMPLATE = """你是一个内网页面生成助手。请根据输入 6. 不要包含