Merge branch 'ji' into 'master'

feat: 重新设计浏览器标签页图标(智能助手中心)

See merge request knowledge-base-bj/deerflow-server!97
This commit is contained in:
董宏凯 committed 2026-06-06 01:21:19 +00:00
commit 024c8ee5a1
34 files changed
+2321 -508

No files matched your search

+2 -1
View File
@@ -13,7 +13,8 @@
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CM 助手</title>
</head>
<body>
+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="智能助手中心">
<defs>
<!-- 品牌玫红渐变填充的圆角磁贴 -->
<linearGradient id="tile" x1="6" y1="4" x2="58" y2="60" gradientUnits="userSpaceOnUse">
<stop stop-color="#FB7185"/>
<stop offset="1" stop-color="#E11D48"/>
</linearGradient>
<!-- 气泡内 AI 闪光星:玫红渐变,与白色气泡形成对比 -->
<linearGradient id="spark" x1="24" y1="18" x2="40" y2="36" gradientUnits="userSpaceOnUse">
<stop stop-color="#FB7185"/>
<stop offset="1" stop-color="#E11D48"/>
</linearGradient>
</defs>
<!-- 磁贴背景 -->
<rect width="64" height="64" rx="15" fill="url(#tile)"/>
<!-- 辅助小闪光,点出「多种智能工具」 -->
<path fill="#FFFFFF" fill-opacity="0.85"
d="M47 43 Q47.6 46.4 50.5 47 Q47.6 47.6 47 51 Q46.4 47.6 43.5 47 Q46.4 46.4 47 43 Z"/>
<!-- 助手对话气泡(白色)+ 尾巴 -->
<rect x="12" y="13" width="40" height="28" rx="10" fill="#FFFFFF"/>
<path fill="#FFFFFF" d="M21 40 H31 L19 49 Z"/>
<!-- 气泡内的 AI 闪光星 -->
<path fill="url(#spark)"
d="M32 17.5 Q33.2 25.8 41.5 27 Q33.2 28.2 32 36.5 Q30.8 28.2 22.5 27 Q30.8 25.8 32 17.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -8,7 +8,6 @@ import {
Folder,
FolderOpen,
Settings,
Trophy,
User,
Wrench,
} from "lucide-react";
@@ -261,14 +260,6 @@ export function PageSidebar({
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/user-leaderboard")}
>
<Trophy className="size-4" />
用户排行榜
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
@@ -585,17 +585,15 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
<SparklesIcon className="h-3.5 w-3.5" />
</Button>
)}
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
{isOwned && (
<Button
size="icon"
@@ -936,15 +934,13 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
)}
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<SkillDetailDialog
@@ -649,17 +649,15 @@ export function SkillRow({
const actionButtons = (
<>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
{canManage && (
<Button
size="icon"
@@ -844,15 +842,13 @@ export function SkillRow({
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
)}
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
<SkillDetailDialog
skillName={skill.name}
open={detailOpen}
@@ -133,8 +133,7 @@ export function SkillDetailDialog({
return stripYamlFrontmatter(contentData?.content ?? "");
}, [contentData]);
const defaultTab: "detail" | "source" =
skill && !skill.detail ? "source" : "detail";
const defaultTab: "detail" = "detail";
const selectedTab = activeTab ?? defaultTab;
function startEditing() {
@@ -234,10 +233,6 @@ export function SkillDetailDialog({
{tx.filesSectionTitle}
</TabsTrigger>
)}
<TabsTrigger value="source" className="text-xs">
<FileCode2Icon className="mr-1 size-3.5" />
{tx.contentSectionTitle}
</TabsTrigger>
</TabsList>
<div className="flex shrink-0 items-center gap-1">
@@ -337,13 +332,9 @@ export function SkillDetailDialog({
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
<p className="text-sm">{tx.detailEmpty}</p>
{isOwner ? (
<p className="mt-1 text-xs">
Open the SKILL.md tab to view the skill source.
</p>
) : (
{isOwner && canManage ? (
<p className="mt-1 text-xs">{tx.detailAdminOnlyHint}</p>
)}
) : null}
</div>
)}
</TabsContent>
@@ -20,9 +20,9 @@ import { cn } from "@/lib/utils";
import { resolveTagColor } from "./tag-colors";
/**
* Admin-only dialog to set the tags on one resource. Clicking a tag binds or
* unbinds it immediately (no separate save step). Only tags of ``targetType``
* are offered, enforcing "a tag may only tag its own type".
* Dialog to set the tags on one resource. Clicking a tag binds or unbinds it
* immediately (no separate save step). Only tags of ``targetType`` are offered,
* enforcing "a tag may only tag its own type".
*/
export function TagPickerDialog({
open,
@@ -4,10 +4,10 @@ import {
BellIcon,
BotIcon,
BrainIcon,
CalendarClockIcon,
ChevronsUpDown,
DatabaseZapIcon,
GaugeIcon,
CalendarClockIcon,
ChevronsUpDown,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
@@ -16,10 +16,9 @@ import {
SettingsIcon,
ShieldCheckIcon,
SparklesIcon,
TagsIcon,
TrophyIcon,
UsersIcon,
} from "lucide-react";
TagsIcon,
UsersIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -189,30 +188,20 @@ export function WorkspaceNavMenu() {
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/checkpoint-migration")
}
>
<DatabaseZapIcon />
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/checkpoint-migration")
}
>
<DatabaseZapIcon />
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
>
<UsersIcon />
用户中心
</DropdownMenuItem>
@@ -243,14 +232,12 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>
+41
View File
@@ -35,6 +35,28 @@ export interface AdminMemoryResponse {
buckets: AdminMemoryBucket[];
}
export interface AdminUserQaItem {
run_id: string;
thread_id: string;
thread_title: string;
assistant_id: string;
status: string;
model_name: string;
question: string;
answer: string;
total_tokens: number;
created_at: string;
updated_at: string;
}
export interface AdminUserQaPageResponse {
user_id: string;
page: number;
page_size: number;
total: number;
items: AdminUserQaItem[];
}
export interface AdminLeaderboardUser {
user_id: string;
email: string;
@@ -104,6 +126,9 @@ export interface AdminLeaderboardResponse {
range_days: number;
since: string;
until: string;
status?: "ready" | "running" | "refreshing";
generated_at?: string;
message?: string;
overview: AdminLeaderboardOverview;
users_by_activity: AdminLeaderboardUser[];
users_by_questions: AdminLeaderboardUser[];
@@ -177,6 +202,22 @@ export async function listUserThreadMessages(
);
}
export async function listUserQa(
userId: string,
params: { page?: number; pageSize?: number; q?: string } = {},
): Promise<AdminUserQaPageResponse> {
const search = new URLSearchParams({
page: String(params.page ?? 1),
page_size: String(params.pageSize ?? 20),
});
const query = (params.q ?? "").trim();
if (query) search.set("q", query);
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/qa?${search}`),
"Failed to load user Q&A",
);
}
export async function getUserMemory(
userId: string,
): Promise<AdminMemoryResponse> {
+16
View File
@@ -9,6 +9,7 @@ import {
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
listUserQa,
listUserThreadMessages,
listUserThreads,
replaceAdminMemoryEntry,
@@ -33,6 +34,10 @@ export function useUserLeaderboard(
limit,
],
queryFn: () => getUserLeaderboard(range, limit),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "running" || status === "refreshing" ? 3000 : false;
},
});
}
@@ -71,6 +76,17 @@ export function useUserThreads(userId: string | null) {
});
}
export function useUserQa(
userId: string | null,
params: { page: number; pageSize: number; q?: string },
) {
return useQuery({
queryKey: ["admin-activity", "qa", userId, params.page, params.pageSize, params.q ?? ""],
queryFn: () => listUserQa(userId!, params),
enabled: !!userId,
});
}
/** Messages of a given thread. Disabled until a thread is picked. */
export function useUserThreadMessages(
userId: string | null,
@@ -35,7 +35,6 @@ import {
ShieldCheck,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
@@ -244,13 +243,6 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/settings/llm-metrics",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-users",
label: "用户管理",
+124 -70
View File
@@ -1,47 +1,21 @@
"use client";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useUserMemory, useUserThreadMessages, useUserThreads } from "@/core/admin/hooks";
import type { AdminMemoryBucket, AdminMessage } from "@/core/admin/api";
import { useUserMemory, useUserQa, useUserThreads } from "@/core/admin/hooks";
import type { AdminMemoryBucket, AdminUserQaItem } from "@/core/admin/api";
import { useAllUsers } from "@/core/auth/hooks";
import { cn } from "@/lib/utils";
/** Flatten a langchain message dump (or raw string / block list) into text. */
function extractText(content: unknown): string {
if (content == null) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "string") return block;
if (block && typeof block === "object") {
const text = (block as Record<string, unknown>).text;
if (typeof text === "string") return text;
}
return "";
})
.join("");
}
if (typeof content === "object") {
const inner = (content as Record<string, unknown>).content;
if (inner !== undefined) return extractText(inner);
}
return "";
}
function roleOf(message: AdminMessage): "user" | "assistant" | null {
const type = (message.type ?? "").toLowerCase();
if (type === "human") return "user";
if (type === "ai" || type === "aimessagechunk") return "assistant";
return null; // tool results and system messages are hidden from the Q&A view
}
const QA_PAGE_SIZE = 20;
function fmtTime(iso: string): string {
if (!iso) return "";
@@ -49,24 +23,39 @@ function fmtTime(iso: string): string {
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
/** A single Q&A bubble. */
function MessageRow({ message }: { message: AdminMessage }) {
const role = roleOf(message);
if (!role) return null;
const text = extractText(message.content).trim();
if (!text) return null; // skip empty AI turns (e.g. tool-call-only responses)
function fmtNumber(value: number): string {
return new Intl.NumberFormat("zh-CN").format(value || 0);
}
function QaItemCard({ item }: { item: AdminUserQaItem }) {
const isError = item.status === "error" || item.status === "timeout";
return (
<div className={cn("flex flex-col gap-1", role === "user" ? "items-start" : "items-end")}>
<span className="text-muted-foreground text-xs">
{role === "user" ? "用户提问" : "AI 回答"}
</span>
<div
className={cn(
"max-w-[85%] whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm",
role === "user" ? "bg-muted" : "bg-primary/5 border-primary/20",
)}
>
{text}
<div className="rounded-md border bg-background p-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-xs">{fmtTime(item.created_at)}</span>
<Badge variant={isError ? "destructive" : "secondary"}>{item.status || "unknown"}</Badge>
{item.model_name && <Badge variant="outline">{item.model_name}</Badge>}
{item.assistant_id && <Badge variant="outline">{item.assistant_id}</Badge>}
<span className="text-muted-foreground text-xs">{fmtNumber(item.total_tokens)} tokens</span>
</div>
{(item.thread_title || item.thread_id) && (
<div className="text-muted-foreground mb-3 truncate text-xs">
{item.thread_title || "未命名会话"} · {item.thread_id}
</div>
)}
<div className="grid gap-3">
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium">用户提问</div>
<div className="bg-muted whitespace-pre-wrap rounded-md px-3 py-2 text-sm">
{item.question || "空问题"}
</div>
</div>
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium">AI 回答</div>
<div className="border-primary/20 bg-primary/5 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border px-3 py-2 text-sm">
{item.answer || "暂无回答"}
</div>
</div>
</div>
</div>
);
@@ -115,24 +104,33 @@ export default function AdminUserActivityPage() {
const [userId, setUserId] = useState<string | null>(null);
const [threadId, setThreadId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [qaQuery, setQaQuery] = useState("");
const [qaPage, setQaPage] = useState(1);
const [tab, setTab] = useState<"qa" | "memory">("qa");
const targetUserId = searchParams.get("user");
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
userId,
threadId,
);
const { data: qaData, isLoading: qaLoading, isFetching: qaFetching } = useUserQa(userId, {
page: qaPage,
pageSize: QA_PAGE_SIZE,
q: qaQuery,
});
const { data: memory, isLoading: memoryLoading } = useUserMemory(userId);
useEffect(() => {
if (targetUserId) {
setUserId(targetUserId);
setThreadId(null);
setQaQuery("");
setQaPage(1);
setTab("qa");
}
}, [targetUserId]);
useEffect(() => {
setQaPage(1);
}, [userId, qaQuery]);
const filteredUsers = useMemo(() => {
const needle = userQuery.trim().toLowerCase();
const list = users ?? [];
@@ -144,9 +142,17 @@ export default function AdminUserActivityPage() {
const selectUser = (id: string) => {
setUserId(id);
setThreadId(null);
setQaQuery("");
setQaPage(1);
setTab("qa");
};
const clearQaFilter = () => {
setThreadId(null);
setQaQuery("");
setQaPage(1);
};
return (
<div className="flex size-full flex-col">
<div className="border-b px-6 py-4">
@@ -203,8 +209,19 @@ export default function AdminUserActivityPage() {
{/* Pane 2: threads */}
<div className="flex w-72 shrink-0 flex-col border-r">
<div className="text-muted-foreground border-b px-3 py-2 text-xs font-medium">
会话列表{threads ? `(${threads.length})` : ""}
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<span className="text-muted-foreground text-xs font-medium">
会话列表{threads ? `(${threads.length})` : ""}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!userId || (!threadId && !qaQuery)}
onClick={clearQaFilter}
>
全部
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
{!userId && (
@@ -229,6 +246,8 @@ export default function AdminUserActivityPage() {
type="button"
onClick={() => {
setThreadId(thread.thread_id);
setQaQuery(thread.thread_id);
setQaPage(1);
setTab("qa");
}}
className={cn(
@@ -270,28 +289,63 @@ export default function AdminUserActivityPage() {
<ScrollArea className="min-h-0 flex-1">
{tab === "qa" && (
<div className="p-4">
{!threadId && (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
<MessageSquareIcon className="size-6" />
请从中间列选择一个会话
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<SearchIcon className="text-muted-foreground absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={qaQuery}
onChange={(event) => {
setQaQuery(event.target.value);
setThreadId(null);
setQaPage(1);
}}
placeholder="搜索问题、回答、运行 ID 或会话 ID"
className="pl-9"
/>
</div>
{qaQuery && (
<Button type="button" variant="outline" size="sm" onClick={clearQaFilter}>
<XIcon className="size-4" />
清空
</Button>
)}
</div>
)}
{threadId && messagesLoading && (
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
<span>共 {fmtNumber(qaData?.total ?? 0)} 条问答</span>
{threadId && <Badge variant="outline">已按会话过滤</Badge>}
{qaFetching && !qaLoading && (
<span className="inline-flex items-center gap-1">
<Loader2 className="size-3 animate-spin" /> 更新中
</span>
)}
</div>
</div>
{qaLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{threadId && !messagesLoading && (
{!qaLoading && (qaData?.items.length ?? 0) === 0 && (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
<MessageSquareIcon className="size-6" />
暂无匹配的问答记录
</div>
)}
{!qaLoading && (qaData?.items.length ?? 0) > 0 && (
<div className="flex flex-col gap-3">
{(messages ?? []).map((m, i) => (
<MessageRow key={m.id ?? i} message={m} />
{(qaData?.items ?? []).map((item) => (
<QaItemCard key={item.run_id} item={item} />
))}
{(messages ?? []).every((m) => !roleOf(m)) && (
<div className="text-muted-foreground py-12 text-center text-sm">
该会话暂无问答记录
</div>
)}
<Pagination
page={qaPage}
pageSize={QA_PAGE_SIZE}
total={qaData?.total ?? 0}
onPageChange={setQaPage}
className="pt-2"
/>
</div>
)}
</div>
@@ -39,6 +39,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -63,6 +64,7 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__";
const LEADERBOARD_PAGE_SIZE = 20;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
@@ -722,11 +724,13 @@ function UserTable({
users,
tab,
selectedUserId,
rankOffset = 0,
onSelect,
}: {
users: AdminLeaderboardUser[];
tab: UserRankTab;
selectedUserId: string | null;
rankOffset?: number;
onSelect: (user: AdminLeaderboardUser) => void;
}) {
const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
@@ -749,7 +753,7 @@ function UserTable({
)}
>
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]">
{index + 1}
{rankOffset + index + 1}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
@@ -1041,6 +1045,7 @@ export default function AdminUserLeaderboardPage() {
const [groupRules, setGroupRules] = useState("zero,two");
const [showGroupStats, setShowGroupStats] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [userPage, setUserPage] = useState(1);
const apiRange = useMemo(
() => rangeForApi(range),
[range],
@@ -1082,7 +1087,23 @@ export default function AdminUserLeaderboardPage() {
}, [data, query, tab]);
const overview = data?.overview;
const leaderboardStatus = data?.status;
const leaderboardPending = leaderboardStatus === "running" || leaderboardStatus === "refreshing";
const leaderboardHasRows = rankedUsers.length > 0 || (overview?.total_runs ?? 0) > 0 || (overview?.total_users ?? 0) > 0;
const topUsers = rankedUsers.slice(0, 3);
const userPageCount = Math.max(1, Math.ceil(rankedUsers.length / LEADERBOARD_PAGE_SIZE));
const visibleUsers = useMemo(() => {
const start = (userPage - 1) * LEADERBOARD_PAGE_SIZE;
return rankedUsers.slice(start, start + LEADERBOARD_PAGE_SIZE);
}, [rankedUsers, userPage]);
useEffect(() => {
setUserPage(1);
}, [apiRange.since, apiRange.until, query, tab]);
useEffect(() => {
setUserPage((page) => Math.min(page, userPageCount));
}, [userPageCount]);
useEffect(() => {
if (rankedUsers.length === 0) {
@@ -1411,9 +1432,14 @@ export default function AdminUserLeaderboardPage() {
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6">
{isLoading && (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]">
<Loader2 className="size-4 animate-spin" />
加载统计数据
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368]">
<div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" />
<span>正在统计排行榜数据</span>
</div>
<div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
线上用户较多或时间范围较大时,统计时间可能较长,请耐心等待。为避免影响其他接口,系统会限制排行榜统计的并发数量。
</div>
</div>
)}
@@ -1423,8 +1449,29 @@ export default function AdminUserLeaderboardPage() {
</div>
)}
{!isLoading && data && overview && (
{!isLoading && !error && leaderboardPending && !leaderboardHasRows && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-[#F1D6D6] bg-white px-6 py-16 text-center text-sm text-[#5F6368] shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex items-center gap-2 font-medium text-[#202124] dark:text-white">
<Loader2 className="size-4 animate-spin text-[#C1121F]" />
<span>排行榜正在后台统计</span>
</div>
<div className="max-w-lg text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
{data?.message || "统计任务已放到后台执行,页面会自动刷新结果。这样排行榜慢慢算,不会长时间占住当前接口请求。"}
</div>
</div>
)}
{!isLoading && data && overview && (!leaderboardPending || leaderboardHasRows) && (
<>
{leaderboardPending && leaderboardHasRows && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-[#FDE68A] bg-[#FFFBEB] px-4 py-3 text-sm text-[#92400E] dark:border-amber-200/25 dark:bg-amber-300/10 dark:text-amber-100">
<div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" />
<span>{data.message || "正在后台刷新排行榜,当前展示缓存结果。"}</span>
</div>
{data.generated_at && <span className="text-xs">缓存生成于 {fmtTime(data.generated_at)}</span>}
</div>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Users} accent="crimson" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
<StatCard icon={MousePointerClick} accent="teal" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
@@ -1492,11 +1539,23 @@ export default function AdminUserLeaderboardPage() {
</div>
</div>
<UserTable
users={rankedUsers}
users={visibleUsers}
tab={tab}
selectedUserId={selectedUserId}
rankOffset={(userPage - 1) * LEADERBOARD_PAGE_SIZE}
onSelect={(user) => setSelectedUserId(user.user_id)}
/>
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-[#6B7280] dark:text-slate-400">
<span>
共 {fmtNumber(rankedUsers.length)} 位用户,当前第 {userPage} / {userPageCount} 页
</span>
<Pagination
page={userPage}
pageSize={LEADERBOARD_PAGE_SIZE}
total={rankedUsers.length}
onPageChange={setUserPage}
/>
</div>
</section>
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
+8 -3
View File
@@ -131,8 +131,8 @@ export default function AgentChatPage() {
const pendingMessageHandled = useRef(false);
useEffect(() => {
if (pendingMessageHandled.current) return;
if (!agent_id || !isNewThread || hasStartedConversation) return;
let pending: { agentId?: string; text?: string } | null = null;
if (!agent_id) return;
let pending: { agentId?: string; threadId?: string; text?: string } | null = null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (raw) pending = JSON.parse(raw);
@@ -140,10 +140,15 @@ export default function AgentChatPage() {
pending = null;
}
if (!pending || pending.agentId !== agent_id || !pending.text) return;
if (pending.threadId) {
if (pending.threadId !== threadId) return;
} else if (!isNewThread) {
return;
}
pendingMessageHandled.current = true;
sessionStorage.removeItem("pendingAgentMessage");
handleSubmit({ text: pending.text, files: [] });
}, [agent_id, isNewThread, hasStartedConversation, handleSubmit]);
}, [agent_id, threadId, isNewThread, handleSubmit]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
+40 -11
View File
@@ -1,9 +1,11 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "@/core/api";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import { useUserPreferencesQuery } from "@/core/user-preferences";
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
@@ -58,6 +60,8 @@ export default function ChatPage() {
const hideAdminPrompt = userPreferences?.hide_admin_prompt_prefix === true;
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentHandoffPending, setAgentHandoffPending] = useState(false);
const agentHandoffPendingRef = useRef(false);
const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector();
const { selectedArtifact } = useArtifacts();
@@ -148,15 +152,37 @@ export default function ChatPage() {
message.text &&
(!message.files || message.files.length === 0)
) {
try {
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({ agentId: selectedAgentId, text: message.text }),
);
} catch {
// sessionStorage may be unavailable in private mode; fall through.
}
router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`);
if (agentHandoffPendingRef.current) return;
const text = message.text.trim();
agentHandoffPendingRef.current = true;
setAgentHandoffPending(true);
setHasStartedConversation(true);
void (async () => {
try {
const created = await getAPIClient(isMock).threads.create({
metadata: { agent_id: selectedAgentId },
});
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({
agentId: selectedAgentId,
threadId: created.thread_id,
text,
}),
);
saveLastAgentId(selectedAgentId);
router.push(
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
);
} catch (err) {
agentHandoffPendingRef.current = false;
setAgentHandoffPending(false);
setHasStartedConversation(false);
toast.error(
err instanceof Error ? err.message : "创建智能体对话失败",
);
}
})();
return;
}
@@ -200,6 +226,8 @@ export default function ChatPage() {
hideAdminPrompt,
preferencesLoading,
selectedAgentId,
agentHandoffPending,
isMock,
router,
settings.context.writing_mode,
writingArtifactPath,
@@ -345,7 +373,8 @@ export default function ChatPage() {
context={settings.context}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
isUploading ||
agentHandoffPending
}
onContextChange={(context) =>
setSettings("context", context)
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Heart, Loader2, Save, Code2, Trash2, X } from "lucide-react";
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Heart, Loader2, Maximize2, Save, Code2, Trash2, X } from "lucide-react";
import { useParams, useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
@@ -9,6 +9,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
@@ -101,6 +102,32 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
return <pre className="whitespace-pre-wrap break-words text-sm">{file.content}</pre>;
}
type FullscreenPreview =
| { mode: "html"; title: string; html: string }
| { mode: "markdown"; title: string; content: string }
| { mode: "text"; title: string; content: string }
| { mode: "file"; title: string; file: ScheduledTaskResultFileContent };
function FullscreenPreviewContent({ preview }: { preview: FullscreenPreview }) {
if (preview.mode === "html") {
return <HtmlPreviewFrame html={preview.html} className="h-full w-full border-0 bg-white" />;
}
if (preview.mode === "markdown") {
return (
<MarkdownContent
className="max-w-none"
content={preview.content || "无正文内容"}
isLoading={false}
rehypePlugins={[]}
/>
);
}
if (preview.mode === "file") {
return <RenderPreview file={preview.file} />;
}
return <pre className="whitespace-pre-wrap break-words text-sm">{preview.content || "无正文内容"}</pre>;
}
type EditState = { draft: string; saving: boolean };
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
@@ -218,6 +245,7 @@ export default function ScheduledTaskRunDetailPage() {
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const [edits, setEdits] = useState<Record<number, EditState | null>>({});
const [fullscreenPreview, setFullscreenPreview] = useState<FullscreenPreview | null>(null);
const canEditRun = run?.can_edit === true;
const canDeleteRun = canEditRun;
@@ -428,7 +456,27 @@ export default function ScheduledTaskRunDetailPage() {
};
return (
<WorkspaceContainer>
<>
<Dialog open={fullscreenPreview !== null} onOpenChange={(open) => !open && setFullscreenPreview(null)}>
<DialogContent className="flex h-[96vh] w-[96vw] max-w-none flex-col gap-0 p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12">
<DialogTitle className="truncate text-base">
{fullscreenPreview?.title ?? "预览"}
</DialogTitle>
</DialogHeader>
{fullscreenPreview && (
<div
className={cn(
"min-h-0 flex-1",
fullscreenPreview.mode === "html" ? "" : "overflow-auto p-4",
)}
>
<FullscreenPreviewContent preview={fullscreenPreview} />
</div>
)}
</DialogContent>
</Dialog>
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col p-3">
@@ -544,6 +592,23 @@ export default function ScheduledTaskRunDetailPage() {
{isHtmlRun && (
<TabsContent value="html" className="m-0 p-4">
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
htmlContent &&
setFullscreenPreview({
mode: "html",
title: result.title || "HTML 预览",
html: htmlContent,
})
}
disabled={!htmlContent}
title="全屏查看 HTML"
>
<Maximize2 className="size-4" />
全屏
</Button>
<Button
size="sm"
variant="outline"
@@ -569,6 +634,24 @@ export default function ScheduledTaskRunDetailPage() {
</TabsContent>
)}
<TabsContent value="body" className="m-0 p-4">
<div className="mb-3 flex justify-end">
<Button
size="sm"
variant="outline"
onClick={() =>
setFullscreenPreview({
mode: result.format === "markdown" || result.format === "mixed" ? "markdown" : "text",
title: result.title || "正文预览",
content: result.content || "无正文内容",
})
}
disabled={!result.content}
title="全屏查看正文"
>
<Maximize2 className="size-4" />
全屏
</Button>
</div>
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
className="max-w-none"
@@ -610,6 +693,24 @@ export default function ScheduledTaskRunDetailPage() {
</div>
</div>
<div className="flex flex-wrap gap-2">
{file.previewable && !isEditing && (
<Button
size="sm"
variant="outline"
onClick={() =>
setFullscreenPreview({
mode: "file",
title: file.name,
file,
})
}
disabled={!file.content}
title="全屏查看文件"
>
<Maximize2 className="size-4" />
全屏
</Button>
)}
{isMarkdown && (
<Button
size="sm"
@@ -720,6 +821,7 @@ export default function ScheduledTaskRunDetailPage() {
</Card>
</div>
</WorkspaceBody>
</WorkspaceContainer>
</WorkspaceContainer>
</>
);
}
+12 -16
View File
@@ -45,7 +45,6 @@ import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useModels } from "@/core/models/hooks";
import {
downloadScheduledTaskRun,
@@ -188,7 +187,6 @@ export default function ScheduledTasksPage() {
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
const [subscribeNotify, setSubscribeNotify] = useState(false);
const [tagPickerTask, setTagPickerTask] = useState<ScheduledTask | null>(null);
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const sortedTasks = useMemo(
() =>
@@ -552,19 +550,17 @@ export default function ScheduledTasksPage() {
<TagChipList tags={task.tags!} className="mt-3" />
)}
<div className="mt-3 flex flex-wrap gap-2">
{isAdmin && (
<Button
size="icon"
variant="outline"
title="编辑标签"
onClick={(event) => {
event.stopPropagation();
setTagPickerTask(task);
}}
>
<TagsIcon className="size-4" />
</Button>
)}
<Button
size="icon"
variant="outline"
title="编辑标签"
onClick={(event) => {
event.stopPropagation();
setTagPickerTask(task);
}}
>
<TagsIcon className="size-4" />
</Button>
{isOwner ? (
<>
<Button
@@ -1163,7 +1159,7 @@ export default function ScheduledTasksPage() {
</DialogContent>
</Dialog>
{isAdmin && tagPickerTask && (
{tagPickerTask && (
<TagPickerDialog
open={!!tagPickerTask}
onOpenChange={(open) => !open && setTagPickerTask(null)}
+1 -7
View File
@@ -275,13 +275,7 @@ export default function StrategyRoutes() {
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
element={<TagsPage />}
/>
</Routes>
</div>
+3 -7
View File
@@ -420,13 +420,9 @@ export default function WorkspaceRoutes({
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
}
/>
<Route
@@ -378,7 +378,7 @@ def _release_singleton_lock(handle) -> None:
pass
async def _curator_scheduler_loop() -> None:
async def _curator_scheduler_loop() -> None:
"""常驻后台任务:每隔 _CURATOR_CHECK_INTERVAL_SECONDS 检查一次需不需要进化。
多 worker 部署下用文件锁做 leader 选举 —— 只有抢到锁的那个 worker 进程真正
@@ -404,11 +404,248 @@ async def _curator_scheduler_loop() -> None:
except Exception:
logger.exception("Curator scheduler tick failed (non-fatal)")
await asyncio.sleep(_CURATOR_CHECK_INTERVAL_SECONDS)
finally:
_release_singleton_lock(lock_handle)
def _warn_if_multi_worker() -> None:
finally:
_release_singleton_lock(lock_handle)
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int, *, alias: str | None = None, minimum: int = 1) -> int:
raw = os.getenv(name)
if raw is None and alias:
raw = os.getenv(alias)
if raw is None:
raw = str(default)
try:
value = int(raw)
except ValueError:
return default
return max(minimum, value)
def _env_float(name: str, default: float, *, alias: str | None = None, minimum: float = 0.0) -> float:
raw = os.getenv(name)
if raw is None and alias:
raw = os.getenv(alias)
if raw is None:
raw = str(default)
try:
value = float(raw)
except ValueError:
return default
return max(minimum, value)
_LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED = _env_bool("ADMIN_LEADERBOARD_BACKGROUND_ENABLED", True)
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_PREWARM_DAYS",
30,
)
_LEADERBOARD_SNAPSHOT_BATCH_SIZE = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_BATCH_SIZE",
1,
)
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS",
300.0,
)
_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_SLEEP_SECONDS",
2.0,
)
_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS = _env_int(
"ADMIN_LEADERBOARD_BACKGROUND_RUNNING_TTL_SECONDS",
1800,
)
_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS = _env_float(
"ADMIN_LEADERBOARD_BACKGROUND_STARTUP_DELAY_SECONDS",
60.0,
)
def _recent_leaderboard_stat_dates(days: int) -> list[str]:
from datetime import datetime, timedelta
from deerflow.persistence.types import BEIJING_TZ
today = datetime.now(BEIJING_TZ).date()
start = today - timedelta(days=max(1, days) - 1)
return [(start + timedelta(days=i)).isoformat() for i in range((today - start).days + 1)]
async def _leaderboard_snapshot_due_dates(
session_factory,
*,
settings_hash: str,
limit: int,
running_ttl_seconds: int,
) -> list[str]:
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, or_, select
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
cutoff = datetime.now(UTC) - timedelta(seconds=running_ttl_seconds)
async with session_factory() as session:
rows = (
await session.execute(
select(AdminLeaderboardDailyStatRow.stat_date)
.where(
AdminLeaderboardDailyStatRow.settings_hash == settings_hash,
or_(
AdminLeaderboardDailyStatRow.status.in_(["queued", "error"]),
and_(
AdminLeaderboardDailyStatRow.status == "running",
AdminLeaderboardDailyStatRow.updated_at < cutoff,
),
),
)
.order_by(AdminLeaderboardDailyStatRow.stat_date.desc())
.limit(limit)
)
).all()
return [str(row[0]) for row in rows]
async def _claim_leaderboard_snapshot(
session_factory,
*,
stat_date: str,
settings_hash: str,
running_ttl_seconds: int,
) -> bool:
from datetime import UTC, datetime
from app.gateway.routers.admin_users import _snapshot_is_fresh
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.types import BEIJING_TZ
async with session_factory() as session:
row = await session.get(
AdminLeaderboardDailyStatRow,
{"stat_date": stat_date, "settings_hash": settings_hash},
)
if row is None or _snapshot_is_fresh(row, stat_date):
return False
if row.status == "running":
updated_at = row.updated_at
if isinstance(updated_at, datetime):
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=BEIJING_TZ)
age = (datetime.now(BEIJING_TZ) - updated_at.astimezone(BEIJING_TZ)).total_seconds()
if age < running_ttl_seconds:
return False
row.status = "running"
row.message = "排行榜后台统计任务已认领。"
row.error = None
row.updated_at = datetime.now(UTC)
await session.commit()
return True
async def _run_leaderboard_snapshot_tick() -> tuple[int, int]:
from app.gateway.routers.admin_users import (
_daily_job_key,
_queue_daily_leaderboard_snapshots,
_refresh_daily_leaderboard_snapshot,
_resolve_leaderboard_snapshot_scope,
)
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.engine import get_session_factory
session_factory = get_session_factory()
if session_factory is None:
return 0, 0
settings = load_system_settings().leaderboard
excluded, include_scheduled, include_admins, include_failed, settings_hash = _resolve_leaderboard_snapshot_scope(settings)
stat_dates = _recent_leaderboard_stat_dates(_LEADERBOARD_SNAPSHOT_PREWARM_DAYS)
queued_dates = await _queue_daily_leaderboard_snapshots(
session_factory,
stat_dates,
settings_hash,
message="排行榜后台统计任务定期预热/补算队列。",
)
due_dates = await _leaderboard_snapshot_due_dates(
session_factory,
settings_hash=settings_hash,
limit=_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
)
processed = 0
for stat_date in due_dates:
claimed = await _claim_leaderboard_snapshot(
session_factory,
stat_date=stat_date,
settings_hash=settings_hash,
running_ttl_seconds=_LEADERBOARD_SNAPSHOT_RUNNING_TTL_SECONDS,
)
if not claimed:
continue
logger.info("Leaderboard snapshot scheduler: building date=%s hash=%s", stat_date, settings_hash[:10])
await _refresh_daily_leaderboard_snapshot(
_daily_job_key(stat_date, settings_hash),
session_factory=session_factory,
settings=settings,
stat_date=stat_date,
settings_hash=settings_hash,
excluded=excluded,
include_scheduled=include_scheduled,
include_admins=include_admins,
include_failed=include_failed,
)
processed += 1
if _LEADERBOARD_SNAPSHOT_SLEEP_SECONDS > 0 and processed < len(due_dates):
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_SLEEP_SECONDS)
return processed, len(queued_dates)
async def _leaderboard_snapshot_scheduler_loop() -> None:
"""Background task inside the existing gateway process for daily snapshots."""
if not _LEADERBOARD_SNAPSHOT_BACKGROUND_ENABLED:
logger.info("Leaderboard snapshot scheduler disabled by ADMIN_LEADERBOARD_BACKGROUND_ENABLED")
return
from deerflow.config.runtime_paths import runtime_home
lock_handle = _acquire_singleton_lock(runtime_home() / ".leaderboard_snapshot_scheduler.lock")
if lock_handle is None:
logger.info("Leaderboard snapshot scheduler: another gateway worker holds the lock; this worker skips")
return
logger.info(
"Leaderboard snapshot scheduler started: prewarm_days=%d batch_size=%d interval=%.1fs",
_LEADERBOARD_SNAPSHOT_PREWARM_DAYS,
_LEADERBOARD_SNAPSHOT_BATCH_SIZE,
_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS,
)
try:
if _LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS > 0:
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_STARTUP_DELAY_SECONDS)
while True:
try:
processed, queued = await _run_leaderboard_snapshot_tick()
if processed or queued:
logger.info(
"Leaderboard snapshot scheduler tick: queued=%d processed=%d",
queued,
processed,
)
except Exception:
logger.exception("Leaderboard snapshot scheduler tick failed (non-fatal)")
await asyncio.sleep(_LEADERBOARD_SNAPSHOT_INTERVAL_SECONDS)
finally:
_release_singleton_lock(lock_handle)
def _warn_if_multi_worker() -> None:
"""检测 uvicorn / gunicorn 多 worker 部署并打 ERROR 日志。
主 agent / canvas / AI 写作流式响应在 Gateway 进程内通过 ``StreamBridge``
@@ -562,16 +799,21 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# 默认每天凌晨 3 点扫一次,删 retention_days(默认 7 天)以前的会话,联动
# 清理 MySQL 业务表 + SQLite checkpointer 里的 thread 状态。多 worker
# 下用文件锁选 leader,只有抢到锁的 worker 真正跑清理。
try:
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
except Exception:
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
from app.channels.service import start_channel_service
try:
from app.gateway.ai_writing_cleanup import cleanup_scheduler_loop
app.state.ai_writing_cleanup_task = asyncio.create_task(cleanup_scheduler_loop(app))
except Exception:
logger.exception("AI writing cleanup scheduler failed to start (non-fatal)")
try:
app.state.leaderboard_snapshot_scheduler_task = asyncio.create_task(_leaderboard_snapshot_scheduler_loop())
except Exception:
logger.exception("Leaderboard snapshot scheduler failed to start (non-fatal)")
# Start IM channel service if any channels are configured
try:
from app.channels.service import start_channel_service
channel_service = await start_channel_service(app.state.config)
logger.info("Channel service started: %s", channel_service.get_status())
@@ -615,7 +857,17 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except asyncio.CancelledError:
pass
except Exception:
logger.exception("AI writing cleanup scheduler shutdown error")
logger.exception("AI writing cleanup scheduler shutdown error")
leaderboard_snapshot_task = getattr(app.state, "leaderboard_snapshot_scheduler_task", None)
if leaderboard_snapshot_task is not None:
leaderboard_snapshot_task.cancel()
try:
await leaderboard_snapshot_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Leaderboard snapshot scheduler shutdown error")
# 阶段 7 删冗余:AI 写作 graph 不再自管 checkpointer / in-flight task;
# thread 状态由 LangGraph Server 主 checkpointer 接管,lifespan 出口由
File diff suppressed because it is too large. Load diff
@@ -1,9 +1,9 @@
"""Tag management API.
Tags are split into three types (``agent`` / ``skill`` / ``scheduled_task``).
A tag may only be applied to a resource of its own type. Tag CRUD and tag
assignment are admin-only; listing tags and reading a resource's tags is open
to everyone (the frontend needs them to render chips and search filters).
A tag may only be applied to a resource of its own type. Tag CRUD, listing,
and assignment are available to ordinary users so teams can organize resources
without asking an administrator to tag every item.
"""
from __future__ import annotations
@@ -11,10 +11,10 @@ from __future__ import annotations
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request, get_tag_store
from app.gateway.deps import get_tag_store
from deerflow.persistence.tags import TAG_TYPES, TagStore
logger = logging.getLogger(__name__)
@@ -22,18 +22,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tags", tags=["tags"])
# --------------------------------------------------------------------------- auth
async def _require_admin(request: Request) -> None:
"""Reject non-admins. Lets the call through when auth is disabled."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Tag management is admin-only")
def _validate_tag_type(tag_type: str) -> str:
if tag_type not in TAG_TYPES:
raise HTTPException(status_code=422, detail=f"Invalid tag type '{tag_type}'. Allowed: {', '.join(TAG_TYPES)}")
@@ -96,10 +84,8 @@ async def list_tags(
@router.post("", response_model=TagResponse, status_code=201, summary="Create Tag")
async def create_tag(
body: TagCreateRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagResponse:
await _require_admin(request)
_validate_tag_type(body.tag_type)
try:
record = await store.create_tag(
@@ -125,10 +111,8 @@ async def create_tag(
async def update_tag(
tag_id: str,
body: TagUpdateRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagResponse:
await _require_admin(request)
update_data = body.model_dump(exclude_unset=True)
if "name" in update_data and update_data["name"] is not None:
update_data["name"] = update_data["name"].strip()
@@ -149,10 +133,8 @@ async def update_tag(
@router.delete("/{tag_id}", status_code=204, summary="Delete Tag")
async def delete_tag(
tag_id: str,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> None:
await _require_admin(request)
try:
deleted = await store.delete_tag(tag_id)
if not deleted:
@@ -195,10 +177,8 @@ async def set_resource_tags(
target_type: str,
target_id: str,
body: TagAssignmentRequest,
request: Request,
store: TagStore = Depends(get_tag_store),
) -> TagListResponse:
await _require_admin(request)
_validate_tag_type(target_type)
try:
records = await store.set_target_tags(target_type, target_id, body.tag_ids)
+16 -12
View File
@@ -90,6 +90,16 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: Qwen/Qwen3.6-27B
display_name: Qwen/Qwen3.6-27B
use: langchain_openai:ChatOpenAI
model: Qwen/Qwen3.6-27B
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
@@ -213,21 +223,15 @@ auth_login:
database:
backend: sqlite
sqlite_dir: .deer-flow/data
pool_size: 10
sqlite_dir: /app/backend/.deer-flow/data
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
echo_sql: false
checkpointer:
type: postgres
# true: 新 checkpoint 写入 Postgres;false: 保留 PG 配置但继续使用 SQLite。
# 也可临时用环境变量 DEER_FLOW_USE_PG_CHECKPOINTER=0 覆盖。
use_postgres: true
connection_string: postgresql://postgres:postgres123@47.88.25.99:8432/deerflow
sqlite_connection_string: .deer-flow/data/deerflow.db
dual_read_enabled: true
dual_read_sqlite_paths:
- .deer-flow/data/deerflow.db
- ../data/.deer-flow/data/checkpoints.db
type: sqlite
connection_string: /app/backend/.deer-flow/data/deerflow.db
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
@@ -11,13 +11,13 @@ blocking, making a unified file safe for both workloads. Writers
that contend for the lock wait via the default 5-second sqlite3
busy timeout rather than failing immediately.
Postgres mode: both use the same database URL but maintain independent
connection pools with different lifecycles.
MySQL mode: application ORM data uses MySQL. LangGraph's checkpointer does
not have a built-in MySQL backend in this project, so deployments that choose
MySQL for business tables should configure the legacy ``checkpointer:``
section separately (usually sqlite) to preserve conversation checkpoints.
Postgres mode: both use the same database URL but maintain independent
connection pools with different lifecycles.
MySQL mode: application ORM data uses MySQL. LangGraph's checkpointer does
not have a built-in MySQL backend in this project, so deployments that choose
MySQL for business tables should configure the legacy ``checkpointer:``
section separately (usually sqlite) to preserve conversation checkpoints.
Memory mode: checkpointer uses MemorySaver, app uses in-memory stores.
No database is initialized.
@@ -26,12 +26,12 @@ Sensitive values (postgres_url) should use $VAR syntax in config.yaml
to reference environment variables from .env:
database:
backend: postgres
postgres_url: $DATABASE_URL
The $VAR resolution is handled by AppConfig.resolve_env_variables()
before this config is instantiated -- DatabaseConfig itself does not
need to do any environment variable processing.
backend: postgres
postgres_url: $DATABASE_URL
The $VAR resolution is handled by AppConfig.resolve_env_variables()
before this config is instantiated -- DatabaseConfig itself does not
need to do any environment variable processing.
"""
from __future__ import annotations
@@ -42,40 +42,54 @@ from typing import Literal
from pydantic import BaseModel, Field
class DatabaseConfig(BaseModel):
backend: Literal["memory", "sqlite", "postgres", "mysql"] = Field(
default="memory",
description=("Storage backend for application data. 'memory' for development (no persistence across restarts), 'sqlite' for single-node deployment, 'postgres' for production multi-node deployment, 'mysql' for MySQL-backed business tables."),
)
class DatabaseConfig(BaseModel):
backend: Literal["memory", "sqlite", "postgres", "mysql"] = Field(
default="memory",
description=("Storage backend for application data. 'memory' for development (no persistence across restarts), 'sqlite' for single-node deployment, 'postgres' for production multi-node deployment, 'mysql' for MySQL-backed business tables."),
)
sqlite_dir: str = Field(
default=".deer-flow/data",
description=("Directory for the SQLite database file. Both checkpointer and application data share {sqlite_dir}/deerflow.db."),
)
postgres_url: str = Field(
default="",
description=(
"PostgreSQL connection URL, shared by checkpointer and app. "
postgres_url: str = Field(
default="",
description=(
"PostgreSQL connection URL, shared by checkpointer and app. "
"Use $DATABASE_URL in config.yaml to reference .env. "
"Example: postgresql://user:pass@host:5432/deerflow "
"(the +asyncpg driver suffix is added automatically where needed)."
),
)
mysql_url: str = Field(
default="",
description=(
"MySQL connection URL for the application ORM. "
"Use $MYSQL_DATABASE_URL in config.yaml to reference .env. "
"Example: mysql+asyncmy://user:pass@host:3306/deerflow?charset=utf8mb4. "
"If mysql:// is provided, the +asyncmy driver suffix is added automatically."
),
)
),
)
mysql_url: str = Field(
default="",
description=(
"MySQL connection URL for the application ORM. "
"Use $MYSQL_DATABASE_URL in config.yaml to reference .env. "
"Example: mysql+asyncmy://user:pass@host:3306/deerflow?charset=utf8mb4. "
"If mysql:// is provided, the +asyncmy driver suffix is added automatically."
),
)
echo_sql: bool = Field(
default=False,
description="Echo all SQL statements to log (debug only).",
)
pool_size: int = Field(
default=5,
description="Connection pool size for the app ORM engine (postgres/mysql only).",
description="Connection pool size for the app ORM engine (postgres/mysql only).",
)
max_overflow: int = Field(
default=10,
description=(
"Extra connections allowed beyond pool_size under load (postgres/mysql only). "
"The real connection ceiling is pool_size + max_overflow. SQLAlchemy default is 10."
),
)
pool_timeout: int = Field(
default=30,
description=(
"Seconds a caller waits for a free pooled connection before erroring (postgres/mysql only). "
"SQLAlchemy default is 30."
),
)
# -- Derived helpers (not user-configured) --
@@ -108,16 +122,16 @@ class DatabaseConfig(BaseModel):
"""SQLAlchemy async URL for the application ORM engine."""
if self.backend == "sqlite":
return f"sqlite+aiosqlite:///{self.sqlite_path}"
if self.backend == "postgres":
url = self.postgres_url
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
return url
if self.backend == "mysql":
url = self.mysql_url
if not url:
raise ValueError("database.mysql_url is required for backend='mysql'")
if url.startswith("mysql://"):
url = url.replace("mysql://", "mysql+asyncmy://", 1)
return url
raise ValueError(f"No SQLAlchemy URL for backend={self.backend!r}")
if self.backend == "postgres":
url = self.postgres_url
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
return url
if self.backend == "mysql":
url = self.mysql_url
if not url:
raise ValueError("database.mysql_url is required for backend='mysql'")
if url.startswith("mysql://"):
url = url.replace("mysql://", "mysql+asyncmy://", 1)
return url
raise ValueError(f"No SQLAlchemy URL for backend={self.backend!r}")
@@ -0,0 +1,5 @@
"""Persistence models for admin analytics snapshots."""
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
__all__ = ["AdminLeaderboardDailyStatRow"]
@@ -0,0 +1,31 @@
"""ORM models for admin analytics snapshots."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class AdminLeaderboardDailyStatRow(Base):
"""One precomputed leaderboard snapshot for a single local calendar day."""
__tablename__ = "admin_leaderboard_daily_stats"
__table_args__ = (
Index("ix_admin_leaderboard_daily_stats_status", "status"),
Index("ix_admin_leaderboard_daily_stats_updated_at", "updated_at"),
)
stat_date: Mapped[str] = mapped_column(String(10), primary_key=True)
settings_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
status: Mapped[str] = mapped_column(String(16), default="ready")
payload_json: Mapped[dict] = mapped_column(PortableJSON(), default=dict)
message: Mapped[str | None] = mapped_column(Text)
error: Mapped[str | None] = mapped_column(Text)
generated_at: Mapped[datetime | None] = mapped_column(BeijingDateTime())
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -11,16 +11,16 @@ None and fall back to in-memory implementations.
from __future__ import annotations
import asyncio
import json
import logging
import os
import sys
from importlib.util import find_spec
from pathlib import Path
from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
import json
import logging
import os
import sys
from importlib.util import find_spec
from pathlib import Path
from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
def _json_serializer(obj: object) -> str:
@@ -133,7 +133,7 @@ async def _run_alembic_upgrade(url: str) -> None:
logger.exception("Auto-applying Alembic migrations failed; continuing with create_all schema")
async def _auto_create_postgres_db(url: str) -> None:
async def _auto_create_postgres_db(url: str) -> None:
"""Connect to the ``postgres`` maintenance DB and CREATE DATABASE.
The target database name is extracted from *url*. The connection is
@@ -156,55 +156,57 @@ async def _auto_create_postgres_db(url: str) -> None:
async with maint_engine.connect() as conn:
await conn.execute(text(f'CREATE DATABASE "{db_name}"'))
logger.info("Auto-created PostgreSQL database: %s", db_name)
finally:
await maint_engine.dispose()
async def _auto_create_mysql_db(url: str) -> None:
"""Create the target MySQL database when it is missing.
``create_all`` can create tables, but MySQL requires the database/schema to
exist before the application URL can connect. We connect to the built-in
``mysql`` maintenance database on the same server and issue a conservative
``CREATE DATABASE IF NOT EXISTS``.
"""
parsed = make_url(url)
db_name = parsed.database
if not db_name:
raise ValueError("Cannot auto-create MySQL database: no database name in URL")
maint_url = parsed.set(database="mysql")
maint_engine = create_async_engine(
maint_url.render_as_string(hide_password=False),
isolation_level="AUTOCOMMIT",
pool_pre_ping=True,
)
quoted = "`" + db_name.replace("`", "``") + "`"
try:
async with maint_engine.connect() as conn:
await conn.execute(
text(
f"CREATE DATABASE IF NOT EXISTS {quoted} "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
)
logger.info("Auto-created MySQL database if missing: %s", db_name)
finally:
await maint_engine.dispose()
def _is_mysql_unknown_database_error(exc: Exception) -> bool:
message = str(exc).lower()
return "unknown database" in message or "1049" in message
async def init_engine(
finally:
await maint_engine.dispose()
async def _auto_create_mysql_db(url: str) -> None:
"""Create the target MySQL database when it is missing.
``create_all`` can create tables, but MySQL requires the database/schema to
exist before the application URL can connect. We connect to the built-in
``mysql`` maintenance database on the same server and issue a conservative
``CREATE DATABASE IF NOT EXISTS``.
"""
parsed = make_url(url)
db_name = parsed.database
if not db_name:
raise ValueError("Cannot auto-create MySQL database: no database name in URL")
maint_url = parsed.set(database="mysql")
maint_engine = create_async_engine(
maint_url.render_as_string(hide_password=False),
isolation_level="AUTOCOMMIT",
pool_pre_ping=True,
)
quoted = "`" + db_name.replace("`", "``") + "`"
try:
async with maint_engine.connect() as conn:
await conn.execute(
text(
f"CREATE DATABASE IF NOT EXISTS {quoted} "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
)
logger.info("Auto-created MySQL database if missing: %s", db_name)
finally:
await maint_engine.dispose()
def _is_mysql_unknown_database_error(exc: Exception) -> bool:
message = str(exc).lower()
return "unknown database" in message or "1049" in message
async def init_engine(
backend: str,
*,
url: str = "",
echo: bool = False,
pool_size: int = 5,
max_overflow: int = 10,
pool_timeout: int = 30,
sqlite_dir: str = "",
) -> None:
"""Create the async engine and session factory, then auto-create tables.
@@ -213,7 +215,9 @@ async def init_engine(
backend: "memory", "sqlite", "postgres", or "mysql".
url: SQLAlchemy async URL (for sqlite/postgres/mysql).
echo: Echo SQL to log.
pool_size: Postgres connection pool size.
pool_size: Base connection pool size (postgres/mysql).
max_overflow: Extra connections allowed beyond pool_size under load (postgres/mysql).
pool_timeout: Seconds to wait for a free pooled connection before erroring (postgres/mysql).
sqlite_dir: Directory to create for SQLite (ensured to exist).
"""
global _engine, _session_factory
@@ -271,6 +275,8 @@ async def init_engine(
url,
echo=echo,
pool_size=pool_size,
max_overflow=max_overflow,
pool_timeout=pool_timeout,
pool_pre_ping=True,
json_serializer=_json_serializer,
)
@@ -279,6 +285,8 @@ async def init_engine(
url,
echo=echo,
pool_size=pool_size,
max_overflow=max_overflow,
pool_timeout=pool_timeout,
pool_pre_ping=True,
pool_recycle=1800,
json_serializer=_json_serializer,
@@ -302,37 +310,39 @@ async def init_engine(
# This is expected during initial scaffolding or minimal installs.
logger.debug("deerflow.persistence.models not found; skipping auto-create tables")
try:
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
except Exception as exc:
if backend == "postgres" and "does not exist" in str(exc):
try:
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
except Exception as exc:
if backend == "postgres" and "does not exist" in str(exc):
# Database not yet created — attempt to auto-create it, then retry.
await _auto_create_postgres_db(url)
# Rebuild engine against the now-existing database
await _engine.dispose()
_engine = create_async_engine(url, echo=echo, pool_size=pool_size, pool_pre_ping=True, json_serializer=_json_serializer)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
elif backend == "mysql" and _is_mysql_unknown_database_error(exc):
# Database not yet created. Create it, rebuild the app engine
# against the target DB, then let SQLAlchemy create all tables.
await _auto_create_mysql_db(url)
await _engine.dispose()
_engine = create_async_engine(
url,
echo=echo,
pool_size=pool_size,
pool_pre_ping=True,
pool_recycle=1800,
json_serializer=_json_serializer,
)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
else:
raise
_engine = create_async_engine(url, echo=echo, pool_size=pool_size, max_overflow=max_overflow, pool_timeout=pool_timeout, pool_pre_ping=True, json_serializer=_json_serializer)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
elif backend == "mysql" and _is_mysql_unknown_database_error(exc):
# Database not yet created. Create it, rebuild the app engine
# against the target DB, then let SQLAlchemy create all tables.
await _auto_create_mysql_db(url)
await _engine.dispose()
_engine = create_async_engine(
url,
echo=echo,
pool_size=pool_size,
max_overflow=max_overflow,
pool_timeout=pool_timeout,
pool_pre_ping=True,
pool_recycle=1800,
json_serializer=_json_serializer,
)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
else:
raise
# Apply in-place schema fixes (column widenings, etc.) that ``create_all``
# cannot do on its own. Failures don't abort startup — see the helper.
@@ -351,6 +361,8 @@ async def init_engine_from_config(config) -> None:
url=config.app_sqlalchemy_url,
echo=config.echo_sql,
pool_size=config.pool_size,
max_overflow=getattr(config, "max_overflow", 10),
pool_timeout=getattr(config, "pool_timeout", 30),
sqlite_dir=config.sqlite_dir if config.backend == "sqlite" else "",
)
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Float, Integer, String, Text
from sqlalchemy import Float, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -15,6 +15,10 @@ class LlmCallMetricRow(Base):
"""One row per completed (or failed) LLM provider invocation."""
__tablename__ = "llm_call_metrics"
__table_args__ = (
Index("ix_llm_call_metrics_created_user", "created_at", "user_id"),
Index("ix_llm_call_metrics_run_created", "run_id", "created_at"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# Wall-clock time the LLM call ended. Indexed because admin views always
@@ -0,0 +1,85 @@
"""Create admin leaderboard daily snapshots and analytics indexes.
Revision ID: 20260608_01
Revises: 20260607_02
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from deerflow.persistence.types import PortableJSON
revision = "20260608_01"
down_revision = "20260607_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
def _create_index(table_name: str, index_name: str, columns: list[str]) -> None:
if _has_table(table_name) and not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns)
def _drop_index(table_name: str, index_name: str) -> None:
if _has_table(table_name) and _has_index(table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
if not _has_table("admin_leaderboard_daily_stats"):
op.create_table(
"admin_leaderboard_daily_stats",
sa.Column("stat_date", sa.String(10), nullable=False),
sa.Column("settings_hash", sa.String(64), nullable=False),
sa.Column("status", sa.String(16), nullable=False, server_default="ready"),
sa.Column("payload_json", PortableJSON(), nullable=True),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("generated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("stat_date", "settings_hash", name="pk_admin_leaderboard_daily_stats"),
)
op.create_index("ix_admin_leaderboard_daily_stats_status", "admin_leaderboard_daily_stats", ["status"])
op.create_index("ix_admin_leaderboard_daily_stats_updated_at", "admin_leaderboard_daily_stats", ["updated_at"])
_create_index("runs", "ix_runs_created_user_status", ["created_at", "user_id", "status"])
_create_index("runs", "ix_runs_user_created", ["user_id", "created_at"])
_create_index("runs", "ix_runs_thread_created", ["thread_id", "created_at"])
_create_index("tool_call_metrics", "ix_tool_call_metrics_created_user", ["created_at", "user_id"])
_create_index("tool_call_metrics", "ix_tool_call_metrics_run_created", ["run_id", "created_at"])
_create_index("llm_call_metrics", "ix_llm_call_metrics_created_user", ["created_at", "user_id"])
_create_index("llm_call_metrics", "ix_llm_call_metrics_run_created", ["run_id", "created_at"])
_create_index("threads_meta", "ix_threads_meta_created_user", ["created_at", "user_id"])
_create_index("threads_meta", "ix_threads_meta_user_created", ["user_id", "created_at"])
def downgrade() -> None:
_drop_index("threads_meta", "ix_threads_meta_user_created")
_drop_index("threads_meta", "ix_threads_meta_created_user")
_drop_index("llm_call_metrics", "ix_llm_call_metrics_run_created")
_drop_index("llm_call_metrics", "ix_llm_call_metrics_created_user")
_drop_index("tool_call_metrics", "ix_tool_call_metrics_run_created")
_drop_index("tool_call_metrics", "ix_tool_call_metrics_created_user")
_drop_index("runs", "ix_runs_thread_created")
_drop_index("runs", "ix_runs_user_created")
_drop_index("runs", "ix_runs_created_user_status")
if _has_table("admin_leaderboard_daily_stats"):
op.drop_table("admin_leaderboard_daily_stats")
@@ -21,6 +21,7 @@ from deerflow.persistence.agents.model import (
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
@@ -58,6 +59,7 @@ __all__ = [
"AgentFavoriteRow",
"AgentRow",
"AgentSkillRow",
"AdminLeaderboardDailyStatRow",
"LlmCallMetricRow",
"NotificationRow",
"RecommendedQuestionRow",
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Index, String, Text
from sqlalchemy import Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -47,4 +47,9 @@ class RunRow(Base):
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
__table_args__ = (Index("ix_runs_thread_status", "thread_id", "status"),)
__table_args__ = (
Index("ix_runs_thread_status", "thread_id", "status"),
Index("ix_runs_created_user_status", "created_at", "user_id", "status"),
Index("ix_runs_user_created", "user_id", "created_at"),
Index("ix_runs_thread_created", "thread_id", "created_at"),
)
@@ -4,15 +4,19 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy import Index, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class ThreadMetaRow(Base):
__tablename__ = "threads_meta"
class ThreadMetaRow(Base):
__tablename__ = "threads_meta"
__table_args__ = (
Index("ix_threads_meta_created_user", "created_at", "user_id"),
Index("ix_threads_meta_user_created", "user_id", "created_at"),
)
thread_id: Mapped[str] = mapped_column(String(64), primary_key=True)
assistant_id: Mapped[str | None] = mapped_column(String(128), index=True)
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String, Text
from sqlalchemy import Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -21,6 +21,10 @@ class ToolCallMetricRow(Base):
"""
__tablename__ = "tool_call_metrics"
__table_args__ = (
Index("ix_tool_call_metrics_created_user", "created_at", "user_id"),
Index("ix_tool_call_metrics_run_created", "run_id", "created_at"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), index=True)
@@ -0,0 +1,85 @@
-- ============================================================================
-- 排行榜(admin leaderboard)统计所需的复合索引 —— MySQL 手动执行版
-- ============================================================================
--
-- 背景:
-- 这些索引声明在各 ORM model 的 __table_args__ 里,但 Base.metadata.create_all
-- 对【已存在的表】不会补建索引,而 alembic 自动迁移在本项目里是关闭的
-- (engine.py 中 `# await _run_alembic_upgrade(url)` 被注释)。
-- 因此在【已有数据的 MySQL 部署】上,这些索引很可能并未真正创建,排行榜的
-- 单日聚合查询会退化为全表扫描,拖慢并占用连接。
--
-- 用法:
-- 1) 先选中目标库(与 database.mysql_url 里的库名一致):
-- USE your_deerflow_db;
-- 2) 直接整文件执行即可。本脚本是【幂等】的:已存在的索引会跳过,不会报错,
-- 可重复执行。
--
-- 注意:
-- * 大表上建索引可能耗时;MySQL 8.0 在线 DDL 默认 ALGORITHM=INPLACE 一般不阻塞
-- 写入,但仍建议在低峰期执行,或先在备库/预发验证。
-- * 涉及列均为 String(64) / String(20) 等短列,utf8mb4 下复合索引长度远低于
-- 3072 字节上限,无需前缀索引。
-- * 索引名与 migration 20260608_01 保持一致,日后若启用 alembic 也不会重复创建。
-- ============================================================================
-- ---- 通用幂等封装:仅当索引不存在时才创建 --------------------------------------
DELIMITER //
DROP PROCEDURE IF EXISTS _create_index_if_missing //
CREATE PROCEDURE _create_index_if_missing(
IN p_table VARCHAR(64),
IN p_index VARCHAR(64),
IN p_columns VARCHAR(255)
)
BEGIN
DECLARE v_exists INT DEFAULT 0;
SELECT COUNT(*) INTO v_exists
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = p_table
AND index_name = p_index;
IF v_exists = 0 THEN
SET @ddl = CONCAT('CREATE INDEX `', p_index, '` ON `', p_table, '` (', p_columns, ')');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT CONCAT('CREATED ', p_table, '.', p_index) AS result;
ELSE
SELECT CONCAT('SKIPPED ', p_table, '.', p_index, ' (already exists)') AS result;
END IF;
END //
DELIMITER ;
-- ---- runs ---------------------------------------------------------------------
CALL _create_index_if_missing('runs', 'ix_runs_created_user_status', '`created_at`, `user_id`, `status`');
CALL _create_index_if_missing('runs', 'ix_runs_user_created', '`user_id`, `created_at`');
CALL _create_index_if_missing('runs', 'ix_runs_thread_created', '`thread_id`, `created_at`');
-- ---- tool_call_metrics -------------------------------------------------------
CALL _create_index_if_missing('tool_call_metrics', 'ix_tool_call_metrics_created_user', '`created_at`, `user_id`');
CALL _create_index_if_missing('tool_call_metrics', 'ix_tool_call_metrics_run_created', '`run_id`, `created_at`');
-- ---- llm_call_metrics --------------------------------------------------------
CALL _create_index_if_missing('llm_call_metrics', 'ix_llm_call_metrics_created_user', '`created_at`, `user_id`');
CALL _create_index_if_missing('llm_call_metrics', 'ix_llm_call_metrics_run_created', '`run_id`, `created_at`');
-- ---- threads_meta ------------------------------------------------------------
CALL _create_index_if_missing('threads_meta', 'ix_threads_meta_created_user', '`created_at`, `user_id`');
CALL _create_index_if_missing('threads_meta', 'ix_threads_meta_user_created', '`user_id`, `created_at`');
-- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _create_index_if_missing;
-- ---- 执行后自检:确认 9 个索引都已存在 ---------------------------------------
SELECT table_name, index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN (
'ix_runs_created_user_status', 'ix_runs_user_created', 'ix_runs_thread_created',
'ix_tool_call_metrics_created_user', 'ix_tool_call_metrics_run_created',
'ix_llm_call_metrics_created_user', 'ix_llm_call_metrics_run_created',
'ix_threads_meta_created_user', 'ix_threads_meta_user_created'
)
GROUP BY table_name, index_name
ORDER BY table_name, index_name;