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";
|
"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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user