feat: 知识库一键后台保存 + 对话置顶仅首页 + 排行榜页面增强

- 知识库「保存」改为一键触发,无需填表;后端经 BackgroundTasks 异步沉淀到 wiki,点击立即返回 {queued:true}
- 对话列表置顶分区仅在第一页展示,翻页后不再重复顶置
- 公开/管理员排行榜页面重构与维度增强;排行榜页改为整页统一滚动

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-08 19:59:55 +08:00
1 parent 24db448cfd
commit 92a46003f8
8 files changed
+854 -304

No files matched your search

@@ -808,6 +808,10 @@ async def _query_user_leaderboard(
if user_id:
stmt = stmt.where(UserRow.id == user_id)
stmt = _apply_user_scope(stmt, UserRow.id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
# 排行榜只统计真正产生过问答的用户;从未问答的账号(仅注册、零活跃)不计入榜单与人数。
# 个人分析(指定 user_id)不受此限制,仍可查看任意单个用户。
if user_id is None:
stmt = stmt.where(func.coalesce(run_agg.c.qa_count, 0) > 0)
stmt = stmt.order_by(
desc(func.coalesce(run_agg.c.run_count, 0) + func.coalesce(tool_agg.c.tool_call_count, 0)),
desc(func.coalesce(run_agg.c.total_tokens, 0)),
@@ -15,7 +15,7 @@ import logging
import mimetypes
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
@@ -40,6 +40,10 @@ class IngestThreadRequest(BaseModel):
status: str = Field(default="approved", description="draft | approved")
tags: list[str] = Field(default_factory=list)
include_sources: bool = True
background: bool = Field(
default=False,
description="When true, sediment the thread asynchronously in a background task and return immediately.",
)
class BatchIngestRequest(BaseModel):
@@ -117,16 +121,64 @@ def _messages_and_meta(values: dict[str, Any]) -> tuple[list[dict[str, Any]], st
return messages, title, batches
async def _ingest_thread_background(service, checkpointer, thread_id: str, body: IngestThreadRequest, created_by: str | None) -> None:
"""Sediment a thread into a note without an HTTP context.
Runs after the response is sent (FastAPI ``BackgroundTasks``); ``created_by``
must be captured by the request handler since the user contextvar is gone
by the time this executes. All failures are swallowed (logged) — the user
has already been told the save was queued.
"""
try:
values = await _read_thread_values(checkpointer, thread_id)
messages, title, batches = _messages_and_meta(values)
if not messages:
logger.warning("Background knowledge ingest skipped: thread %s has no messages", sanitize_log_param(thread_id))
return
await service.capture_thread(
thread_id=thread_id,
messages=messages,
thread_title=title,
mode=body.mode,
title=body.title,
status=body.status,
tags=body.tags,
include_sources=body.include_sources,
reference_batches=batches,
created_by=created_by,
)
except EmptyThreadError:
logger.warning("Background knowledge ingest skipped: thread %s has no sediment-able content", sanitize_log_param(thread_id))
except Exception:
logger.exception("Background knowledge ingest failed for thread %s", sanitize_log_param(thread_id))
# --------------------------------------------------------------------------- #
# Ingest
# --------------------------------------------------------------------------- #
@router.post("/ingest/thread/{thread_id}")
@require_auth
@require_permission("threads", "read", owner_check=True)
async def ingest_thread(thread_id: str, body: IngestThreadRequest, request: Request) -> dict[str, Any]:
"""Sediment a single thread into a knowledge note."""
async def ingest_thread(thread_id: str, body: IngestThreadRequest, request: Request, background_tasks: BackgroundTasks) -> dict[str, Any]:
"""Sediment a single thread into a knowledge note.
With ``background=true`` the heavy capture (read checkpoint → extract →
write vault + DB) is deferred to a background task and the endpoint returns
``{"queued": true}`` immediately, so the user's "保存" click never blocks.
"""
service = get_knowledge_service(request)
checkpointer = get_checkpointer(request)
if body.background:
# Capture the user id now — the contextvar is gone once the task runs.
background_tasks.add_task(
_ingest_thread_background,
service,
checkpointer,
thread_id,
body,
get_effective_user_id(),
)
return {"queued": True}
values = await _read_thread_values(checkpointer, thread_id)
messages, title, batches = _messages_and_meta(values)
if not messages: