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"; "use client";
import { BookMarked } from "lucide-react"; import { BookMarked } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; 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 { useKnowledgeActions } from "@/core/knowledge/hooks";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
@@ -29,130 +11,37 @@ import { Tooltip } from "./tooltip";
export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) { export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) {
const { thread } = useThread(); const { thread } = useThread();
const navigate = useNavigate();
const { ingestThread } = useKnowledgeActions(); 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) { if (thread.messages.length === 0) {
return null; 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 () => { const handleSave = async () => {
try { try {
const result = await ingestThread.mutateAsync({ await ingestThread.mutateAsync({
threadId, threadId,
payload: { payload: { background: true },
title: title.trim() || null,
tags: tags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status,
mode,
include_sources: includeSources,
},
}); });
toast.success("已保存到知识库", { toast.success("已加入知识库保存队列,正在后台整理…");
action: {
label: "查看",
onClick: () =>
navigate(`/page/workspace/knowledge/${result.note.id}`),
},
});
setOpen(false);
setTitle("");
setTags("");
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败"); toast.error(error instanceof Error ? error.message : "保存失败");
} }
}; };
return ( return (
<> <Tooltip content="保存到知识库(后台异步整理)">
<Tooltip content="保存到知识库"> <Button
<Button className="text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground" variant="ghost"
variant="ghost" onClick={() => void handleSave()}
onClick={() => setOpen(true)} disabled={ingestThread.isPending}
> >
<BookMarked /> <BookMarked />
保存 {ingestThread.isPending ? "保存中…" : "保存"}
</Button> </Button>
</Tooltip> </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>
</>
); );
} }
+5 -1
View File
@@ -102,7 +102,11 @@ export async function searchKnowledge(query: string, limit = 8) {
} }
export async function ingestThread(threadId: string, payload: IngestThreadPayload = {}) { 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)}`, { await apiFetch(`${baseUrl()}/ingest/thread/${encodeURIComponent(threadId)}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
+2
View File
@@ -70,6 +70,8 @@ export interface IngestThreadPayload {
status?: KnowledgeStatus; status?: KnowledgeStatus;
tags?: string[]; tags?: string[];
include_sources?: boolean; include_sources?: boolean;
/** When true the backend sediments asynchronously and returns immediately. */
background?: boolean;
} }
export interface BatchIngestPayload { export interface BatchIngestPayload {
@@ -1229,6 +1229,8 @@ export default function AdminUserLeaderboardPage() {
return ( return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white"> <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"> <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="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* 标题维度:仅承载页面标题与说明 */} {/* 标题维度:仅承载页面标题与说明 */}
@@ -1448,7 +1450,6 @@ export default function AdminUserLeaderboardPage() {
</div> </div>
</div> </div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6"> <div className="space-y-5 p-6">
{isLoading && ( {isLoading && (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368] dark:text-slate-400"> <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]); }, [threads, search, onlyFavorites]);
// Pinned conversations are surfaced in their own section and excluded from // Pinned conversations are surfaced in their own section on the first page
// the paginated list so they stay visible regardless of the current 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( const pinnedThreads = useMemo(
() => filteredThreads.filter(isThreadPinned), () => filteredThreads.filter(isThreadPinned),
[filteredThreads], [filteredThreads],
@@ -200,7 +201,7 @@ export default function ChatsPage() {
<main className="min-h-0 flex-1"> <main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4"> <ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col"> <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"> <div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
{t.chats.pinnedSection} {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: if user_id:
stmt = stmt.where(UserRow.id == 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) 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( 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.run_count, 0) + func.coalesce(tool_agg.c.tool_call_count, 0)),
desc(func.coalesce(run_agg.c.total_tokens, 0)), desc(func.coalesce(run_agg.c.total_tokens, 0)),
@@ -15,7 +15,7 @@ import logging
import mimetypes import mimetypes
from typing import Any from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -40,6 +40,10 @@ class IngestThreadRequest(BaseModel):
status: str = Field(default="approved", description="draft | approved") status: str = Field(default="approved", description="draft | approved")
tags: list[str] = Field(default_factory=list) tags: list[str] = Field(default_factory=list)
include_sources: bool = True 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): 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 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 # Ingest
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@router.post("/ingest/thread/{thread_id}") @router.post("/ingest/thread/{thread_id}")
@require_auth @require_auth
@require_permission("threads", "read", owner_check=True) @require_permission("threads", "read", owner_check=True)
async def ingest_thread(thread_id: str, body: IngestThreadRequest, request: Request) -> dict[str, Any]: 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.""" """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) service = get_knowledge_service(request)
checkpointer = get_checkpointer(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) values = await _read_thread_values(checkpointer, thread_id)
messages, title, batches = _messages_and_meta(values) messages, title, batches = _messages_and_meta(values)
if not messages: if not messages: