feat: 知识库一键后台保存 + 对话置顶仅首页 + 排行榜页面增强
- 知识库「保存」改为一键触发,无需填表;后端经 BackgroundTasks 异步沉淀到 wiki,点击立即返回 {queued:true}
- 对话列表置顶分区仅在第一页展示,翻页后不再重复顶置
- 公开/管理员排行榜页面重构与维度增强;排行榜页改为整页统一滚动
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user