Merge branch 'ji' into 'master'
feat: 重新设计浏览器标签页图标(智能助手中心) See merge request knowledge-base-bj/deerflow-server!97
This commit is contained in:
commit
024c8ee5a1
34 files changed
+2321
-508
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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: "用户管理",
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 私有记忆。
|
||||
# 三层覆盖优先级(高->低):
|
||||
|
||||
+61
-47
@@ -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}")
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
"""Persistence models for admin analytics snapshots."""
|
||||
|
||||
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
|
||||
|
||||
__all__ = ["AdminLeaderboardDailyStatRow"]
|
||||
+31
@@ -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 "",
|
||||
)
|
||||
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+85
@@ -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")
|
||||
+2
@@ -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"),
|
||||
)
|
||||
+7
-3
@@ -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)
|
||||
|
||||
+5
-1
@@ -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;
|
||||
Reference in new issue
Block a user