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. 不要包含