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

@@ -1,27 +1,9 @@
"use client";
import { BookMarked } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
import { useThread } from "./messages/context";
@@ -29,130 +11,37 @@ import { Tooltip } from "./tooltip";
export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) {
const { thread } = useThread();
const navigate = useNavigate();
const { ingestThread } = useKnowledgeActions();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [status, setStatus] = useState<"approved" | "draft">("approved");
const [mode, setMode] = useState<"summary" | "full">("summary");
const [includeSources, setIncludeSources] = useState(true);
if (thread.messages.length === 0) {
return null;
}
// One click → fire-and-forget. The backend sediments the thread into the
// wiki asynchronously (title/tags auto-generated), so the click never blocks.
const handleSave = async () => {
try {
const result = await ingestThread.mutateAsync({
await ingestThread.mutateAsync({
threadId,
payload: {
title: title.trim() || null,
tags: tags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status,
mode,
include_sources: includeSources,
},
payload: { background: true },
});
toast.success("已保存到知识库", {
action: {
label: "查看",
onClick: () =>
navigate(`/page/workspace/knowledge/${result.note.id}`),
},
});
setOpen(false);
setTitle("");
setTags("");
toast.success("已加入知识库保存队列,正在后台整理…");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<>
<Tooltip content="保存到知识库">
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={() => setOpen(true)}
>
<BookMarked />
保存
</Button>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>保存到知识库</DialogTitle>
<DialogDescription>
将本次对话沉淀为 obsidian-wiki 兼容的知识笔记。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
placeholder="标题(留空自动生成)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder="标签,逗号分隔"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
<div className="flex items-center gap-2">
<Select
value={status}
onValueChange={(v) => setStatus(v as "approved" | "draft")}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="approved">正式</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
</SelectContent>
</Select>
<Select
value={mode}
onValueChange={(v) => setMode(v as "summary" | "full")}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="summary">摘要</SelectItem>
<SelectItem value="full">完整</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeSources}
onChange={(e) => setIncludeSources(e.target.checked)}
className="size-4"
/>
包含搜索 / 工具来源
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
取消
</Button>
<Button
onClick={() => void handleSave()}
disabled={ingestThread.isPending}
>
{ingestThread.isPending ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
<Tooltip content="保存到知识库(后台异步整理)">
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={() => void handleSave()}
disabled={ingestThread.isPending}
>
<BookMarked />
{ingestThread.isPending ? "保存中…" : "保存"}
</Button>
</Tooltip>
);
}
+5 -1
View File
@@ -102,7 +102,11 @@ export async function searchKnowledge(query: string, limit = 8) {
}
export async function ingestThread(threadId: string, payload: IngestThreadPayload = {}) {
return parseResponse<{ note: KnowledgeNote; created: boolean }>(
return parseResponse<{
note?: KnowledgeNote;
created?: boolean;
queued?: boolean;
}>(
await apiFetch(`${baseUrl()}/ingest/thread/${encodeURIComponent(threadId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
+2
View File
@@ -70,6 +70,8 @@ export interface IngestThreadPayload {
status?: KnowledgeStatus;
tags?: string[];
include_sources?: boolean;
/** When true the backend sediments asynchronously and returns immediately. */
background?: boolean;
}
export interface BatchIngestPayload {
@@ -1229,6 +1229,8 @@ export default function AdminUserLeaderboardPage() {
return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
{/* 整页统一滚动:标题与操作工具条不再固定置顶,随内容一起滚动,避免可视区域被压缩 */}
<ScrollArea className="min-h-0 flex-1">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex flex-col gap-4">
{/* 标题维度:仅承载页面标题与说明 */}
@@ -1448,7 +1450,6 @@ export default function AdminUserLeaderboardPage() {
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6">
{isLoading && (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368] dark:text-slate-400">
+4 -3
View File
@@ -76,8 +76,9 @@ export default function ChatsPage() {
});
}, [threads, search, onlyFavorites]);
// Pinned conversations are surfaced in their own section and excluded from
// the paginated list so they stay visible regardless of the current page.
// Pinned conversations are surfaced in their own section on the first page
// only, and excluded from the paginated list (so they don't repeat on every
// page as the user pages through history).
const pinnedThreads = useMemo(
() => filteredThreads.filter(isThreadPinned),
[filteredThreads],
@@ -200,7 +201,7 @@ export default function ChatsPage() {
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{pinnedThreads.length > 0 && (
{currentPage === 1 && pinnedThreads.length > 0 && (
<>
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
{t.chats.pinnedSection}
File diff suppressed because it is too large. Load diff
@@ -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: