This commit is contained in:
fengxiangdebaozi committed 2026-06-05 22:40:44 +08:00
commit 44b7f9fa99
123 files changed
+10776 -12476

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
-10222
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -56,6 +56,7 @@
"@uiw/react-codemirror": "^4.25.4",
"@xyflow/react": "^12.10.0",
"ai": "^6.0.33",
"axios": "1.16.1",
"best-effort-json-parser": "^1.2.1",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
/**
* Sandboxed preview of a generated HTML document.
*
* Renders via a `blob:` URL rather than `srcDoc`. A srcDoc document's base URL
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
* a real document URL, so fragment links scroll within the frame as expected —
* all without enabling scripts (the sandbox stays script-free for security; the
* server already sanitizes script vectors and this is defense-in-depth).
*
* The `charset=utf-8` on the blob type is required: a blob: URL whose
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
* Chinese Windows), garbling CJK text when the HTML lacks a `<meta charset>`.
*/
export function HtmlPreviewFrame({
html,
className = "h-[70vh] w-full rounded-md border bg-white",
}: {
html: string;
className?: string;
}) {
const [url, setUrl] = useState<string>();
useEffect(() => {
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
type: "text/html;charset=utf-8",
});
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [html]);
return (
<iframe
title="HTML 预览"
sandbox="allow-scripts allow-forms"
src={url}
className={className}
/>
);
}
@@ -8,6 +8,7 @@ import {
Folder,
FolderOpen,
Settings,
Trophy,
User,
Wrench,
} from "lucide-react";
@@ -260,6 +261,14 @@ 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" />
个人信息
@@ -0,0 +1,96 @@
"use client";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/** Compute the page-number window to render, inserting -1 sentinels for gaps.
* Always shows first/last and a window around the current page. */
function buildPageItems(current: number, totalPages: number): number[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const items = new Set<number>([1, totalPages, current]);
for (const d of [1, 2]) {
if (current - d >= 1) items.add(current - d);
if (current + d <= totalPages) items.add(current + d);
}
const sorted = [...items].sort((a, b) => a - b);
const out: number[] = [];
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) out.push(-1); // gap sentinel
out.push(p);
prev = p;
}
return out;
}
export interface PaginationProps {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
className?: string;
}
/** Controlled, compact pagination control. Renders nothing when a single page. */
export function Pagination({
page,
pageSize,
total,
onPageChange,
className,
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / Math.max(pageSize, 1)));
if (totalPages <= 1) return null;
const items = buildPageItems(page, totalPages);
return (
<nav
className={cn("flex items-center justify-center gap-1", className)}
aria-label="分页"
>
<Button
variant="outline"
size="icon-sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="上一页"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
{items.map((p, idx) =>
p === -1 ? (
<span
key={`gap-${idx}`}
className="text-muted-foreground px-1 text-sm select-none"
>
…
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="icon-sm"
onClick={() => onPageChange(p)}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
),
)}
<Button
variant="outline"
size="icon-sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="下一页"
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
</nav>
);
}
+30 -4
View File
@@ -22,10 +22,31 @@ function ResizablePanelGroup({
);
}
// react-resizable-panels v4 changed size semantics: a bare *number* is now
// interpreted as PIXELS, while a unit-less *string* is a percentage. Every call
// site in this app passes sizes as percentages (the v2/v3 convention), so a
// value like `maxSize={60}` would otherwise pin a panel to 60px and freeze the
// layout/drag. Normalize numbers to "<n>%" to preserve the intended percentage.
const asPercent = (v: number | string | undefined) =>
typeof v === "number" ? `${v}%` : v;
function ResizablePanel({
defaultSize,
minSize,
maxSize,
collapsedSize,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
return (
<ResizablePrimitive.Panel
data-slot="resizable-panel"
defaultSize={asPercent(defaultSize)}
minSize={asPercent(minSize)}
maxSize={asPercent(maxSize)}
collapsedSize={asPercent(collapsedSize)}
{...props}
/>
);
}
function ResizableHandle({
@@ -39,14 +60,19 @@ function ResizableHandle({
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
// Thin visible divider, but a generous invisible hit area on both sides
// (after:w-4 → 16px) so the handle is easy to grab and drag. Hovering /
// dragging tints the divider so it's obvious it's interactive.
"bg-border hover:bg-primary/60 active:bg-primary relative flex w-px items-center justify-center transition-colors",
"after:absolute after:inset-y-0 after:left-1/2 after:w-4 after:-translate-x-1/2 after:cursor-col-resize",
"focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
<div className="bg-border z-10 flex h-8 w-4 items-center justify-center rounded-sm border shadow-sm">
<GripVerticalIcon className="size-3.5" />
</div>
)}
</ResizablePrimitive.Separator>
@@ -1,6 +1,6 @@
"use client";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { ArrowDownWideNarrow, Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
@@ -83,6 +83,8 @@ function UserInfoPanel({ user }: { user: UserListItem }) {
<Badge variant="secondary">普通用户</Badge>
)}
</dd>
<dt className="text-muted-foreground font-medium">问答次数</dt>
<dd className="tabular-nums">{user.qa_count}</dd>
<dt className="text-muted-foreground font-medium">Skills</dt>
<dd className="tabular-nums">{user.skill_count}</dd>
<dt className="text-muted-foreground font-medium">Agents</dt>
@@ -300,21 +302,29 @@ export function AdminUserHub() {
const { data: users, isLoading: usersLoading } = useAllUsers(true);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [activeTab, setActiveTab] = useState<"info" | "audit" | "memory">("info");
// 活动审计 放到前面 → 默认进入审计标签页。
const [activeTab, setActiveTab] = useState<"audit" | "info" | "memory">("audit");
// 是否按问答次数(降序)排序用户列表。
const [sortByQa, setSortByQa] = useState(false);
const filteredUsers = useMemo(() => {
const needle = userQuery.trim().toLowerCase();
const list = users ?? [];
return needle
? list.filter((u) => u.email.toLowerCase().includes(needle))
: list;
}, [users, userQuery]);
let list = users ?? [];
if (needle) {
list = list.filter((u) => u.email.toLowerCase().includes(needle));
}
if (sortByQa) {
// Copy before sorting so we never mutate the query cache array.
list = [...list].sort((a, b) => (b.qa_count ?? 0) - (a.qa_count ?? 0));
}
return list;
}, [users, userQuery, sortByQa]);
const selectedUser = users?.find((u) => u.id === selectedUserId) ?? null;
function selectUser(id: string) {
setSelectedUserId(id);
setActiveTab("info");
setActiveTab("audit");
}
return (
@@ -338,6 +348,20 @@ export function AdminUserHub() {
className="pl-8"
/>
</div>
<button
type="button"
onClick={() => setSortByQa((v) => !v)}
className={cn(
"mx-2 mb-1 flex items-center justify-center gap-1 rounded-md border px-2 py-1 text-xs",
sortByQa
? "border-primary/40 bg-primary/5 text-primary"
: "text-muted-foreground hover:bg-muted",
)}
title="按问答次数从高到低排序"
>
<ArrowDownWideNarrow className="size-3.5" />
{sortByQa ? "按问答次数排序中" : "按问答次数排序"}
</button>
<ScrollArea className="min-h-0 flex-1">
{usersLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
@@ -369,6 +393,12 @@ export function AdminUserHub() {
管理员
</Badge>
)}
<span
className="text-muted-foreground shrink-0 tabular-nums text-xs"
title="问答次数"
>
{u.qa_count ?? 0}
</span>
</button>
))}
</div>
@@ -388,8 +418,8 @@ export function AdminUserHub() {
>
<div className="border-b px-4 pt-3 shrink-0">
<TabsList variant="line">
<TabsTrigger value="info">用户信息</TabsTrigger>
<TabsTrigger value="audit">活动审计</TabsTrigger>
<TabsTrigger value="info">用户信息</TabsTrigger>
<TabsTrigger value="memory">记忆管理</TabsTrigger>
</TabsList>
</div>
@@ -7,6 +7,7 @@ import {
InfoIcon,
LockIcon,
MessageSquareIcon,
PinIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
@@ -15,7 +16,14 @@ import {
Trash2Icon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -49,6 +57,7 @@ import {
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useToggleAgentPin,
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
@@ -61,9 +70,122 @@ import { cn } from "@/lib/utils";
interface AgentCardProps {
agent: Agent;
/** "card" (default) renders the full gallery card; "list" renders a compact
* single-row layout for the list view. All dialogs/actions are shared. */
layout?: "card" | "list";
}
export function AgentCard({ agent }: AgentCardProps) {
interface BadgeItem {
key: string;
node: ReactNode;
}
// Minimum number of skill/tool badges to always render before collapsing the
// remainder into the "+N" chip.
const MIN_VISIBLE_BADGES = 3;
/** Render skill/tool-group badges clamped to at most two rows. Overflow is
* collapsed into a clickable "+N" chip that opens a dialog listing the full
* badge set (the visible rows stay as-is — only the chip is interactive).
*
* Badge widths are variable, so the cutoff is measured rather than assumed: an
* off-screen mirror of the full badge set is laid out at the real container
* width, and we keep only the badges that land within the first two rows
* (reserving one slot for the "+N" chip). A ResizeObserver recomputes when the
* responsive grid changes the card width.
*/
function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
const measureRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(items.length);
const [dialogOpen, setDialogOpen] = useState(false);
const sig = items.map((i) => i.key).join("|");
useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
const compute = () => {
const kids = Array.from(el.children) as HTMLElement[];
if (kids.length === 0) {
setVisible(0);
return;
}
const top0 = kids[0].offsetTop;
const rowH = kids[0].offsetHeight;
// +2px fudge so the second row's badges (which may sit a hair lower due
// to line-box rounding) are not falsely treated as a third row.
const maxBottom = top0 + rowH * 2 + 2;
let count = 0;
for (const k of kids) {
if (k.offsetTop + k.offsetHeight <= maxBottom) count += 1;
else break;
}
// Reserve one slot for the "+N" chip when anything overflows so the chip
// itself never spills onto a third row.
if (count < kids.length && count > 0) count -= 1;
// Always surface a few badges before collapsing into "+N", even when the
// card is narrow enough that two rows barely fit anything (otherwise a
// skinny column could show just "+N" with no skills at all). Keeps at
// least one badge hidden so the chip still makes sense.
if (count < kids.length) {
count = Math.max(count, Math.min(MIN_VISIBLE_BADGES, kids.length - 1));
}
setVisible(count);
};
compute();
const ro = new ResizeObserver(compute);
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sig]);
const hidden = items.length - visible;
const showChip = hidden > 0;
const shown = items.slice(0, visible);
return (
<div className="relative">
{/* Off-screen full-width mirror used only for measuring row breaks. */}
<div
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute inset-x-0 top-0 flex flex-wrap gap-1"
>
{items.map((i) => i.node)}
</div>
<div className="flex flex-wrap gap-1">
{shown.map((i) => i.node)}
{showChip && (
<Badge
variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs"
title="查看全部"
onClick={(e) => {
e.stopPropagation();
setDialogOpen(true);
}}
>
+{hidden}
</Badge>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>全部技能与工具</DialogTitle>
<DialogDescription>共 {items.length} 项</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-wrap gap-1.5 overflow-y-auto py-1">
{items.map((i) => i.node)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
const isList = layout === "list";
const { t } = useI18n();
const router = useRouter();
const deleteAgent = useDeleteAgent();
@@ -72,6 +194,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const togglePin = useToggleAgentPin();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -110,6 +233,7 @@ export function AgentCard({ agent }: AgentCardProps) {
// already own, so only offer it on built-in / others' agents.
const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite;
const isPinned = !!agent.is_pinned;
useEffect(() => {
if (!editOpen) {
@@ -273,6 +397,16 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleTogglePin() {
const next = !isPinned;
try {
await togglePin.mutateAsync({ id: agent.id, pinned: next });
toast.success(next ? "已置顶" : "已取消置顶");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
// dialog. Featured agents get appended to the end on add (Date.now() is a
@@ -304,252 +438,328 @@ export function AgentCard({ agent }: AgentCardProps) {
const enabledSkills = skills.filter((skill) => skill.enabled);
return (
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row.
const statusBadges = (
<>
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
<CardHeader className="min-w-0 pb-3">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{desensitize(agent.model)}
</Badge>
)}
</div>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
我的
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
>
来自他人
</Badge>
)}
{isOwned && agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title="该智能体已对所有用户可见"
>
<GlobeIcon className="size-3" />
已发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title="该智能体位于私密广场,需要密码才能查看"
>
<ShieldIcon className="size-3" />
私密
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
title="已加入首页选择器"
>
<SparklesIcon className="size-3" />
首页推荐
</Badge>
)}
</>
);
const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`,
node: (
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
{desensitize(group)}
</Badge>
),
})),
...(agent.skills ?? []).map((skill) => ({
key: `sk:${skill}`,
node: (
<Badge
key={`sk:${skill}`}
variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs"
title={t.settings.skills.viewDetailAction}
onClick={(e) => {
e.stopPropagation();
setDetailSkillName(skill);
}}
>
{desensitize(skill)}
</Badge>
),
})),
];
const hasSkillsOrTools =
(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0;
const showActionRow =
canEdit || canDelete || isOwned || isAdmin || canFavorite;
// The icon action buttons, shared by both layouts.
const actionButtons = (
<>
{canFavorite && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
!isList && "mr-auto",
isFavorite && "text-amber-500 hover:text-amber-600",
)}
onClick={() => void handleToggleFavorite()}
disabled={toggleFavorite.isPending}
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
>
<StarIcon
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
/>
</Button>
)}
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
isPinned && "text-emerald-600 hover:text-emerald-700",
!isList && !canFavorite && "mr-auto",
)}
onClick={() => void handleTogglePin()}
disabled={togglePin.isPending}
title={isPinned ? "取消置顶" : "置顶"}
>
<PinIcon className={cn("h-3.5 w-3.5", isPinned && "fill-current")} />
</Button>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.featured_order != null &&
"text-amber-600 hover:text-amber-700",
)}
onClick={() => void handleToggleFeatured()}
disabled={setFeatured.isPending}
title={
agent.featured_order != null
? "从首页选择器移除"
: "加入首页选择器"
}
>
<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>
)}
{isOwned && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
title={agent.published ? "取消发布" : "发布"}
>
{agent.published ? (
<LockIcon className="h-3.5 w-3.5" />
) : (
<GlobeIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
{(isOwned || isAdmin) && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.is_private && "text-purple-600 hover:text-purple-700",
)}
onClick={() => void handleTogglePrivate()}
disabled={updateAgent.isPending || setPrivate.isPending}
title={agent.is_private ? "移出私密广场" : "放入私密广场"}
>
<ShieldIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleDuplicate()}
disabled={createAgent.isPending}
title="复制为新智能体"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canEdit && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑智能体"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
)}
{canDelete && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
)}
</>
);
const chatButton = (
<Button
size="sm"
className={cn(isList ? "shrink-0" : "w-full")}
onClick={handleChat}
>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
);
const cardLayout = (
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
<CardHeader className="min-w-0 pb-2">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="flex shrink-0 items-center gap-1">
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
我的
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
>
来自他人
</Badge>
)}
{isOwned && agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title="该智能体已对所有用户可见"
>
<GlobeIcon className="size-3" />
已发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title="该智能体位于私密广场,需要密码才能查看"
>
<ShieldIcon className="size-3" />
私密
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
title="已加入首页选择器"
>
<SparklesIcon className="size-3" />
首页推荐
<div className="min-w-0 flex-1 overflow-hidden">
<CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{desensitize(agent.model)}
</Badge>
)}
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(agent.description)}
</CardDescription>
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{statusBadges}
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(agent.description)}
</CardDescription>
)}
</CardHeader>
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3">
<div className="flex flex-wrap gap-1">
{agent.tool_groups?.map((group) => (
<Badge
key={`tg:${group}`}
variant="outline"
className="text-xs"
>
{desensitize(group)}
</Badge>
))}
{agent.skills?.map((skill) => (
<Badge
key={`sk:${skill}`}
variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs"
title={t.settings.skills.viewDetailAction}
onClick={(e) => {
e.stopPropagation();
setDetailSkillName(skill);
}}
>
{desensitize(skill)}
</Badge>
))}
</div>
</CardContent>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{hasSkillsOrTools && (
<CardContent className="pt-0 pb-2">
<ClampedBadgeRow items={skillBadgeItems} />
</CardContent>
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
{showActionRow && (
<div className="flex flex-wrap items-center justify-end gap-1">
{actionButtons}
</div>
)}
{chatButton}
</CardFooter>
</Card>
);
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
<div className="flex flex-wrap items-center justify-end gap-1">
{canFavorite && (
<Button
size="icon"
variant="ghost"
className={cn(
"mr-auto h-8 w-8 shrink-0",
isFavorite && "text-amber-500 hover:text-amber-600",
)}
onClick={() => void handleToggleFavorite()}
disabled={toggleFavorite.isPending}
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
>
<StarIcon
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
/>
</Button>
)}
{isAdmin && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.featured_order != null &&
"text-amber-600 hover:text-amber-700",
)}
onClick={() => void handleToggleFeatured()}
disabled={setFeatured.isPending}
title={
agent.featured_order != null
? "从首页选择器移除"
: "加入首页选择器"
}
>
<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>
)}
{isOwned && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
title={agent.published ? "取消发布" : "发布"}
>
{agent.published ? (
<LockIcon className="h-3.5 w-3.5" />
) : (
<GlobeIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
{(isOwned || isAdmin) && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.is_private &&
"text-purple-600 hover:text-purple-700",
)}
onClick={() => void handleTogglePrivate()}
disabled={updateAgent.isPending || setPrivate.isPending}
title={
agent.is_private ? "移出私密广场" : "放入私密广场"
}
>
<ShieldIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleDuplicate()}
disabled={createAgent.isPending}
title="复制为新智能体"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canEdit && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑智能体"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
)}
{canDelete && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
)}
</div>
)}
<Button size="sm" className="w-full" onClick={handleChat}>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
</CardFooter>
</Card>
const listLayout = (
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex min-w-0 items-center gap-2">
<span
className="min-w-0 truncate text-sm font-medium"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</span>
<div className="flex shrink-0 items-center gap-1">{statusBadges}</div>
</div>
{agent.description && (
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{desensitize(agent.description)}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons}
{chatButton}
</div>
</div>
);
return (
<>
{isList ? listLayout : cardLayout}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
@@ -1,23 +1,26 @@
"use client";
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents, usePrivateAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
import { AgentCard } from "./agent-card";
const PAGE_SIZE = 12;
// Within a single tab the verified password is kept in sessionStorage so a
// refresh / route navigation does not re-prompt. Cleared on tab close, so the
// password never persists beyond the browsing session.
@@ -26,24 +29,26 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
"mine",
);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
// Tag filtering stays server-side; name search must run on the *desensitized*
// form because that's what the user sees on the card. If we sent the search
// string to the backend it would compare against the raw stored name and
// miss every desensitized hit (e.g. typing "TS分析" wouldn't match the
// stored "态势分析").
const { agents, isLoading } = useAgents({ tagIds });
const [sort, setSort] = useState<AgentSort>("time_desc");
// Per-tab 1-based page index, preserved while switching tabs.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 },
);
const needle = search.trim().toLowerCase();
const matchesSearch = (a: Agent) =>
!needle || desensitize(a.name).toLowerCase().includes(needle);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
// Search + tag filtering are both server-side now. The backend mirrors the
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
// "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
// filters change, every tab returns to its first page.
const tagKey = tagIds.join(",");
useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
}, [search, tagKey, sort]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
@@ -56,11 +61,37 @@ export function AgentGallery() {
});
const [passwordInput, setPasswordInput] = useState("");
// Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
const mainScope = activeTab === "mine" ? "mine" : "square";
const mainPage = activeTab === "mine" ? pages.mine : pages.all;
const {
agents: mainAgents,
total: mainTotal,
isLoading: mainLoading,
} = useAgentsPaginated({
scope: mainScope,
search,
tagIds,
sort,
page: mainPage,
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
});
const {
agents: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading,
error: privateError,
} = usePrivateAgents(privatePassword);
} = usePrivateAgentsPaginated(privatePassword, {
search,
tagIds,
sort,
page: pages.private,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
});
// If the server reports the stored password no longer works (likely the
// admin rotated it), drop it from session storage and re-prompt.
@@ -72,49 +103,9 @@ export function AgentGallery() {
}
}, [privateError, privatePassword]);
// The "广场" tab must never surface private-square agents, even to their
// own owner — moving an agent into the private square is supposed to hide
// it from the public square entirely. (The backend listing endpoint
// already filters them out for non-owners; this extra clause covers the
// owner's own private agents, which still come back in the unified list
// because owners always see their own.)
const allAgents = useMemo(
() =>
agents.filter(
(a) => (a.builtin || a.published) && !a.is_private && matchesSearch(a),
),
// matchesSearch closes over `needle`; depending on `search` is sufficient
// and avoids re-running on every render via the function reference.
// eslint-disable-next-line react-hooks/exhaustive-deps
[agents, search],
);
// "我的" shows the user's own agents plus any agent they've favorited (收藏)
// from the square — favorited public agents have a different user_id but a
// truthy is_favorite flag.
const myAgents = useMemo(
() =>
agents.filter(
(a) =>
((a.user_id !== null && a.user_id === currentUserId) ||
a.is_favorite) &&
matchesSearch(a),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[agents, currentUserId, search],
);
const filteredPrivateAgents = useMemo(() => {
const wanted = new Set(tagIds);
return privateAgents.filter((a) => {
if (!matchesSearch(a)) return false;
if (wanted.size > 0) {
if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false;
}
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [privateAgents, search, tagIds]);
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
const handleNewAgent = () => {
router.push("/page/workspace/agents/new");
@@ -136,13 +127,56 @@ export function AgentGallery() {
setPrivatePassword(null);
};
const renderGrid = (list: typeof agents) => (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
);
const renderGrid = (list: Agent[]) =>
viewMode === "list" ? (
<div className="flex flex-col gap-2">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} layout="list" />
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
);
/** Loading / empty / (grid + pagination) for one paginated tab. */
const renderListBody = (opts: {
list: Agent[];
total: number;
isLoading: boolean;
tab: "all" | "mine" | "private";
emptyDescription: string;
showNewButton?: boolean;
}) => {
const { list, total, isLoading, tab, emptyDescription, showNewButton } = opts;
// keepPreviousData keeps the old page visible while a new page loads, so
// only show the spinner on the very first load (no rows yet).
if (isLoading && list.length === 0) {
return (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
);
}
if (list.length === 0) {
return renderEmpty(emptyDescription, showNewButton);
}
return (
<div className="flex flex-col gap-4">
{renderGrid(list)}
<Pagination
page={pages[tab]}
pageSize={PAGE_SIZE}
total={total}
onPageChange={(p) => setTabPage(tab, p)}
className="pt-2"
/>
</div>
);
};
const renderEmpty = (description: string, showNewButton = false) => (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
@@ -218,6 +252,15 @@ export function AgentGallery() {
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitPassword();
}}
// This is a one-off access code, not a login credential. Tell
// browsers / password managers not to save or autofill it (which
// would also stop them from spilling a saved account into the
// name search box on this page).
name="private-square-access-code"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
</div>
@@ -225,19 +268,13 @@ export function AgentGallery() {
);
}
if (isPrivateLoading) {
return (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
);
}
if (filteredPrivateAgents.length === 0) {
return renderEmpty("私密广场暂时还没有智能体。");
}
return renderGrid(filteredPrivateAgents);
return renderListBody({
list: privateAgents,
total: privateTotal,
isLoading: isPrivateLoading,
tab: "private",
emptyDescription: "私密广场暂时还没有智能体。",
});
};
return (
@@ -276,48 +313,70 @@ export function AgentGallery() {
</TabsTrigger>
)}
</TabsList>
{activeTab === "private" && privatePassword && (
<Button
variant="outline"
size="sm"
onClick={handleLockPrivate}
title="锁定私密广场,下次需要重新输入密码"
>
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
锁定
</Button>
)}
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm whitespace-nowrap">
共 {activeTotal} 个
</span>
<ViewModeToggle value={viewMode} onChange={setViewMode} />
{activeTab === "private" && privatePassword && (
<Button
variant="outline"
size="sm"
onClick={handleLockPrivate}
title="锁定私密广场,下次需要重新输入密码"
>
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
锁定
</Button>
)}
</div>
</div>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="updated_desc">最近更新</SelectItem>
<SelectItem value="updated_asc">最早更新</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : allAgents.length === 0 ? (
renderEmpty(t.agents.emptyDescription)
) : (
renderGrid(allAgents)
)}
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
tab: "all",
emptyDescription: t.agents.emptyDescription,
})}
</TabsContent>
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? (
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
{t.common.loading}
</div>
) : myAgents.length === 0 ? (
renderEmpty(t.agents.emptyDescription, true)
) : (
renderGrid(myAgents)
)}
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
tab: "mine",
emptyDescription: t.agents.emptyDescription,
showNewButton: true,
})}
</TabsContent>
{privateFeatureAvailable && (
@@ -136,21 +136,21 @@ export function AgentManageDialog({
{agents.map((agent, index) => (
<div
key={agent.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-accent/30"
className="flex min-w-0 items-start gap-2 px-3 py-2 hover:bg-accent/30"
>
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs tabular-nums">
{index + 1}
</span>
<BotIcon className="text-muted-foreground size-4 shrink-0" />
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="truncate text-[11px] text-muted-foreground">
<span className="line-clamp-2 whitespace-normal break-words text-[11px] leading-snug text-muted-foreground">
{agent.description}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip content="上移">
<Button
size="icon"
@@ -222,12 +222,13 @@ export function AgentManageDialog({
key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`}
onSelect={() => void add(agent)}
className="flex w-full min-w-0 items-start overflow-hidden"
>
<BotIcon className="text-muted-foreground mr-2 size-4 shrink-0" />
<BotIcon className="text-muted-foreground mr-2 mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="truncate text-[10px] text-muted-foreground">
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
{agent.description}
</span>
)}
@@ -120,15 +120,15 @@ export function AgentSelector({
value={`${agent.name} ${agent.description ?? ""}`}
onSelect={() => handleSelect(agent)}
className={cn(
"flex items-center gap-2",
"flex w-full min-w-0 items-start gap-2 overflow-hidden",
agent.id === selectedAgentId && "bg-accent",
)}
>
<BotIcon className="text-muted-foreground size-4 shrink-0" />
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span>
{agent.description && (
<span className="truncate text-[10px] text-muted-foreground">
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
{agent.description}
</span>
)}
@@ -14,6 +14,7 @@ import {
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -43,24 +44,29 @@ import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import {
checkCodeFile,
getFileName,
isImageFile,
} from "@/core/utils/files";
import {
downloadMermaidSvgFile,
isMermaidFile,
openMermaidSvgInNewTab,
renderMermaidToSvg,
} from "@/core/utils/mermaid";
import {
checkCodeFile,
getFileName,
isImageFile,
} from "@/core/utils/files";
import {
downloadMermaidSvgFile,
isMermaidFile,
openMermaidSvgInNewTab,
renderMermaidToSvg,
} from "@/core/utils/mermaid";
import {
createMarkdownAnchorResolver,
injectInPageAnchorHandler,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { getBackendBaseURL } from "@/core/config";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -73,6 +79,12 @@ import { useArtifacts } from "./context";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
function findScrollableArtifactElement(root: HTMLElement | null) {
if (!root) {
return null;
@@ -135,14 +147,14 @@ export function ArtifactFileDetail({
return { isCodeFile: true, language: "markdown" };
}
return checkCodeFile(filepath);
}, [filepath, useWriteFileStreaming, isSkillFile]);
const isSupportPreview = useMemo(() => {
return (
language === "html" ||
language === "markdown" ||
language === "mermaid"
);
}, [language]);
}, [filepath, useWriteFileStreaming, isSkillFile]);
const isSupportPreview = useMemo(() => {
return (
language === "html" ||
language === "markdown" ||
language === "mermaid"
);
}, [language]);
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
@@ -160,12 +172,12 @@ export function ArtifactFileDetail({
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isHtmlStreaming = language === "html" && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (isMermaidFile(filepath) || language === "mermaid") return "MMD";
if (language === "html") return "HTML";
if (language === "markdown") return "MD";
if (isSkillFile) return "SKILL";
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (isMermaidFile(filepath) || language === "mermaid") return "MMD";
if (language === "html") return "HTML";
if (language === "markdown") return "MD";
if (isSkillFile) return "SKILL";
if (language) return language.toUpperCase();
return "FILE";
}, [filepath, isSkillFile, language]);
@@ -434,16 +446,16 @@ export function ArtifactFileDetail({
toast.error("文件内容为空,暂时无法打开");
return;
}
if (language === "mermaid") {
void openMermaidSvgInNewTab(source).catch((error) => {
console.error("Failed to open mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Failed to open artifact");
});
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
if (language === "mermaid") {
void openMermaidSvgInNewTab(source).catch((error) => {
console.error("Failed to open mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Failed to open artifact");
});
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
@@ -453,22 +465,22 @@ export function ArtifactFileDetail({
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}, [displayContent, language]);
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法下载");
return;
}
if (language === "mermaid") {
void downloadMermaidSvgFile(source, filepath).catch((error) => {
console.error("Failed to download mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Download failed");
});
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
if (language === "mermaid") {
void downloadMermaidSvgFile(source, filepath).catch((error) => {
console.error("Failed to download mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Download failed");
});
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
@@ -587,27 +599,27 @@ export function ArtifactFileDetail({
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
if (useWriteFileStreaming) {
handleOpenWriteFileInNewTab();
return;
}
if (language === "mermaid") {
try {
await openMermaidSvgInNewTab(displayContent ?? "");
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
return;
}
try {
await openArtifactInNewTab({
filepath,
onClick={async () => {
if (useWriteFileStreaming) {
handleOpenWriteFileInNewTab();
return;
}
if (language === "mermaid") {
try {
await openMermaidSvgInNewTab(displayContent ?? "");
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
return;
}
try {
await openArtifactInNewTab({
filepath,
threadId,
isMock,
publicShareCode,
@@ -652,27 +664,27 @@ export function ArtifactFileDetail({
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
onClick={async () => {
if (useWriteFileStreaming) {
handleDownloadWriteFile();
return;
}
if (language === "mermaid") {
try {
await downloadMermaidSvgFile(displayContent ?? "", filepath);
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Download failed",
);
}
return;
}
try {
await downloadArtifactFile({
filepath,
onClick={async () => {
if (useWriteFileStreaming) {
handleDownloadWriteFile();
return;
}
if (language === "mermaid") {
try {
await downloadMermaidSvgFile(displayContent ?? "", filepath);
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Download failed",
);
}
return;
}
try {
await downloadArtifactFile({
filepath,
threadId,
isMock,
publicShareCode,
@@ -717,15 +729,15 @@ export function ArtifactFileDetail({
<ArrowDownIcon className="size-4" />
滑动到底部并继续自动下滑
</button>
)}
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" ||
language === "html" ||
language === "mermaid") && (
<ArtifactFilePreview
content={desensitize(displayContent ?? "")}
language={language ?? "text"}
)}
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" ||
language === "html" ||
language === "mermaid") && (
<ArtifactFilePreview
content={desensitize(displayContent ?? "")}
language={language ?? "text"}
threadId={threadId}
publicShareCode={publicShareCode}
animated={showPreviewSwitchPulse}
@@ -821,64 +833,127 @@ export function ArtifactFilePreview({
publicShareCode?: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
const [mermaidError, setMermaidError] = useState<string | null>(null);
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
const [mermaidError, setMermaidError] = useState<string | null>(null);
const markdownComponents = useMemo(() => {
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
{children}
</Tag>
);
};
useEffect(() => {
if (language !== "html") {
setHtmlPreviewUrl(undefined);
return;
return {
h1: heading(1),
h2: heading(2),
h3: heading(3),
h4: heading(4),
h5: heading(5),
h6: heading(6),
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
return <ArtifactLink {...props} />;
}
const { className, ...rest } = props;
const resolvedHref = anchorResolver.resolveHref(
props.href,
extractTextFromChildren(props.children),
);
if (resolvedHref === null) {
return (
<span
className={cn(
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
className,
)}
title="未在当前 Markdown 中找到对应章节"
>
{props.children}
</span>
);
}
const finalHref = resolvedHref ?? props.href;
return (
<ArtifactLink
{...rest}
className={className}
href={finalHref}
onClick={(event) => {
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
rest.onClick?.(event);
}}
/>
);
},
};
}, [content]);
useEffect(() => {
if (language !== "html") {
setHtmlPreviewUrl(undefined);
return;
}
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
const blob = new Blob([content ?? ""], { type: "text/html;charset=utf-8" });
// Intercept in-page anchor clicks so they scroll within the iframe instead
// of navigating the sandboxed blob: document (which blanks the frame).
const blob = new Blob([injectInPageAnchorHandler(content ?? "")], {
type: "text/html;charset=utf-8",
});
const url = URL.createObjectURL(blob);
setHtmlPreviewUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [content, language]);
useEffect(() => {
if (language !== "mermaid") {
setMermaidSvg(null);
setMermaidError(null);
return;
}
let cancelled = false;
setMermaidSvg(null);
setMermaidError(null);
void renderMermaidToSvg(content ?? "")
.then((svg) => {
if (!cancelled) {
setMermaidSvg(svg);
}
})
.catch((error) => {
console.error("Failed to render mermaid preview:", error);
if (!cancelled) {
setMermaidError(
error instanceof Error ? error.message : "Mermaid 渲染失败",
);
}
});
return () => {
cancelled = true;
};
}, [content, language]);
URL.revokeObjectURL(url);
};
}, [content, language]);
useEffect(() => {
if (language !== "mermaid") {
setMermaidSvg(null);
setMermaidError(null);
return;
}
let cancelled = false;
setMermaidSvg(null);
setMermaidError(null);
void renderMermaidToSvg(content ?? "")
.then((svg) => {
if (!cancelled) {
setMermaidSvg(svg);
}
})
.catch((error) => {
console.error("Failed to render mermaid preview:", error);
if (!cancelled) {
setMermaidError(
error instanceof Error ? error.message : "Mermaid 渲染失败",
);
}
});
return () => {
cancelled = true;
};
}, [content, language]);
if (language === "markdown") {
return (
<div
data-artifact-preview-scroll
data-markdown-anchor-root
className={cn(
"size-full overflow-auto px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
@@ -888,7 +963,7 @@ export function ArtifactFilePreview({
className="size-full"
{...streamdownPlugins}
components={{
a: ArtifactLink,
...markdownComponents,
img: (props) => (
<SandboxPreviewImage
{...props}
@@ -907,9 +982,9 @@ export function ArtifactFilePreview({
);
}
if (language === "html") {
return (
<iframe
if (language === "html") {
return (
<iframe
className={cn(
"size-full bg-white",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
@@ -918,41 +993,41 @@ export function ArtifactFilePreview({
sandbox="allow-scripts allow-forms"
src={htmlPreviewUrl}
/>
);
}
if (language === "mermaid") {
return (
<div
data-artifact-preview-scroll
className={cn(
"size-full overflow-auto bg-[#fcfdfc] px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
{mermaidSvg ? (
<div
className="mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
/>
) : mermaidError ? (
<div className="space-y-3">
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{mermaidError}
</div>
<pre className="whitespace-pre-wrap break-words rounded-xl bg-slate-100 p-4 text-xs text-slate-700">
{content ?? ""}
</pre>
</div>
) : (
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
<LoaderIcon className="size-5 animate-spin" />
正在生成图表预览
</div>
)}
</div>
);
}
return null;
}
);
}
if (language === "mermaid") {
return (
<div
data-artifact-preview-scroll
className={cn(
"size-full overflow-auto bg-[#fcfdfc] px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
{mermaidSvg ? (
<div
className="mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
/>
) : mermaidError ? (
<div className="space-y-3">
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{mermaidError}
</div>
<pre className="whitespace-pre-wrap break-words rounded-xl bg-slate-100 p-4 text-xs text-slate-700">
{content ?? ""}
</pre>
</div>
) : (
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
<LoaderIcon className="size-5 animate-spin" />
正在生成图表预览
</div>
)}
</div>
);
}
return null;
}
@@ -19,32 +19,43 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAgentSelector } from "@/core/agents/hooks";
import {
useCreateCustomPrompt,
useCustomPrompts,
useDeleteCustomPrompt,
useUpdateCustomPrompt,
} from "@/core/custom-prompts";
import {
useUpdateUserPreferences,
useUserPreferences,
} from "@/core/user-preferences";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* Bar above the chat input showing the user's saved prompts as centered,
* clickable chips. When there are too many to fit, they become a horizontal
* carousel (arrows appear). A gear pinned to the far right opens the manage
* dialog. Prompts are bound to the account (server-backed), and this coexists
* with the admin-configured global prompt prefix.
*/
export function CustomPromptBar({
onInsert,
className,
currentAgentId,
}: {
onInsert: (content: string) => void;
className?: string;
currentAgentId?: string | null;
}) {
const prompts = useCustomPrompts();
const allPrompts = useCustomPrompts();
const prompts = allPrompts.filter(
(prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
);
const [manageOpen, setManageOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [canLeft, setCanLeft] = useState(false);
const [canRight, setCanRight] = useState(false);
@@ -60,7 +71,7 @@ export function CustomPromptBar({
updateArrows();
window.addEventListener("resize", updateArrows);
return () => window.removeEventListener("resize", updateArrows);
}, [updateArrows, prompts]);
}, [prompts, updateArrows]);
const scrollByDir = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
@@ -70,7 +81,7 @@ export function CustomPromptBar({
return (
<>
<div className={cn("flex items-center gap-1", className)}>
<div className={cn("flex min-w-0 items-center gap-1", className)}>
{prompts.length > 0 && (
<>
{overflowing && (
@@ -89,7 +100,7 @@ export function CustomPromptBar({
<div
ref={scrollRef}
onScroll={updateArrows}
className="flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className="min-w-0 flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<div className="mx-auto flex w-max items-center gap-1.5 px-0.5 py-0.5">
{prompts.map((prompt) => (
@@ -123,7 +134,7 @@ export function CustomPromptBar({
)}
</>
)}
{prompts.length === 0 && <div className="flex-1" />}
{prompts.length === 0 && <div className="min-w-0 flex-1" />}
<Button
type="button"
variant="ghost"
@@ -153,16 +164,26 @@ function ManagePromptsDialog({
const createPrompt = useCreateCustomPrompt();
const updatePrompt = useUpdateCustomPrompt();
const deletePrompt = useDeleteCustomPrompt();
const { agents } = useAgentSelector();
const preferences = useUserPreferences();
const updatePreferences = useUpdateUserPreferences();
const [editingId, setEditingId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [agentId, setAgentId] = useState<string | undefined>(undefined);
const saving = createPrompt.isPending || updatePrompt.isPending;
const selectableAgents = agents;
const agentNameById = (id?: string | null) =>
id ? agents.find((agent) => agent.id === id)?.name ?? null : null;
const resetForm = () => {
setEditingId(null);
setTitle("");
setContent("");
setAgentId(undefined);
};
const handleSubmit = async () => {
@@ -171,15 +192,24 @@ function ManagePromptsDialog({
toast.error("提示词内容不能为空");
return;
}
if (!agentId) {
toast.error("请选择关联智能体");
return;
}
try {
if (editingId) {
await updatePrompt.mutateAsync({
id: editingId,
input: { title: trimmedTitle, content },
input: { title: trimmedTitle, content, agent_id: agentId },
});
toast.success("提示词已更新");
} else {
await createPrompt.mutateAsync({ title: trimmedTitle, content });
await createPrompt.mutateAsync({
title: trimmedTitle,
content,
agent_id: agentId,
});
toast.success("提示词已添加");
}
resetForm();
@@ -189,17 +219,31 @@ function ManagePromptsDialog({
};
const handleEdit = (id: string) => {
const target = prompts.find((p) => p.id === id);
const target = prompts.find((prompt) => prompt.id === id);
if (!target) return;
setEditingId(id);
setTitle(target.title);
setContent(target.content);
setAgentId(
target.agent_id && selectableAgents.some((agent) => agent.id === target.agent_id)
? target.agent_id
: undefined,
);
};
const handleToggleHideAdmin = async (next: boolean) => {
try {
await updatePreferences.mutateAsync({ hide_admin_prompt_prefix: next });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async (id: string) => {
try {
await deletePrompt.mutateAsync(id);
if (editingId === id) resetForm();
toast.success("提示词已删除");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
@@ -217,25 +261,73 @@ function ManagePromptsDialog({
<DialogHeader>
<DialogTitle>管理常用提示词</DialogTitle>
<DialogDescription>
保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。
保存常用提示词后,会显示在输入框上方。提示词与账号绑定,可跨设备使用。
</DialogDescription>
</DialogHeader>
<div className="flex items-start justify-between gap-3 rounded-md border p-3">
<div className="min-w-0">
<p className="text-sm font-medium">只用我自己的提示词</p>
<p className="text-muted-foreground mt-0.5 text-xs">
开启后,输入框不再显示管理员配置的提示词前缀开关,本账号仅使用自己保存的提示词。
</p>
</div>
<Switch
checked={preferences.hide_admin_prompt_prefix}
disabled={updatePreferences.isPending}
onCheckedChange={(value) => void handleToggleHideAdmin(value)}
/>
</div>
<div className="space-y-3 rounded-md border p-3">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={(event) => setTitle(event.target.value)}
placeholder="名称(可选,例如:周报总结)"
/>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onChange={(event) => setContent(event.target.value)}
placeholder="提示词内容"
className="min-h-28"
/>
<div className="flex min-w-0 flex-col gap-1">
<p className="text-muted-foreground text-xs">关联智能体</p>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate [&>span]:text-left">
<SelectValue placeholder="请选择已配置的智能体" />
</SelectTrigger>
<SelectContent
position="popper"
className="max-h-[40vh] w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)] overflow-hidden"
>
{selectableAgents.map((agent) => (
<SelectItem
key={agent.id}
value={agent.id}
className="max-w-full pr-8"
>
<span className="block max-w-full truncate">
{desensitize(agent.name)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{selectableAgents.length === 0
? "当前账号还没有已配置的智能体。"
: "关联后,仅在选中该智能体的对话里显示此提示词。"}
</p>
</div>
<div className="flex justify-end gap-2">
{editingId && (
<Button variant="outline" size="sm" onClick={resetForm} disabled={saving}>
<Button
variant="outline"
size="sm"
onClick={resetForm}
disabled={saving}
>
取消编辑
</Button>
)}
@@ -260,6 +352,11 @@ function ManagePromptsDialog({
<p className="truncate text-sm font-medium">
{prompt.title || "未命名提示词"}
</p>
{prompt.agent_id && (
<p className="mt-0.5 truncate text-[11px] text-sky-600 dark:text-sky-400">
仅 {desensitize(agentNameById(prompt.agent_id) || "未知智能体")} 可见
</p>
)}
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap">
{prompt.content}
</p>
@@ -86,7 +86,8 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import {
isThreadRagModeLocked,
lockThreadRagMode,
@@ -131,6 +132,7 @@ export function InputBox({
onStop,
promptPrefix,
agentSelector,
assistantId,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -203,7 +205,11 @@ export function InputBox({
null,
);
const { questions: recommendedQuestions } = useRecommendedQuestions();
const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences();
const showAdminRecommendedQuestions =
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
useEffect(() => {
if (models.length === 0) {
@@ -510,7 +516,7 @@ export function InputBox({
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && recommendedQuestions.length > 0 && (
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
@@ -564,6 +570,7 @@ export function InputBox({
{isNewThread && (
<CustomPromptBar
className="px-1"
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
onInsert={(content) => {
const current = textInput.value?.trim() ?? "";
textInput.setInput(current ? `${current}\n${content}` : content);
@@ -0,0 +1,99 @@
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import { useDeleteMessage } from "@/core/threads/hooks";
import { Tooltip } from "../tooltip";
/**
* Hover affordance that deletes a conversation turn (a user question together
* with its answer) after a confirmation step. The actual removal is persisted
* server-side; `onDeleted` receives the ids the server removed so the parent
* can hide those bubbles immediately without waiting for the stream cache to
* refresh.
*/
export function DeleteMessageButton({
threadId,
messageId,
onDeleted,
className,
}: {
threadId: string;
messageId: string;
onDeleted?: (deletedIds: string[]) => void;
className?: string;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const deleteMessage = useDeleteMessage();
const handleConfirm = async () => {
try {
const deletedIds = await deleteMessage.mutateAsync({
threadId,
messageId,
});
onDeleted?.(deletedIds);
setOpen(false);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "删除消息失败。",
);
}
};
return (
<>
<Tooltip content={t.inputBox.deleteMessage}>
<Button
size="icon-sm"
type="button"
variant="ghost"
className={className}
onClick={() => setOpen(true)}
>
<Trash2Icon size={12} />
</Button>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{t.inputBox.deleteMessageConfirmTitle}</DialogTitle>
<DialogDescription>
{t.inputBox.deleteMessageConfirmDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={deleteMessage.isPending}
>
{t.common.cancel}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void handleConfirm()}
disabled={deleteMessage.isPending}
>
{t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,79 +1,136 @@
"use client";
import { useMemo } from "react";
import type { AnchorHTMLAttributes } from "react";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean {
return !!href && /^https?:\/\//.test(href);
}
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
components?: MessageResponseProps["components"];
};
/** Renders markdown content. */
export function MarkdownContent({
content,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
}: MarkdownContentProps) {
const components = useMemo(() => {
return {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />;
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
}
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href);
return (
<a
{...rest}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
/>
);
},
...componentsFromProps,
};
}, [componentsFromProps]);
if (!content) return null;
return (
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</MessageResponse>
);
}
"use client";
import { useMemo } from "react";
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
function isExternalUrl(href: string | undefined): boolean {
return !!href && /^https?:\/\//.test(href);
}
function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
components?: MessageResponseProps["components"];
};
/** Renders markdown content. */
export function MarkdownContent({
content,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
}: MarkdownContentProps) {
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const components = useMemo(() => {
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
const Tag = `h${level}` as const;
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
{children}
</Tag>
);
};
return {
h1: heading(1),
h2: heading(2),
h3: heading(3),
h4: heading(4),
h5: heading(5),
h6: heading(6),
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />;
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
}
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href);
const resolvedHref = anchorResolver.resolveHref(
props.href,
extractTextFromChildren(props.children),
);
if (resolvedHref === null) {
return (
<span
className={cn(
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
className,
)}
title="未在当前 Markdown 中找到对应章节"
>
{props.children}
</span>
);
}
const finalHref = resolvedHref ?? props.href;
return (
<a
{...rest}
href={finalHref}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
onClick={(event) => {
// HashRouter would turn a `#section` click into a route change → 404.
// Scroll to the heading ourselves instead.
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
rest.onClick?.(event);
}}
/>
);
},
...componentsFromProps,
};
}, [anchorResolver, componentsFromProps]);
if (!content) return null;
return (
<div data-markdown-anchor-root className="contents">
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</MessageResponse>
</div>
);
}
@@ -57,6 +57,7 @@ import { CopyButton } from '../copy-button';
import { Tooltip } from '../tooltip';
import { SkillDisplayBlock } from '../skill-display-block';
import { ThreadContext } from './context';
import { DeleteMessageButton } from './delete-message-button';
import { DownloadWordButton } from './download-word-button';
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
import { MessageTokenUsage } from './message-token-usage';
@@ -135,6 +136,7 @@ export function MessageListItem({
displaySources = [],
extraRehypePlugins,
onEditResubmit,
onMessageDeleted,
}: {
className?: string;
fitRowToContent?: boolean;
@@ -145,6 +147,7 @@ export function MessageListItem({
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onMessageDeleted?: (deletedIds: string[]) => void;
}) {
const isHuman = message.type === 'human';
const messageMarkdown =
@@ -170,6 +173,7 @@ export function MessageListItem({
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onMessageDeleted={onMessageDeleted}
/>
{/*
Human messages carry their own inline copy/edit toolbar inside
@@ -214,6 +218,7 @@ function HumanMessageBlock({
messageTime,
components,
onEditResubmit,
deleteButton,
}: {
text: string;
pageContext?: EmbeddedPageContext | null;
@@ -221,6 +226,7 @@ function HumanMessageBlock({
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void;
deleteButton?: React.ReactNode;
}) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
@@ -298,10 +304,10 @@ function HumanMessageBlock({
)}
<MessageTimeStamp time={messageTime} align="right" />
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
{text && (
{(text || deleteButton) && (
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
<CopyButton clipboardData={text} className="size-6" />
{onEditResubmit && (
{text && <CopyButton clipboardData={text} className="size-6" />}
{text && onEditResubmit && (
<Tooltip content={t.inputBox.editQuestion}>
<Button
size="icon-sm"
@@ -314,6 +320,7 @@ function HumanMessageBlock({
</Button>
</Tooltip>
)}
{deleteButton}
</div>
)}
</div>
@@ -360,6 +367,7 @@ function MessageContent_({
displaySources = [],
extraRehypePlugins,
onEditResubmit,
onMessageDeleted,
}: {
className?: string;
message: Message;
@@ -369,6 +377,7 @@ function MessageContent_({
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
onMessageDeleted?: (deletedIds: string[]) => void;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
@@ -500,6 +509,11 @@ function MessageContent_({
}
if (isHuman) {
// Only the user's own question carries a delete affordance, and only once
// it exists server-side (real, non-optimistic id). Deleting it cascades to
// its answer on the backend.
const canDelete =
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
return (
<HumanMessageBlock
text={parsedHumanContent.content}
@@ -508,6 +522,16 @@ function MessageContent_({
messageTime={messageTime}
components={components}
onEditResubmit={onEditResubmit}
deleteButton={
canDelete ? (
<DeleteMessageButton
threadId={threadId}
messageId={message.id!}
onDeleted={(ids) => onMessageDeleted?.(ids)}
className="size-6"
/>
) : null
}
/>
);
}
@@ -1,7 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import {
Conversation,
@@ -37,6 +37,7 @@ import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { StreamingIndicator } from "../streaming-indicator";
import { ThreadContext } from "./context";
import { MarkdownContent } from "./markdown-content";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
@@ -88,7 +89,38 @@ export function MessageList({
);
const updateSubtask = useUpdateSubtask();
const { t } = useI18n();
const messages = thread.messages;
const threadContext = useContext(ThreadContext);
// Read-only contexts (public share, mock previews) must not offer deletion.
const allowDelete = !threadContext?.publicShareCode && !threadContext?.isMock;
// Locally hide messages the user just deleted. The server-side checkpoint is
// trimmed immediately, but the live `useStream` cache still holds the message
// until the next refetch — filtering by id here makes the removal instant and
// keeps it hidden for the rest of the session (a reload reads the trimmed
// state). Reset whenever the active thread changes.
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
() => new Set(),
);
useEffect(() => {
setHiddenMessageIds(new Set());
}, [threadId]);
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
if (deletedIds.length === 0) return;
setHiddenMessageIds((prev) => {
const next = new Set(prev);
for (const id of deletedIds) next.add(id);
return next;
});
}, []);
const allMessages = thread.messages;
const messages = useMemo(
() =>
hiddenMessageIds.size === 0
? allMessages
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
[allMessages, hiddenMessageIds],
);
// Stamp a wall-clock time onto every question/answer the first time it is
// seen, in a single batched commit. Questions sent this session already carry
@@ -259,6 +291,9 @@ export function MessageList({
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
/>
);
});
@@ -1,6 +1,6 @@
"use client";
import { MoonIcon, SunIcon } from "lucide-react";
import { LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
import { toast } from "sonner";
@@ -48,6 +48,8 @@ export function AppearanceSettingsPage() {
const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light";
const globalSkillView = appearance?.default_skill_view ?? "list";
const globalAgentView = appearance?.default_agent_view ?? "card";
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
@@ -65,6 +67,27 @@ export function AppearanceSettingsPage() {
}
};
const handleSetDefaultView = async (
kind: "skill" | "agent",
value: "card" | "list",
) => {
const current = kind === "skill" ? globalSkillView : globalAgentView;
if (value === current) return;
try {
await updateAppearance.mutateAsync(
kind === "skill"
? { default_skill_view: value }
: { default_agent_view: value },
);
const label = kind === "skill" ? "技能" : "智能体";
toast.success(
`已将${label}默认展示模式设置为${value === "card" ? "卡片" : "列表"}`,
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const themeOptions = useMemo(
() => [
{
@@ -146,6 +169,52 @@ export function AppearanceSettingsPage() {
</>
)}
{isAdmin && (
<>
<Separator />
<SettingsSection
title="默认展示模式(管理员)"
description="技能与智能体列表对所有还没有手动切换过的用户的默认展示模式;已经自己切换过的用户保留其个人选择不受影响。"
>
<div className="flex flex-col gap-4">
{(
[
{ kind: "skill", label: "技能", current: globalSkillView },
{ kind: "agent", label: "智能体", current: globalAgentView },
] as const
).map(({ kind, label, current }) => (
<div key={kind} className="flex flex-wrap items-center gap-2">
<span className="w-14 text-sm font-medium">{label}</span>
<Button
type="button"
variant={current === "card" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultView(kind, "card")}
disabled={updateAppearance.isPending}
>
<LayoutGridIcon className="mr-1.5 size-4" />
卡片
</Button>
<Button
type="button"
variant={current === "list" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultView(kind, "list")}
disabled={updateAppearance.isPending}
>
<ListIcon className="mr-1.5 size-4" />
列表
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:{current === "card" ? "卡片" : "列表"}
</p>
</div>
))}
</div>
</SettingsSection>
</>
)}
<Separator />
<SettingsSection
@@ -3,6 +3,8 @@
import {
AlertCircle,
ArchiveIcon,
ArrowDownUpIcon,
BookmarkIcon,
CheckCircle2,
ChevronLeftIcon,
ChevronRightIcon,
@@ -14,7 +16,6 @@ import {
Loader2,
LockIcon,
PinIcon,
PinOffIcon,
RefreshCwIcon,
SparklesIcon,
TagsIcon,
@@ -28,13 +29,6 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -45,7 +39,6 @@ import {
} from "@/components/ui/dialog";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
@@ -60,11 +53,13 @@ import {
ItemDescription,
} from "@/components/ui/item";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
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 { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -74,11 +69,13 @@ import {
useDuplicateSkill,
useInstallSkillUpload,
useReconcileCustomSkills,
useSetSkillFeatured,
useSetSkillPinned,
useSetSkillPublished,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import { useViewMode } from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
@@ -100,6 +97,15 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
}
const PAGE_SIZE = 10;
type SkillSort =
| "time_desc"
| "time_asc"
| "activity_desc"
| "activity_asc"
| "usage_desc"
| "usage_asc"
| "name_asc"
| "name_desc";
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanSuccessToastDarkOptions = {
@@ -144,10 +150,12 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc");
const [page, setPage] = useState(1);
const [viewMode, setViewMode] = useViewMode("skill");
const reconcileMutation = useReconcileCustomSkills();
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds });
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -193,25 +201,36 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
}, [skills, currentUserId]);
const filteredSkills = filter === "mine" ? activeMine : activeAll;
const orderedSkills = useMemo(
() =>
filteredSkills
.map((skill, index) => ({ skill, index }))
.sort((a, b) => {
// 置顶(featured)决定排序;钉选(pinned)只管防归档,不影响顺序。
const featuredDelta =
Number(!!b.skill.featured) - Number(!!a.skill.featured);
return featuredDelta || a.index - b.index;
})
.map(({ skill }) => skill),
[filteredSkills],
);
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
const totalPages = Math.max(1, Math.ceil(orderedSkills.length / PAGE_SIZE));
// Filter / search / tag changes may shrink the result set — pull the page
// back into range without forcing a reset on every keystroke.
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
useEffect(() => {
setPage(1);
}, [search, tagIds, sort]);
const pagedSkills = useMemo(
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filteredSkills, page],
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page],
);
const handleCreateSkill = () => {
onClose?.();
router.push("/?mode=skill");
};
const handleRescan = async () => {
try {
const result = await reconcileMutation.mutateAsync();
@@ -311,10 +330,6 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<Upload className="size-4" />
{t.settings.skills.installSkill}
</Button>
<Button size="sm" onClick={handleCreateSkill}>
<SparklesIcon className="size-4" />
{t.settings.skills.createSkill}
</Button>
</div>
</header>
<TagSearchBar
@@ -322,36 +337,53 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<div className="flex items-center gap-2">
<ViewModeToggle value={viewMode} onChange={setViewMode} />
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="activity_desc">最近使用</SelectItem>
<SelectItem value="activity_asc">最早使用</SelectItem>
<SelectItem value="usage_desc">使用最多</SelectItem>
<SelectItem value="usage_asc">使用最少</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
</div>
}
/>
{isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill onCreateSkill={handleCreateSkill} />
<EmptySkill />
)}
{/* —— 原纵向列表布局(保留备查)——
{!isLoading && !error &&
filteredSkills.length > 0 &&
pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
/>
))} */}
{/* card */}
{!isLoading && !error && filteredSkills.length > 0 && (
<div className="grid grid-cols-1 items-stretch gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div
className={cn(
viewMode === "card"
? "grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
: "flex flex-col gap-3",
)}
>
{pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
className="h-full"
layout={viewMode === "card" ? "card" : "row"}
/>
))}
</div>
@@ -408,16 +440,19 @@ export function SkillRow({
skill,
currentUserId,
isAdmin,
className,
layout = "row",
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
className?: string;
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
layout?: "row" | "card";
}) {
const isCard = layout === "card";
const { t } = useI18n();
const setPublished = useSetSkillPublished();
const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured();
const deleteSkill = useDeleteCustomSkill();
const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
@@ -497,376 +532,253 @@ export function SkillRow({
}
}
async function handleToggleFeatured() {
const next = !skill.featured;
try {
await setFeatured.mutateAsync({ skillName: skill.name, featured: next });
toast.success(
next
? t.settings.skills.featureSuccess
: t.settings.skills.unfeatureSuccess,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
const badgeGroup = (
<div className="flex flex-wrap items-center gap-2">
<span className={cn(isCard && "truncate font-medium")}>
{desensitize(skill.name)}
</span>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin}
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
<GlobeIcon className="size-3" />
{t.settings.skills.badgePublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={t.settings.skills.badgeSourceAgentTitle}
>
{t.settings.skills.badgeSourceAgent}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeSourceUploadTitle}
>
{t.settings.skills.badgeSourceUpload}
</Badge>
)}
{skill.featured && (
<Badge
variant="outline"
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
title={t.settings.skills.badgeFeaturedTitle}
>
{t.settings.skills.badgeFeatured}
</Badge>
)}
{skill.pinned && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title={t.settings.skills.badgePinnedTitle}
>
{t.settings.skills.badgePinned}
</Badge>
)}
</div>
);
const metaNodes = (
<>
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
)}
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" />
)}
</>
);
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>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.featured && "text-amber-600 hover:text-amber-700",
)}
disabled={setFeatured.isPending}
onClick={() => void handleToggleFeatured()}
title={
skill.featured
? t.settings.skills.unfeatureAction
: t.settings.skills.featureAction
}
>
<PinIcon
className={cn("size-3.5", skill.featured && "fill-current")}
/>
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700",
)}
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
<BookmarkIcon
className={cn("size-3.5", skill.pinned && "fill-current")}
/>
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<LockIcon className="size-3.5" />
) : (
<GlobeIcon className="size-3.5" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
>
<CopyIcon className="size-3.5" />
</Button>
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</>
);
return (
<>
{/* —— 原 Item 列表行布局(保留备查)——
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
>
<ItemTitle>
<div className="flex flex-wrap items-center gap-2">
<span>{desensitize(skill.name)}</span>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin}
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
<GlobeIcon className="size-3" />
{t.settings.skills.badgePublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={t.settings.skills.badgeSourceAgentTitle}
>
{t.settings.skills.badgeSourceAgent}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeSourceUploadTitle}
>
{t.settings.skills.badgeSourceUpload}
</Badge>
)}
{skill.pinned && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title={t.settings.skills.badgePinnedTitle}
>
{t.settings.skills.badgePinned}
</Badge>
)}
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{desensitize(skill.description ?? "")}
</ItemDescription>
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
{skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
)}
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" />
)}
</ItemContent>
<ItemActions>
{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>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
{skill.pinned ? (
<PinOffIcon className="size-3.5" />
) : (
<PinIcon className="size-3.5" />
)}
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<LockIcon className="size-3.5" />
) : (
<GlobeIcon className="size-3.5" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
>
<CopyIcon className="size-3.5" />
</Button>
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</ItemActions>
</Item> */}
<Card
className={cn(
"group flex h-full min-w-0 cursor-pointer flex-col transition-shadow hover:shadow-md dark:!border-[#213D5E] dark:bg-[#02244C] dark:text-white",
className,
)}
onClick={() => setDetailOpen(true)}
>
<CardHeader className="min-w-0 flex flex-col gap-0">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<SparklesIcon className="h-5 w-5" />
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 overflow-hidden">
<CardTitle
className="min-w-0 flex-1 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(skill.name)}
>
{desensitize(skill.name)}
</CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs">
{isBuiltin
? t.settings.skills.badgeBuiltin
: skill.category}
</Badge>
</div>
</div>
<div className="mt-4 mb-1.5 flex min-h-5 flex-wrap items-center gap-1">
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
<GlobeIcon className="size-3" />
{t.settings.skills.badgePublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={t.settings.skills.badgeSourceAgentTitle}
>
{t.settings.skills.badgeSourceAgent}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeSourceUploadTitle}
>
{t.settings.skills.badgeSourceUpload}
</Badge>
)}
{skill.pinned && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title={t.settings.skills.badgePinnedTitle}
>
{t.settings.skills.badgePinned}
</Badge>
)}
</div>
<div className="mt-1.5 min-h-10 mb-3">
{skill.description ? (
<CardDescription className="line-clamp-2 text-sm">
{desensitize(skill.description)}
</CardDescription>
) : null}
</div>
<div className="text-muted-foreground mt-0.5 min-h-4 text-xs leading-tight">
{skill.use_count !== undefined && skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
{isCard ? (
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div
className={cn(
"min-w-0 max-w-full overflow-hidden pb-0",
(skill.tags?.length ?? 0) > 0 ? "pt-2.5" : "min-h-4",
)}
className="min-w-0 flex-1 cursor-pointer"
onClick={() => setDetailOpen(true)}
>
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="w-full" />
)}
{badgeGroup}
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
{desensitize(skill.description ?? "")}
</p>
{metaNodes}
</div>
</CardHeader>
<CardFooter
className="cursor-default py-0 pt-1.5"
onClick={(e) => e.stopPropagation()}
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
{actionButtons}
</div>
</div>
) : (
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
>
<div className="flex w-full flex-wrap items-center justify-end gap-1">
{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>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
{skill.pinned ? (
<PinOffIcon className="h-3.5 w-3.5" />
) : (
<PinIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<LockIcon className="h-3.5 w-3.5" />
) : (
<GlobeIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardFooter>
</Card>
<ItemTitle>{badgeGroup}</ItemTitle>
<ItemDescription className="line-clamp-4">
{desensitize(skill.description ?? "")}
</ItemDescription>
{metaNodes}
</ItemContent>
<ItemActions>{actionButtons}</ItemActions>
</Item>
)}
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent>
<DialogHeader>
@@ -950,7 +862,7 @@ export function SkillRow({
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
function EmptySkill() {
const { t } = useI18n();
return (
<Empty>
@@ -963,9 +875,6 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
{t.settings.skills.emptyDescription}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={onCreateSkill}>{t.settings.skills.emptyButton}</Button>
</EmptyContent>
</Empty>
);
}
@@ -1,7 +1,7 @@
"use client";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/core/i18n/hooks";
@@ -31,12 +31,14 @@ export function TagSearchBar({
tagIds,
onTagIdsChange,
className,
actions,
}: {
targetType: TagType;
onSearchChange: (value: string) => void;
tagIds: string[];
onTagIdsChange: (value: string[]) => void;
className?: string;
actions?: ReactNode;
}) {
const { t } = useI18n();
const { data: tags } = useTags(targetType);
@@ -67,14 +69,31 @@ export function TagSearchBar({
return (
<div className={cn("flex flex-col gap-2.5", className)}>
<div className="relative">
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative min-w-0 flex-1">
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
name="tag-search"
// A `type="password"` field elsewhere on the page (e.g. the private
// square access gate) makes browsers / password managers pick the
// nearest text input as the "username" and autofill the account into
// it. Mark this search box so it is never treated as a credential
// field.
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>
{actions && (
<div className="flex w-full shrink-0 justify-end sm:w-auto">
{actions}
</div>
)}
</div>
{allTags.length > 0 && (
@@ -0,0 +1,56 @@
"use client";
import { LayoutGridIcon, ListIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ViewMode } from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
/**
* Compact card/list switch shown in the header of the skills and agents lists.
* Purely presentational — the parent owns the `value` (resolved from the user's
* local override or the admin default via `useViewMode`) and persists changes.
*/
export function ViewModeToggle({
value,
onChange,
className,
}: {
value: ViewMode;
onChange: (mode: ViewMode) => void;
className?: string;
}) {
return (
<div
className={cn(
"bg-muted/60 inline-flex items-center gap-0.5 rounded-md p-0.5",
className,
)}
role="group"
aria-label="展示模式"
>
<Button
type="button"
size="icon"
variant={value === "card" ? "default" : "ghost"}
className="size-7"
onClick={() => onChange("card")}
title="卡片模式"
aria-pressed={value === "card"}
>
<LayoutGridIcon className="size-3.5" />
</Button>
<Button
type="button"
size="icon"
variant={value === "list" ? "default" : "ghost"}
className="size-7"
onClick={() => onChange("list")}
title="列表模式"
aria-pressed={value === "list"}
>
<ListIcon className="size-3.5" />
</Button>
</div>
);
}
@@ -4,19 +4,22 @@ import {
BellIcon,
BotIcon,
BrainIcon,
CalendarClockIcon,
ChevronsUpDown,
GaugeIcon,
CalendarClockIcon,
ChevronsUpDown,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
ScrollTextIcon,
Settings2Icon,
SettingsIcon,
ShieldCheckIcon,
SparklesIcon,
TagsIcon,
UsersIcon,
} from "lucide-react";
TagsIcon,
TrophyIcon,
UsersIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -36,6 +39,10 @@ import {
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
loadLogSystemSettings,
type LogSystemSettings,
} from "@/core/system-settings/api";
import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
@@ -71,6 +78,7 @@ export function WorkspaceNavMenu() {
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [logSystem, setLogSystem] = useState<LogSystemSettings | null>(null);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
@@ -79,6 +87,21 @@ export function WorkspaceNavMenu() {
setMounted(true);
}, []);
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
loadLogSystemSettings()
.then((settings) => {
if (!cancelled) setLogSystem(settings);
})
.catch(() => {
if (!cancelled) setLogSystem(null);
});
return () => {
cancelled = true;
};
}, [isAdmin]);
return (
<>
<SettingsDialog
@@ -166,10 +189,30 @@ export function WorkspaceNavMenu() {
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin")}
>
{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")}
>
<UsersIcon />
用户中心
</DropdownMenuItem>
@@ -208,6 +251,20 @@ export function WorkspaceNavMenu() {
{t.adminTags.title}
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem
onClick={() =>
window.open(
logSystem.url,
"_blank",
"noopener,noreferrer",
)
}
>
<ScrollTextIcon />
{logSystem.label || "日志系统"}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
+240
View File
@@ -35,6 +35,119 @@ export interface AdminMemoryResponse {
buckets: AdminMemoryBucket[];
}
export interface AdminLeaderboardUser {
user_id: string;
email: string;
system_role: string;
thread_count: number;
run_count: number;
qa_count: number;
llm_call_count: number;
tool_call_count: number;
skill_call_count: number;
total_tokens: number;
input_tokens: number;
output_tokens: number;
last_active_at: string;
}
export interface AdminLeaderboardSkill {
name: string;
call_count: number;
user_count: number;
success_count: number;
error_count: number;
avg_duration_ms: number;
last_used_at: string;
}
export interface AdminLeaderboardDimension {
name: string;
count: number;
user_count: number;
success_count: number;
error_count: number;
total_tokens: number;
last_seen_at: string;
}
export interface AdminLeaderboardQuestion {
text: string;
user_id: string;
email: string;
thread_id: string;
run_id: string;
asked_at: string;
}
export interface AdminLeaderboardTrendPoint {
date: string;
runs: number;
questions: number;
tokens: number;
users: number;
}
export interface AdminLeaderboardOverview {
total_users: number;
active_users: number;
total_threads: number;
total_runs: number;
total_questions: number;
total_tokens: number;
total_skill_calls: number;
total_tool_calls: number;
error_runs: number;
}
export interface AdminLeaderboardResponse {
range_days: number;
since: string;
until: string;
overview: AdminLeaderboardOverview;
users_by_activity: AdminLeaderboardUser[];
users_by_questions: AdminLeaderboardUser[];
users_by_tokens: AdminLeaderboardUser[];
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
frequent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminUserAnalyticsResponse {
range_days: number;
since: string;
until: string;
user: AdminLeaderboardUser;
overview: AdminLeaderboardOverview;
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminLeaderboardReportResponse {
report: string;
generated_at: string;
cached?: boolean;
model_name?: string | null;
}
export type AdminLeaderboardReportStreamEvent =
| { event: "meta"; data: { cached?: boolean; model_name?: string | null } }
| { event: "cached"; data: { report: string; generated_at?: string | null; model_name?: string | null } }
| { event: "thinking"; data: { chunk: string } }
| { event: "text"; data: { chunk: string } }
| { event: "done"; data: { cached?: boolean; generated_at?: string | null; model_name?: string | null; report?: string } }
| { event: "error"; data: { message: string } };
const base = () => `${getBackendBaseURL()}/api/admin/users`;
async function parse<T>(res: Response, what: string): Promise<T> {
@@ -73,6 +186,133 @@ export async function getUserMemory(
);
}
export async function getUserLeaderboard(
range: {
since: string;
until: string;
days?: number;
},
limit = 20,
): Promise<AdminLeaderboardResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/leaderboard?${params}`),
"Failed to load leaderboard",
);
}
export async function getUserAnalytics(
userId: string,
range: { since: string; until: string; days?: number },
limit = 20,
): Promise<AdminUserAnalyticsResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/analytics?${params}`),
"Failed to load user analytics",
);
}
export async function generateUserLeaderboardReport(
range: { since: string; until: string; days?: number },
limit = 30,
modelName?: string,
): Promise<AdminLeaderboardReportResponse> {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
if (modelName) params.set("model_name", modelName);
return parse(
await apiFetch(`${base()}/leaderboard/report?${params}`, {
method: "POST",
}),
"Failed to generate leaderboard report",
);
}
function parseSseFrame(frame: string): AdminLeaderboardReportStreamEvent | null {
let event = "message";
const dataLines: string[] = [];
for (const rawLine of frame.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line || line.startsWith(":")) continue;
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
if (!dataLines.length) return null;
const data = JSON.parse(dataLines.join("\n")) as AdminLeaderboardReportStreamEvent["data"];
return { event, data } as AdminLeaderboardReportStreamEvent;
}
export async function streamUserLeaderboardReport({
range,
limit = 30,
modelName,
signal,
onEvent,
}: {
range: { since: string; until: string; days?: number };
limit?: number;
modelName?: string;
signal?: AbortSignal;
onEvent: (event: AdminLeaderboardReportStreamEvent) => void;
}) {
const params = new URLSearchParams({
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
if (modelName) params.set("model_name", modelName);
const response = await apiFetch(`${base()}/leaderboard/report/stream?${params}`, {
method: "POST",
signal,
});
if (!response.ok) {
const err = (await response.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to stream leaderboard report (HTTP ${response.status})`);
}
if (!response.body) {
throw new Error("当前浏览器不支持流式报告读取");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() ?? "";
for (const frame of frames) {
const parsed = parseSseFrame(frame);
if (parsed) onEvent(parsed);
}
if (done) break;
}
const tail = buffer.trim();
if (tail) {
const parsed = parseSseFrame(tail);
if (parsed) onEvent(parsed);
}
}
export async function addAdminMemoryEntry(
userId: string,
content: string,
+49
View File
@@ -5,7 +5,10 @@ import {
addAdminV1Fact,
deleteAdminMemoryEntry,
deleteAdminV1Fact,
generateUserLeaderboardReport,
getUserMemory,
getUserAnalytics,
getUserLeaderboard,
listUserThreadMessages,
listUserThreads,
replaceAdminMemoryEntry,
@@ -13,6 +16,52 @@ import {
updateAdminV1Section,
} from "./api";
export function useUserLeaderboard(
range: {
since: string;
until: string;
days?: number;
},
limit = 20,
) {
return useQuery({
queryKey: [
"admin-activity",
"leaderboard",
range.since,
range.until,
limit,
],
queryFn: () => getUserLeaderboard(range, limit),
});
}
export function useUserAnalytics(
userId: string | null,
range: { since: string; until: string; days?: number },
limit = 20,
) {
return useQuery({
queryKey: ["admin-activity", "analytics", userId, range.since, range.until, limit],
queryFn: () => getUserAnalytics(userId!, range, limit),
enabled: !!userId,
});
}
export function useGenerateUserLeaderboardReport() {
return useMutation({
mutationFn: ({
range,
limit,
modelName,
}: {
range: { since: string; until: string; days?: number };
limit?: number;
modelName?: string;
}) => generateUserLeaderboardReport(range, limit ?? 30, modelName),
});
}
/** Threads owned by a given user. Disabled until a user is picked. */
export function useUserThreads(userId: string | null) {
return useQuery({
+122 -15
View File
@@ -2,11 +2,13 @@ import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type {
Agent,
AgentSelectorResponse,
CreateAgentRequest,
UpdateAgentRequest,
} from "./types";
Agent,
AgentSelectorResponse,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
@@ -23,13 +25,27 @@ export class AgentNameCheckError extends Error {
export interface ListAgentsParams {
search?: string;
tagIds?: string[];
}
/** Pagination scope. Omit for the legacy combined-visibility list. */
scope?: "mine" | "square";
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
page?: number;
pageSize?: number;
sort?: AgentSort;
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
function buildAgentsQuery(params: ListAgentsParams): string {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
const query = qs.toString();
if (params.scope) qs.set("scope", params.scope);
if (params.page != null) qs.set("page", String(params.page));
if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
if (params.sort) qs.set("sort", params.sort);
return qs.toString();
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
const query = buildAgentsQuery(params);
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
);
@@ -38,6 +54,29 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
return data.agents;
}
/** Like {@link listAgents} but returns the pagination envelope (total/page). */
export async function listAgentsPaginated(
params: ListAgentsParams = {},
): Promise<PaginatedAgents> {
const query = buildAgentsQuery(params);
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
const data = (await res.json()) as {
agents: Agent[];
total?: number;
page?: number;
page_size?: number;
};
return {
agents: data.agents,
total: data.total ?? data.agents.length,
page: data.page ?? params.page ?? 1,
pageSize: data.page_size ?? params.pageSize ?? data.agents.length,
};
}
export async function listAllAgentsAdmin(): Promise<Agent[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents?admin=true`);
if (!res.ok) {
@@ -90,13 +129,13 @@ export async function deleteAgent(id: string): Promise<void> {
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
}
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`);
if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`);
return res.json() as Promise<AgentSelectorResponse>;
}
export async function favoriteAgent(id: string): Promise<void> {
}
export async function favoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "POST",
});
@@ -106,15 +145,35 @@ export async function favoriteAgent(id: string): Promise<void> {
}
}
export async function unfavoriteAgent(id: string): Promise<void> {
export async function unfavoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "DELETE",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
}
}
}
}
export async function pinAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
method: "POST",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to pin agent: ${res.statusText}`);
}
}
export async function unpinAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
method: "DELETE",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unpin agent: ${res.statusText}`);
}
}
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
const res = await apiFetch(
@@ -152,6 +211,54 @@ export async function listPrivateAgents(password: string): Promise<Agent[]> {
return data.agents;
}
export interface ListPrivateAgentsParams {
password: string;
search?: string;
tagIds?: string[];
page?: number;
pageSize?: number;
sort?: AgentSort;
}
/** Paginated private-square listing (password-gated). */
export async function listPrivateAgentsPaginated(
params: ListPrivateAgentsParams,
): Promise<PaginatedAgents> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/private-square/list`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
password: params.password,
search: params.search || undefined,
tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined,
page: params.page,
page_size: params.pageSize,
sort: params.sort,
}),
},
);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(
err.detail ?? `Failed to load private agents: ${res.statusText}`,
);
}
const data = (await res.json()) as {
agents: Agent[];
total?: number;
page?: number;
page_size?: number;
};
return {
agents: data.agents,
total: data.total ?? data.agents.length,
page: data.page ?? params.page ?? 1,
pageSize: data.page_size ?? params.pageSize ?? data.agents.length,
};
}
export async function setAgentPrivate(
id: string,
isPrivate: boolean,
+165 -36
View File
@@ -3,25 +3,55 @@ import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
} from "@tanstack/react-query";
import {
adminSyncAgentSkills,
createAgent,
deleteAgent,
favoriteAgent,
getAgent,
getAgentSelector,
listAgents,
listAllAgentsAdmin,
listPrivateAgents,
reorderFeaturedAgents,
setAgentFeatured,
setAgentPrivate,
unfavoriteAgent,
updateAgent,
} from "./api";
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
listAgents,
listAgentsPaginated,
listAllAgentsAdmin,
listPrivateAgents,
listPrivateAgentsPaginated,
pinAgent,
reorderFeaturedAgents,
setAgentFeatured,
setAgentPrivate,
unfavoriteAgent,
unpinAgent,
updateAgent,
} from "./api";
import type {
Agent,
AgentSort,
CreateAgentRequest,
PaginatedAgents,
UpdateAgentRequest,
} from "./types";
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
type CachedAgents = Agent[] | PaginatedAgents | undefined;
function isPaginated(value: CachedAgents): value is PaginatedAgents {
return !!value && !Array.isArray(value) && Array.isArray(value.agents);
}
/** Apply ``map`` to every agent inside whatever listing shape is cached. */
function mapCachedAgents(
queryClient: ReturnType<typeof useQueryClient>,
map: (a: Agent) => Agent,
) {
queryClient.setQueriesData<CachedAgents>({ queryKey: ["agents"] }, (old) => {
if (Array.isArray(old)) return old.map(map);
if (isPaginated(old)) return { ...old, agents: old.agents.map(map) };
return old;
});
}
/** Patch an updated agent into every cached agent listing immediately so
* cards re-render before the background refetch finishes. Without this the
@@ -32,13 +62,7 @@ function applyAgentToCache(
queryClient: ReturnType<typeof useQueryClient>,
next: Agent,
) {
queryClient.setQueriesData<Agent[] | undefined>(
{ queryKey: ["agents"] },
(old) => {
if (!Array.isArray(old)) return old;
return old.map((a) => (a.id === next.id ? { ...a, ...next } : a));
},
);
mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
}
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
@@ -52,6 +76,45 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
return { agents: data ?? [], isLoading, error };
}
/** Server-side paginated agent listing for one gallery tab (scope). Returns
* the page of agents plus the total count for the "共 N 个" header. */
export function useAgentsPaginated(params: {
scope: "mine" | "square";
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
}) {
const { scope, page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const { data, isLoading, error } = useQuery({
queryKey: [
"agents",
"paginated",
scope,
search,
[...tagIds].sort(),
sort,
page,
pageSize,
],
queryFn: () =>
listAgentsPaginated({ scope, search, tagIds, sort, page, pageSize }),
enabled,
placeholderData: keepPreviousData,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
error,
};
}
/**
* Admin-only listing — returns every agent including unpublished agents owned
* by other users and private-square entries. Caller must be ``system_role
@@ -117,20 +180,14 @@ export function useDeleteAgent() {
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
* ``is_favorite`` across every cached agent listing + detail so the star
* reacts instantly before the round-trip completes. */
export function useToggleAgentFavorite() {
export function useToggleAgentFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
onMutate: ({ id, favorite }) => {
queryClient.setQueriesData<Agent[] | undefined>(
{ queryKey: ["agents"] },
(old) =>
Array.isArray(old)
? old.map((a) =>
a.id === id ? { ...a, is_favorite: favorite } : a,
)
: old,
mapCachedAgents(queryClient, (a) =>
a.id === id ? { ...a, is_favorite: favorite } : a,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_favorite: favorite } : old,
@@ -139,10 +196,30 @@ export function useToggleAgentFavorite() {
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useAgentSelector() {
});
}
export function useToggleAgentPin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) =>
pinned ? pinAgent(id) : unpinAgent(id),
onMutate: ({ id, pinned }) => {
mapCachedAgents(queryClient, (a) =>
a.id === id ? { ...a, is_pinned: pinned } : a,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_pinned: pinned } : old,
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["agents"] });
await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" });
},
});
}
export function useAgentSelector() {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
queryFn: () => getAgentSelector(),
@@ -153,9 +230,9 @@ export function useAgentSelector() {
isLoading,
error,
};
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
}
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
}
@@ -196,6 +273,58 @@ export function usePrivateAgents(password: string | null) {
return { agents: data ?? [], isLoading, error, refetch };
}
/** Paginated variant of {@link usePrivateAgents} — one page of the
* password-gated private square plus the total count. */
export function usePrivateAgentsPaginated(
password: string | null,
params: {
search?: string;
tagIds?: string[];
sort?: AgentSort;
page: number;
pageSize: number;
enabled?: boolean;
},
) {
const { page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const { data, isLoading, error, refetch } = useQuery({
queryKey: [
"agents",
"private-square",
password ?? "",
"paginated",
search,
[...tagIds].sort(),
sort,
page,
pageSize,
],
queryFn: () =>
listPrivateAgentsPaginated({
password: password!,
search,
tagIds,
sort,
page,
pageSize,
}),
enabled: !!password && enabled,
staleTime: 30_000,
retry: false,
placeholderData: keepPreviousData,
});
return {
agents: data?.agents ?? [],
total: data?.total ?? 0,
isLoading,
error,
refetch,
};
}
export function useSetAgentPrivate() {
const queryClient = useQueryClient();
return useMutation({
+15 -4
View File
@@ -1,6 +1,6 @@
import type { Tag } from "@/core/tags";
export interface Agent {
export interface Agent {
id: string;
name: string;
description: string;
@@ -17,11 +17,22 @@ export interface Agent {
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
is_favorite?: boolean;
}
is_favorite?: boolean;
is_pinned?: boolean;
}
export type AgentSort = "time_desc" | "time_asc" | "updated_desc" | "updated_asc" | "name_asc" | "name_desc";
export interface AgentSelectorResponse {
agents: Agent[];
}
export interface AgentSelectorResponse {
/** Pagination envelope returned by the server-side paginated agent listings. */
export interface PaginatedAgents {
agents: Agent[];
total: number;
page: number;
pageSize: number;
}
export interface CreateAgentRequest {
+37 -6
View File
@@ -1,4 +1,8 @@
import { getAuthorizationHeaderValue } from "@/core/auth";
import {
clearStoredAuth,
getAuthorizationHeaderValue,
getStoredAuth,
} from "@/core/auth";
import { withCsrfHeaders } from "./csrf";
function withAuthHeaders(headers?: HeadersInit): Headers {
@@ -8,13 +12,40 @@ function withAuthHeaders(headers?: HeadersInit): Headers {
next.set("Authorization", authHeader);
}
return next;
}
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
return fetch(input, {
}
function requestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return (input as Request).url ?? "";
}
export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
const response = await fetch(input, {
...init,
credentials: init?.credentials ?? "include",
headers: withAuthHeaders(init?.headers),
});
// Session is considered lost ONLY when the backend actually answers 401.
// (Previously a stray localStorage change could log the user out client-side
// even though the session was still valid.) Auth endpoints are excluded so a
// failed/transient login attempt never wipes state, and we only act when we
// believed we were logged in — avoiding redirect loops on the login page.
if (response.status === 401) {
const url = requestUrl(input);
const isAuthEndpoint = url.includes("/api/v1/auth/");
if (!isAuthEndpoint && getStoredAuth()) {
clearStoredAuth();
if (
typeof window !== "undefined" &&
!window.location.hash.startsWith("#/login")
) {
window.location.replace("#/login");
}
}
}
return response;
}
+2
View File
@@ -9,6 +9,8 @@ export interface UserListItem {
system_role: "admin" | "user";
skill_count: number;
agent_count: number;
/** 问答次数:用户提问的次数(一次提问算一次)。 */
qa_count: number;
}
export async function listAllUsers(): Promise<UserListItem[]> {
+7 -35
View File
@@ -6,13 +6,7 @@ import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
const AUTH_STORAGE_KEY = "deerflow.auth";
// 防抖窗口:跨标签页登录/重写 auth 常常在极短时间内连发多个 storage 事件
// (例如先写 token、再写用户信息)。等它"落定"再判断,避免抖动期的中间态
// (短暂 null / 半写状态)误触发动作。
const SETTLE_DELAY_MS = 1200;
// 提示 toast 的稳定 id —— 同一次身份变化只保留一条,避免连发事件刷屏。
const IDENTITY_TOAST_ID = "auth-identity-changed";
function readUserId(): string | null {
@@ -20,23 +14,8 @@ function readUserId(): string | null {
}
/**
* Mount once at the app root. Watches `localStorage["deerflow.auth"]` for
* cross-tab changes.
*
* 背景:`deerflow.auth` 在同源所有标签页之间共享。某个标签页登录/重写它时,
* 其它标签页仍持有旧用户的内存态(会话列表、thread id、以及多智能体会商这类
* 纯内存的页面状态),与新 token 不一致,可能在"新对话"等操作上 404。
*
* 旧实现:只要检测到 user_id 变化就 `window.location.reload()`。这会把**正在
* 使用的标签页**(例如正在跑多智能体会商第二步的那个)整页刷新,内存态全丢、
* 步骤回到第一步 —— 用户复制链接到新标签页打开(新标签页可能以不同/默认用户
* 重新登录并改写共享 auth)就会触发,体验很差。
*
* 现实现(方案 1,收敛粗暴刷新):
* - **真·退出登录**(auth 变为 null)→ 仍然跳登录页并刷新(本就该重新登录)。
* - **出现不同的非空用户**(另一个标签页登录成别的身份)→ **不再自动整页刷新**,
* 改为弹一条常驻提示,把"是否刷新同步"的决定权交给用户,避免冲掉正在进行的工作。
* - 防抖 + 落定后二次校验,过滤掉同用户重写 / 抖动中间态带来的误触发。
* Mount once at the app root. Watches localStorage auth changes from other
* tabs and lets the user decide when to refresh on account switches.
*/
export function AuthSyncWatcher() {
const lastUserIdRef = useRef<string | null>(readUserId());
@@ -44,26 +23,18 @@ export function AuthSyncWatcher() {
useEffect(() => {
let settleTimer: ReturnType<typeof setTimeout> | null = null;
// storage 事件落定后再判断当前的真实身份并决定动作。
function evaluateSettledIdentity() {
const nextUserId = readUserId();
if (nextUserId === lastUserIdRef.current) {
// 同用户重写,或抖动后又回到原身份 —— 无需任何动作。
return;
}
lastUserIdRef.current = nextUserId;
if (nextUserId === null) {
// 另一个标签页退出登录(或清空了 auth)→ 本标签页也该回到登录页。
lastUserIdRef.current = null;
window.location.replace("#/login");
window.location.reload();
return;
}
// 出现了不同的非空用户。共享 auth 已被改成别的身份,继续在本标签页发请求
// 可能 404 / 鉴权失败,但**不**强制刷新 —— 用户可能正在本标签页干活
// (如多智能体会商),由用户自己决定何时刷新同步。
lastUserIdRef.current = nextUserId;
toast.warning("检测到账号在其它标签页发生变化", {
id: IDENTITY_TOAST_ID,
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
@@ -76,8 +47,9 @@ export function AuthSyncWatcher() {
}
function handleStorage(event: StorageEvent) {
// 只关心 auth 键的变化;event.key === null 表示 localStorage 被整体清空。
if (event.key !== null && event.key !== AUTH_STORAGE_KEY) {
// Ignore unrelated keys and localStorage.clear(). Backend session loss is
// handled centrally through 401 responses.
if (event.key !== AUTH_STORAGE_KEY) {
return;
}
if (settleTimer) {
@@ -9,6 +9,12 @@ export interface CustomPrompt {
title: string;
content: string;
sort_order?: number;
/**
* Optional association to a single agent. ``null``/absent = global (shown for
* every agent); otherwise the prompt only surfaces when that agent is
* selected.
*/
agent_id?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
@@ -27,6 +33,7 @@ async function listUserPrompts(): Promise<CustomPrompt[]> {
async function createUserPrompt(input: {
title: string;
content: string;
agent_id?: string | null;
}): Promise<CustomPrompt> {
const res = await apiFetch(baseUrl(), {
method: "POST",
@@ -42,7 +49,7 @@ async function createUserPrompt(input: {
async function updateUserPrompt(
id: string,
input: { title?: string; content?: string },
input: { title?: string; content?: string; agent_id?: string | null },
): Promise<CustomPrompt> {
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
method: "PUT",
@@ -93,7 +100,7 @@ export function useUpdateCustomPrompt() {
input,
}: {
id: string;
input: { title?: string; content?: string };
input: { title?: string; content?: string; agent_id?: string | null };
}) => updateUserPrompt(id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
@@ -82,6 +82,10 @@ export const enUS: Translations = {
inputBox: {
placeholder: "How can I assist you today?",
editQuestion: "Edit question",
deleteMessage: "Delete question and answer",
deleteMessageConfirmTitle: "Delete this question and its answer?",
deleteMessageConfirmDescription:
"The question and its answer will both be removed. This cannot be undone.",
createSkillPrompt:
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
addAttachments: "Add attachments",
@@ -472,6 +476,8 @@ export const enUS: Translations = {
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
badgePinned: "Pinned",
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
badgeFeatured: "Top",
badgeFeaturedTitle: "Pinned to the top of the list",
badgeSourceAgent: "Agent-created",
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
badgeSourceUpload: "Uploaded",
@@ -490,6 +496,10 @@ export const enUS: Translations = {
unpinAction: "Unpin",
pinSuccess: "Pinned",
unpinSuccess: "Unpinned",
featureAction: "Pin to top",
unfeatureAction: "Unpin from top",
featureSuccess: "Pinned to top",
unfeatureSuccess: "Unpinned from top",
deleteAction: "Delete skill",
deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
@@ -79,6 +79,9 @@ export interface Translations {
inputBox: {
placeholder: string;
editQuestion: string;
deleteMessage: string;
deleteMessageConfirmTitle: string;
deleteMessageConfirmDescription: string;
createSkillPrompt: string;
addAttachments: string;
mode: string;
@@ -406,6 +409,8 @@ export interface Translations {
badgeArchivedTitle: string;
badgePinned: string;
badgePinnedTitle: string;
badgeFeatured: string;
badgeFeaturedTitle: string;
badgeSourceAgent: string;
badgeSourceAgentTitle: string;
badgeSourceUpload: string;
@@ -424,6 +429,10 @@ export interface Translations {
unpinAction: string;
pinSuccess: string;
unpinSuccess: string;
featureAction: string;
unfeatureAction: string;
featureSuccess: string;
unfeatureSuccess: string;
deleteAction: string;
deleteConfirm: string;
deleteSuccess: string;
@@ -82,6 +82,9 @@ export const zhCN: Translations = {
inputBox: {
placeholder: "今天我能为你做些什么?",
editQuestion: "编辑问题",
deleteMessage: "删除提问及回答",
deleteMessageConfirmTitle: "删除该提问及其回答?",
deleteMessageConfirmDescription: "该提问及对应的回答都会被删除,且无法恢复。",
createSkillPrompt:
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
addAttachments: "添加附件",
@@ -453,6 +456,8 @@ export const zhCN: Translations = {
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
badgePinned: "钉选",
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
badgeFeatured: "置顶",
badgeFeaturedTitle: "已置顶,固定显示在列表顶部",
badgeSourceAgent: "Agent 创建",
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
badgeSourceUpload: "用户上传",
@@ -471,6 +476,10 @@ export const zhCN: Translations = {
unpinAction: "取消钉选",
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
featureAction: "置顶",
unfeatureAction: "取消置顶",
featureSuccess: "已置顶",
unfeatureSuccess: "已取消置顶",
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除",
@@ -35,6 +35,7 @@ import {
ShieldCheck,
Sparkles,
Target,
Trophy,
Users,
} from "lucide-react";
@@ -243,6 +244,13 @@ 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: "用户管理",
@@ -26,6 +26,14 @@ export type HtmlPageConfig = {
export type ScheduledTaskExecutionContext = {
require_markdown?: boolean;
/**
* The unified "必须生成 HTML" switch. The task form no longer splits "普通任务 /
* HTML 页面" into separate kinds — instead ``require_html`` and
* ``require_markdown`` are two independent toggles that may both be on. Legacy
* tasks created under the old two-tab UI still carry ``task_kind`` and are
* treated as HTML tasks by the backend for backward compatibility.
*/
require_html?: boolean;
task_kind?: ScheduledTaskKind;
html_config?: HtmlPageConfig;
[key: string]: unknown;
+7
View File
@@ -55,6 +55,13 @@ export interface LocalSettings {
* app sizes text/spacing in rem, the whole UI scales proportionally.
*/
fontScale: number;
/**
* Per-user override for the skill / agent list display mode (card vs list).
* `undefined` = follow the admin-configured system default. Set when the
* user flips the card/list toggle on the skills or agents page.
*/
skillViewMode?: "card" | "list";
agentViewMode?: "card" | "list";
};
context: Omit<
AgentThreadContext,
+20
View File
@@ -6,12 +6,14 @@ import type { Skill, SkillDisplayConfig, SkillState } from "./type";
export interface ListSkillsParams {
search?: string;
tagIds?: string[];
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
}
export async function loadSkills(params: ListSkillsParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.sort) qs.set("sort", params.sort);
const query = qs.toString();
const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
@@ -296,6 +298,24 @@ export async function setSkillPinned(
return response.json() as Promise<Skill>;
}
export async function setSkillFeatured(
skillName: string,
featured: boolean,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/featured`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ featured }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill featured state");
}
return response.json() as Promise<Skill>;
}
export interface SkillContentResponse {
name: string;
category: string;
+25 -5
View File
@@ -19,23 +19,32 @@ import {
setSkillDetail,
setSkillDisplay,
setSkillPinned,
setSkillFeatured,
setSkillPublished,
setSkillState,
validateSkillUpload,
} from "./api";
import type { ListSkillsParams } from "./api";
import type { Skill, SkillState } from "./type";
import { loadAllSkillsAdmin, loadSkills } from ".";
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
// Stable empty fallback. Returning `data ?? []` inline would hand out a fresh
// array reference on every render while the query is loading/errored, which
// makes downstream `useMemo([skills])` / `useEffect([skills])` consumers churn
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: ListSkillsParams = {}) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort()],
queryFn: () => loadSkills({ search, tagIds }),
queryKey: ["skills", search, [...tagIds].sort(), sort],
queryFn: () => loadSkills({ search, tagIds, sort }),
placeholderData: keepPreviousData,
});
return { skills: data ?? [], isLoading, error };
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
}
/**
@@ -50,7 +59,7 @@ export function useAllSkillsAdmin(enabled: boolean) {
enabled,
staleTime: 30_000,
});
return { skills: data ?? [], isLoading, error };
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
}
export function useSkillContent(skillName: string | null) {
@@ -193,6 +202,17 @@ export function useSetSkillPinned() {
});
}
export function useSetSkillFeatured() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, featured }: { skillName: string; featured: boolean }) =>
setSkillFeatured(skillName, featured),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useDuplicateSkill() {
const queryClient = useQueryClient();
return useMutation({
+3
View File
@@ -60,9 +60,12 @@ export interface Skill {
// so stale / archived skills appear in dedicated drawers instead.
state?: SkillState;
pinned?: boolean;
// 置顶 —— 仅影响列表排序,与 pinned(防自动归档)正交。
featured?: boolean;
// Provenance — distinguishes skills created by an agent during a chat
// (``agent``) from skills the user uploaded / authored manually (``upload``).
source?: SkillSource;
created_at?: string | null;
last_activity_at?: string | null;
use_count?: number;
view_count?: number;
@@ -61,6 +61,22 @@ export interface CitationDisplayUpdate {
cleanup_words?: string[];
}
export interface LeaderboardSettings {
exclude_user_ids: string[];
include_scheduled: boolean;
include_admins: boolean;
include_failed: boolean;
cleanup_system_questions: boolean;
}
export interface LeaderboardSettingsUpdate {
exclude_user_ids?: string[];
include_scheduled?: boolean;
include_admins?: boolean;
include_failed?: boolean;
cleanup_system_questions?: boolean;
}
export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/private-square`,
@@ -74,13 +90,18 @@ export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
}
export type ThemeMode = "light" | "dark";
export type ViewMode = "card" | "list";
export interface AppearanceSettings {
default_theme: ThemeMode;
default_skill_view: ViewMode;
default_agent_view: ViewMode;
}
export interface AppearanceUpdate {
default_theme?: ThemeMode;
default_skill_view?: ViewMode;
default_agent_view?: ViewMode;
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
@@ -127,6 +148,36 @@ export async function loadCitationDisplaySettings(): Promise<CitationDisplaySett
return (await response.json()) as CitationDisplaySettings;
}
export async function loadLeaderboardSettings(): Promise<LeaderboardSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/leaderboard`,
);
if (!response.ok) {
throw new Error(`Failed to load leaderboard settings: ${response.status}`);
}
return (await response.json()) as LeaderboardSettings;
}
export async function updateLeaderboardSettings(
body: LeaderboardSettingsUpdate,
): Promise<LeaderboardSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/leaderboard`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update leaderboard settings" }));
throw new Error(detail.detail ?? "Failed to update leaderboard settings");
}
return (await response.json()) as LeaderboardSettings;
}
export async function updateCitationDisplaySettings(
body: CitationDisplayUpdate,
): Promise<CitationDisplaySettings> {
@@ -147,6 +198,22 @@ export async function updateCitationDisplaySettings(
return (await response.json()) as CitationDisplaySettings;
}
export interface LogSystemSettings {
enabled: boolean;
url: string;
label: string;
}
export async function loadLogSystemSettings(): Promise<LogSystemSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/log-system`,
);
if (!response.ok) {
throw new Error(`Failed to load log system settings: ${response.status}`);
}
return (await response.json()) as LogSystemSettings;
}
export async function updatePrivateSquareSettings(
body: PrivateSquareUpdate,
): Promise<PrivateSquareStatus> {
@@ -1,24 +1,31 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSettings } from "@/core/settings";
import {
loadAppearanceSettings,
loadCitationDisplaySettings,
loadLeaderboardSettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings,
updateAppearanceSettings,
updateCitationDisplaySettings,
updateLeaderboardSettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
type AppearanceUpdate,
type CitationDisplayUpdate,
type LeaderboardSettingsUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
type ViewMode,
} from "./api";
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
export function usePromptPrefixSettings() {
return useQuery({
@@ -82,6 +89,31 @@ export function useUpdateAppearanceSettings() {
});
}
/**
* Effective card/list display mode for the skill or agent list. A user's own
* choice (persisted in local settings) wins; otherwise the admin-configured
* system default applies; otherwise a built-in fallback (skills → list,
* agents → card, matching the historical layout of each page). `setMode`
* persists the user's override locally.
*/
export function useViewMode(
kind: "skill" | "agent",
): [ViewMode, (mode: ViewMode) => void] {
const [settings, setSettings] = useLocalSettings();
const { data: appearance } = useAppearanceSettings();
const overrideKey = kind === "skill" ? "skillViewMode" : "agentViewMode";
const userOverride = settings.display[overrideKey];
const adminDefault =
kind === "skill"
? appearance?.default_skill_view
: appearance?.default_agent_view;
const fallback: ViewMode = kind === "skill" ? "list" : "card";
const mode: ViewMode = userOverride ?? adminDefault ?? fallback;
const setMode = (next: ViewMode) =>
setSettings("display", { [overrideKey]: next });
return [mode, setMode];
}
export function useCitationDisplaySettings() {
return useQuery({
queryKey: CITATION_DISPLAY_KEY,
@@ -102,3 +134,25 @@ export function useUpdateCitationDisplaySettings() {
},
});
}
export function useLeaderboardSettings() {
return useQuery({
queryKey: LEADERBOARD_KEY,
queryFn: () => loadLeaderboardSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateLeaderboardSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: LeaderboardSettingsUpdate) =>
updateLeaderboardSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(LEADERBOARD_KEY, data);
queryClient.invalidateQueries({ queryKey: ["admin-activity", "leaderboard"] });
},
});
}
+49 -6
View File
@@ -350,12 +350,12 @@ export function useThreadStream({
// agent thread URL — otherwise fall back to the global new chat.
const hash = typeof window !== "undefined" ? window.location.hash : "";
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
const target = agentMatch
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
: iframeMatch
? "/page/workspace/chats_iframe/new"
: "/page/workspace/chats/new";
const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
const target = agentMatch
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
: iframeMatch
? "/page/workspace/chats_iframe/new"
: "/page/workspace/chats/new";
// First navigate to a safe URL so the post-reload page lands on a
// valid route. Then full-reload so TanStack Query cache, context
// providers and any other state captured under the previous user's
@@ -810,6 +810,49 @@ export function useDeleteThread() {
});
}
/**
* Delete a conversation turn (a user question together with its answer) from a
* thread. Hits the Gateway endpoint that removes the question and every
* following message up to the next question from the latest checkpoint, and
* returns the ids that were actually removed. The live `useStream` cache is not
* auto-refreshed by this call — callers hide the returned ids locally (see
* `MessageList`) so the removal shows immediately; a reload then reads the
* persisted, already-trimmed state.
*/
export function useDeleteMessage() {
return useMutation({
mutationFn: async ({
threadId,
messageId,
}: {
threadId: string;
messageId: string;
}): Promise<string[]> => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/messages/${encodeURIComponent(messageId)}`,
{
method: "DELETE",
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "删除消息失败。" }));
throw new Error(error.detail ?? "删除消息失败。");
}
const body = (await response.json().catch(() => ({}))) as {
deleted_ids?: string[];
};
// Fall back to the requested id so the caller can always hide something.
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
},
});
}
export function useRenameThread() {
const queryClient = useQueryClient();
const apiClient = getAPIClient();
@@ -0,0 +1,85 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
/** Per-user UI preferences, bound to the account (stored server-side). */
export interface UserPreferences {
/**
* When true, the chat input hides the admin-configured prompt-prefix switch
* and never applies the prefix — the user uses only their own prompts.
*/
hide_admin_prompt_prefix: boolean;
}
const QUERY_KEY = ["user-preferences"] as const;
const DEFAULT_PREFS: UserPreferences = { hide_admin_prompt_prefix: false };
const baseUrl = () => `${getBackendBaseURL()}/api/user-preferences`;
async function getUserPreferences(): Promise<UserPreferences> {
const res = await apiFetch(baseUrl());
if (!res.ok) throw new Error(`Failed to load preferences: ${res.statusText}`);
const data = (await res.json()) as Partial<UserPreferences>;
return { hide_admin_prompt_prefix: !!data.hide_admin_prompt_prefix };
}
async function updateUserPreferences(
input: Partial<UserPreferences>,
): Promise<UserPreferences> {
const res = await apiFetch(baseUrl(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to update preferences: ${res.statusText}`);
}
const data = (await res.json()) as Partial<UserPreferences>;
return { hide_admin_prompt_prefix: !!data.hide_admin_prompt_prefix };
}
/** Reactive current-user preferences (server-backed, follows the account). */
export function useUserPreferences(): UserPreferences {
const { data } = useQuery({
queryKey: QUERY_KEY,
queryFn: getUserPreferences,
});
return data ?? DEFAULT_PREFS;
}
export function useUserPreferencesQuery() {
return useQuery({
queryKey: QUERY_KEY,
queryFn: getUserPreferences,
});
}
export function useUpdateUserPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUserPreferences,
// Optimistically flip the cached preference so the switch — and every
// surface that reacts to it (e.g. the chat input's admin prompt-prefix
// toggle) — updates instantly instead of waiting for the round-trip,
// which made the toggle look like it "didn't take effect / didn't refresh".
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: QUERY_KEY });
const previous = queryClient.getQueryData<UserPreferences>(QUERY_KEY);
queryClient.setQueryData<UserPreferences>(QUERY_KEY, (old) => ({
...(old ?? DEFAULT_PREFS),
...input,
}));
return { previous };
},
onError: (_err, _input, context) => {
// Roll back to the pre-toggle value if the server rejected the change.
if (context?.previous) {
queryClient.setQueryData(QUERY_KEY, context.previous);
}
},
onSuccess: (data) => {
queryClient.setQueryData(QUERY_KEY, data);
},
});
}
+196 -10
View File
@@ -1,10 +1,196 @@
export function extractTitleFromMarkdown(markdown: string) {
if (markdown.startsWith("# ")) {
let title = markdown.split("\n")[0]!.trim();
if (title.startsWith("# ")) {
title = title.slice(2).trim();
}
return title;
}
return undefined;
}
export function extractTitleFromMarkdown(markdown: string) {
if (markdown.startsWith("# ")) {
let title = markdown.split("\n")[0]!.trim();
if (title.startsWith("# ")) {
title = title.slice(2).trim();
}
return title;
}
return undefined;
}
export type MarkdownHeadingAnchor = {
level: number;
text: string;
id: string;
};
export type MarkdownAnchorResolver = {
headings: MarkdownHeadingAnchor[];
getHeadingId: (level: number, text: string) => string | undefined;
resolveHref: (href: string | undefined, label?: string) => string | null | undefined;
};
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
const PROTOCOL_RE = /^[a-z][a-z\d+.-]*:/i;
function stripInlineMarkdown(value: string): string {
return value
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[`*_~]/g, "")
.replace(/<[^>]+>/g, "")
.trim();
}
export function markdownSlug(value: string): string {
const cleaned = stripInlineMarkdown(value)
.normalize("NFKC")
.toLowerCase()
.replace(/[^\p{L}\p{N}\s._-]+/gu, "")
.trim()
.replace(/[\s._-]+/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned || "section";
}
function decodeLinkPart(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/** True for hrefs that point to an anchor within the current document (`#section`). */
export function isInPageAnchor(href: string | undefined | null): href is string {
return !!href && href.startsWith("#") && !href.startsWith("#reference-");
}
function escapeAttrValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* Scrolls to an in-page anchor by id without mutating `window.location.hash`.
*
* The app runs under a HashRouter, so letting an `<a href="#...">` click change
* the hash would be interpreted as a route change and 404. We resolve the target
* element ourselves and scroll to it instead. Scoped to the nearest
* `[data-markdown-anchor-root]` so headings in other messages don't collide.
*/
export function scrollToInPageAnchor(anchor: HTMLElement, href: string | undefined | null): void {
if (!isInPageAnchor(href)) return;
const id = decodeLinkPart(href.slice(1)).trim();
if (!id) return;
const scope = anchor.closest<HTMLElement>("[data-markdown-anchor-root]");
const selector = `[id="${escapeAttrValue(id)}"]`;
const target =
(scope?.querySelector<HTMLElement>(selector) ?? null) ??
document.getElementById(id) ??
document.querySelector<HTMLElement>(selector);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function normalizeLookupKey(value: string): string {
return markdownSlug(decodeLinkPart(value).replace(/^#+/, ""));
}
function isExternalOrSpecialHref(href: string): boolean {
if (href.startsWith("//")) return true;
if (PROTOCOL_RE.test(href)) return true;
return href.startsWith("/") || href.startsWith("?");
}
function candidateTextFromHref(href: string): string {
const withoutQuery = href.split(/[?#]/)[0] ?? href;
const lastSegment = withoutQuery.split("/").filter(Boolean).pop() ?? withoutQuery;
return decodeLinkPart(lastSegment).replace(/\.(md|markdown)$/i, "");
}
/**
* Injects a small runtime script into generated HTML so that clicking an
* in-page anchor (`<a href="#section">`) scrolls within the iframe instead of
* navigating it. The preview iframe loads from a sandboxed `blob:` URL, where a
* fragment navigation blanks the frame; intercepting the click avoids that.
*/
export function injectInPageAnchorHandler(html: string): string {
const script = `<script>(function(){
function decode(v){try{return decodeURIComponent(v);}catch(e){return v;}}
function bySelector(id){
if(!document.querySelector)return null;
var esc=window.CSS&&CSS.escape?CSS.escape(id):id.replace(/["\\\\]/g,"\\\\$&");
try{return document.querySelector("#"+esc+",[name=\\""+esc+"\\"]");}catch(e){return null;}
}
document.addEventListener("click",function(e){
var el=e.target;
while(el&&el.tagName!=="A"){el=el.parentElement;}
if(!el)return;
var href=el.getAttribute("href");
if(!href||href.charAt(0)!=="#"||href.length<2)return;
e.preventDefault();
var id=decode(href.slice(1));
var raw=href.slice(1);
var target=
document.getElementById(id)||
document.getElementById(raw)||
document.getElementsByName(id)[0]||
document.getElementsByName(raw)[0]||
bySelector(id)||
bySelector(raw);
if(target){target.scrollIntoView({behavior:"smooth",block:"start"});}
},true);
})();</script>`;
if (/<\/body>/i.test(html)) {
return html.replace(/<\/body>/i, `${script}</body>`);
}
return html + script;
}
export function createMarkdownAnchorResolver(markdown: string): MarkdownAnchorResolver {
const headings: MarkdownHeadingAnchor[] = [];
const idsByLookup = new Map<string, string>();
const seen = new Map<string, number>();
let inFence = false;
for (const rawLine of markdown.split(/\r?\n/)) {
const line = rawLine.trim();
if (/^(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = HEADING_RE.exec(rawLine);
if (!match) continue;
const level = match[1]!.length;
const text = stripInlineMarkdown(match[2]!);
const baseId = markdownSlug(text);
const count = seen.get(baseId) ?? 0;
seen.set(baseId, count + 1);
const id = count === 0 ? baseId : `${baseId}-${count}`;
headings.push({ level, text, id });
idsByLookup.set(normalizeLookupKey(text), id);
idsByLookup.set(normalizeLookupKey(id), id);
}
return {
headings,
getHeadingId: (level: number, text: string) => {
void level;
return idsByLookup.get(normalizeLookupKey(text));
},
resolveHref: (href: string | undefined, label?: string) => {
if (!href) return href;
const trimmed = href.trim();
if (!trimmed || isExternalOrSpecialHref(trimmed)) return href;
const hash = trimmed.includes("#") ? trimmed.slice(trimmed.indexOf("#") + 1) : "";
const candidates = [
hash,
trimmed.startsWith("#") ? trimmed.slice(1) : "",
candidateTextFromHref(trimmed),
label ?? "",
].filter(Boolean);
for (const candidate of candidates) {
const id = idsByLookup.get(normalizeLookupKey(candidate));
if (id) return `#${id}`;
}
return trimmed.startsWith("#") ? null : null;
},
};
}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Download,
Edit3,
@@ -71,6 +72,7 @@ const emptyForm = {
};
export default function AdminScheduledTasksPage() {
const queryClient = useQueryClient();
const { theme } = useTheme();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -178,8 +180,15 @@ export default function AdminScheduledTasksPage() {
};
const triggerTask = async (task: ScheduledTask) => {
setSelectedTaskId(task.task_id);
try {
await actions.trigger.mutateAsync(task.task_id);
await queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "admin", task.task_id, "runs"],
});
await queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "admin", "list"],
});
toast.success("已触发执行");
} catch (error) {
toast.error(error instanceof Error ? error.message : "触发失败");
@@ -208,23 +217,24 @@ export default function AdminScheduledTasksPage() {
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="grid size-full min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[460px_minmax(0,1fr)]">
<Card className="flex min-h-0 flex-col">
<div className="grid size-full min-h-0 grid-cols-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 p-4 xl:grid-cols-[460px_minmax(0,1fr)] xl:grid-rows-1">
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="gap-3">
<div>
<div className="min-w-0">
<CardTitle>定时任务管理</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
管理员视图:查看、编辑、删除全部用户的定时任务
</p>
</div>
<form
className="flex gap-2"
className="flex min-w-0 gap-2"
onSubmit={(event) => {
event.preventDefault();
setSearch(searchInput.trim());
}}
>
<Input
className="min-w-0"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="按任务名称搜索"
@@ -268,7 +278,7 @@ export default function AdminScheduledTasksPage() {
onClick={() => setSelectedTaskId(task.task_id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{task.name}
</div>
@@ -348,10 +358,10 @@ export default function AdminScheduledTasksPage() {
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="gap-2">
<CardTitle>执行记录</CardTitle>
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground min-w-0 truncate text-sm">
{selectedTask ? selectedTask.name : "请选择左侧任务"}
</p>
</CardHeader>
@@ -379,11 +389,11 @@ export default function AdminScheduledTasksPage() {
className="rounded-md border p-4 transition-colors hover:bg-accent/40"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{run.result?.title || formatDate(run.scheduled_for)}
</div>
<div className="text-muted-foreground mt-1 text-xs">
<div className="text-muted-foreground mt-1 break-words text-xs">
计划:{formatDate(run.scheduled_for)} · 开始:
{formatDate(run.started_at)}
</div>
@@ -1,7 +1,8 @@
"use client";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -109,11 +110,13 @@ function MemoryBucketView({ bucket }: { bucket: AdminMemoryBucket }) {
}
export default function AdminUserActivityPage() {
const [searchParams] = useSearchParams();
const { data: users, isLoading: usersLoading } = useAllUsers(true);
const [userId, setUserId] = useState<string | null>(null);
const [threadId, setThreadId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
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(
@@ -122,6 +125,14 @@ export default function AdminUserActivityPage() {
);
const { data: memory, isLoading: memoryLoading } = useUserMemory(userId);
useEffect(() => {
if (targetUserId) {
setUserId(targetUserId);
setThreadId(null);
setTab("qa");
}
}, [targetUserId]);
const filteredUsers = useMemo(() => {
const needle = userQuery.trim().toLowerCase();
const list = users ?? [];
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,204 @@
"use client";
import { ArrowLeft, Save, Search, ShieldOff, SlidersHorizontal, X } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import type { UserListItem } from "@/core/auth/api";
import { useAllUsers } from "@/core/auth/hooks";
import {
useLeaderboardSettings,
useUpdateLeaderboardSettings,
} from "@/core/system-settings/hooks";
function userLabel(user?: UserListItem) {
return user?.email || user?.id || "";
}
export default function AdminUserLeaderboardSettingsPage() {
const navigate = useNavigate();
const { data: users = [] } = useAllUsers(true);
const { data: settings, isLoading } = useLeaderboardSettings();
const updateSettings = useUpdateLeaderboardSettings();
const [query, setQuery] = useState("");
const excludedIds = settings?.exclude_user_ids ?? [];
const selectedUsers = useMemo(
() => excludedIds.map((id) => users.find((user) => user.id === id) ?? ({ id, email: id, system_role: "user" } as UserListItem)),
[excludedIds, users],
);
const candidates = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return [];
return users
.filter((user) => !excludedIds.includes(user.id))
.filter((user) => `${user.email} ${user.id}`.toLowerCase().includes(needle))
.slice(0, 8);
}, [excludedIds, query, users]);
const patch = async (body: Record<string, unknown>) => {
try {
await updateSettings.mutateAsync(body);
toast.success("排行榜设置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const addUser = (userId: string) => {
void patch({ exclude_user_ids: [...excludedIds, userId] });
setQuery("");
};
const removeUser = (userId: string) => {
void patch({ exclude_user_ids: excludedIds.filter((id) => id !== userId) });
};
return (
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A]">
<SlidersHorizontal className="size-4" />
管理员设置
</div>
<h1 className="text-2xl font-semibold">排行榜设置</h1>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
这里的规则会影响用户排行榜、趋势、技能、模型、问题与个人详情统计。
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate("/page/strategy/admin/user-leaderboard")}>
<ArrowLeft className="mr-1 size-4" />
返回排行榜
</Button>
</div>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto max-w-6xl space-y-5 p-6">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold">
<ShieldOff className="size-4 text-[#C1121F]" />
统计排除名单
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。
</p>
</div>
{excludedIds.length > 0 && (
<Button size="sm" variant="outline" onClick={() => void patch({ exclude_user_ids: [] })}>
清空名单
</Button>
)}
</div>
<div className="grid gap-4 lg:grid-cols-[340px_minmax(0,1fr)]">
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#A87575]" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索账号邮箱或 ID"
className="h-10 border-[#F1D6D6] bg-white pl-9"
/>
{query.trim() && candidates.length > 0 && (
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-lg dark:border-[#462123] dark:bg-[#230D0F]">
<ScrollArea className="max-h-72">
{candidates.map((user) => (
<button
key={user.id}
type="button"
onClick={() => addUser(user.id)}
className="flex w-full items-center justify-between gap-3 border-b border-[#F8EAEA] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#FFF1F1] dark:border-[#462123] dark:hover:bg-[#361315]"
>
<span className="min-w-0">
<span className="block truncate font-medium">{user.email}</span>
<span className="block truncate text-xs text-[#8B6B6B]">{user.id}</span>
</span>
<Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge>
</button>
))}
</ScrollArea>
</div>
)}
</div>
<div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#19090A]">
{selectedUsers.length === 0 ? (
<span className="text-sm text-[#8B6B6B]">暂无排除账号</span>
) : (
<div className="flex flex-wrap gap-2">
{selectedUsers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-[280px] items-center gap-1.5 rounded-full border border-[#F1D6D6] bg-white px-3 py-1.5 text-xs text-[#7A1F25] shadow-sm dark:border-[#462123] dark:bg-[#230D0F] dark:text-[#F2B8BB]"
title={userLabel(user)}
>
<span className="truncate">{userLabel(user)}</span>
<button type="button" onClick={() => removeUser(user.id)} aria-label={`移除 ${userLabel(user)}`}>
<X className="size-3.5" />
</button>
</span>
))}
</div>
)}
</div>
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold">
<Save className="size-4 text-[#C1121F]" />
统计规则
</div>
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。
</p>
</div>
{isLoading && <Badge variant="outline">加载中</Badge>}
</div>
<div className="grid gap-3 md:grid-cols-2">
{[
["include_scheduled", "统计定时任务", "关闭后,定时任务产生的运行、问答、Token、模型调用、最新问题都不统计。"],
["include_admins", "统计管理员账号", "关闭后,管理员账号不参与排行和整体指标。"],
["include_failed", "统计失败运行", "关闭后,失败运行和失败模型调用不进入榜单。"],
["cleanup_system_questions", "隐藏系统标签问题", "开启后,<uploaded_files> 等附件/系统标签不会出现在问题列表。"],
].map(([key, title, desc]) => (
<label
key={key}
className="flex min-h-28 cursor-pointer items-start justify-between gap-4 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]"
>
<span>
<span className="block font-medium">{title}</span>
<span className="mt-1 block text-sm leading-6 text-[#7A5A5A] dark:text-[#D8B8B8]">{desc}</span>
</span>
<Switch
checked={Boolean(settings?.[key as keyof typeof settings])}
disabled={!settings || updateSettings.isPending}
onCheckedChange={(checked) => void patch({ [key]: checked })}
aria-label={title}
/>
</label>
))}
</div>
</section>
</div>
</ScrollArea>
</main>
</div>
);
}
+1
View File
@@ -250,6 +250,7 @@ export default function AgentChatPage() {
className="w-full"
isNewThread={isLandingState}
threadId={threadId}
assistantId={agent_id}
autoFocus={isLandingState}
status={
thread.error
+21 -1
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import { useUserPreferencesQuery } from "@/core/user-preferences";
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
import {
ChatBox,
@@ -50,6 +51,11 @@ export default function ChatPage() {
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
// Personal opt-out: when set, hide the admin prompt-prefix switch entirely and
// never apply the prefix — the user uses only their own prompts.
const { data: userPreferences, isLoading: preferencesLoading } =
useUserPreferencesQuery();
const hideAdminPrompt = userPreferences?.hide_admin_prompt_prefix === true;
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } =
@@ -159,6 +165,8 @@ export default function ChatPage() {
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
!preferencesLoading &&
!hideAdminPrompt &&
promptPrefixSettings?.enabled === true;
setHasStartedConversation(true);
window.dispatchEvent(
@@ -189,6 +197,8 @@ export default function ChatPage() {
hasStartedConversation,
promptPrefixOn,
promptPrefixSettings,
hideAdminPrompt,
preferencesLoading,
selectedAgentId,
router,
settings.context.writing_mode,
@@ -217,6 +227,9 @@ export default function ChatPage() {
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
if (preferencesLoading) return undefined;
// Personal opt-out hides the admin prompt-prefix switch entirely.
if (hideAdminPrompt) return undefined;
// The prefix toggle is offered only on the opening turn of a new thread.
if (!isNewThread || hasStartedConversation) return undefined;
return {
@@ -224,7 +237,14 @@ export default function ChatPage() {
on: promptPrefixOn,
onToggle: () => setPromptPrefixOn((v) => !v),
};
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
}, [
promptPrefixSettings,
hideAdminPrompt,
preferencesLoading,
isNewThread,
hasStartedConversation,
promptPrefixOn,
]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
@@ -0,0 +1,257 @@
"use client";
import { DatabaseZap, Loader2Icon, RefreshCwIcon } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
interface CheckpointMigrationSources {
source_paths: string[];
}
interface CheckpointMigrationTask {
task_id: string;
status: "queued" | "running" | "completed" | "failed";
start_at: string;
end_at: string;
source_paths: string[];
total_threads: number;
copied_threads: number;
skipped_threads: number;
failed_threads: number;
skipped_ranges: number;
message: string;
error?: string | null;
created_at: string;
updated_at: string;
}
async function loadSources(): Promise<CheckpointMigrationSources> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/sources`,
);
if (!response.ok) throw new Error("加载 SQLite 来源失败");
return (await response.json()) as CheckpointMigrationSources;
}
async function startMigration(body: {
start_at: string;
end_at: string;
}): Promise<CheckpointMigrationTask> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: "启动迁移失败" }));
throw new Error(detail.detail ?? "启动迁移失败");
}
return (await response.json()) as CheckpointMigrationTask;
}
async function loadTask(taskId: string): Promise<CheckpointMigrationTask> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks/${taskId}`,
);
if (!response.ok) throw new Error("加载迁移任务失败");
return (await response.json()) as CheckpointMigrationTask;
}
function toLocalInputValue(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function statusVariant(status?: string) {
if (status === "failed") return "destructive" as const;
if (status === "completed") return "default" as const;
if (status === "queued" || status === "running") return "secondary" as const;
return "outline" as const;
}
export default function CheckpointMigrationPage() {
const now = useMemo(() => new Date(), []);
const weekAgo = useMemo(
() => new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
[now],
);
const [startAt, setStartAt] = useState(toLocalInputValue(weekAgo));
const [endAt, setEndAt] = useState(toLocalInputValue(now));
const [taskId, setTaskId] = useState<string | null>(null);
const { data: sources, isLoading: sourcesLoading, refetch } = useQuery({
queryKey: ["checkpointMigration", "sources"],
queryFn: loadSources,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const startMutation = useMutation({ mutationFn: startMigration });
const { data: task } = useQuery({
queryKey: ["checkpointMigration", "task", taskId],
queryFn: () => loadTask(taskId as string),
enabled: !!taskId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "queued" || status === "running" ? 2000 : false;
},
refetchOnWindowFocus: false,
});
const running =
startMutation.isPending ||
task?.status === "queued" ||
task?.status === "running";
async function handleStart() {
const start = new Date(startAt);
const end = new Date(endAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
toast.error("请选择有效的开始和结束时间");
return;
}
if (end <= start) {
toast.error("结束时间必须晚于开始时间");
return;
}
try {
const created = await startMutation.mutateAsync({
start_at: start.toISOString(),
end_at: end.toISOString(),
});
setTaskId(created.task_id);
toast.success("迁移任务已启动");
} catch (err) {
toast.error(err instanceof Error ? err.message : "启动迁移失败");
}
}
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
<div>
<h1 className="text-xl font-semibold">Checkpoint 迁移</h1>
<p className="text-muted-foreground mt-1 text-sm">
按时间段把 SQLite 旧聊天 checkpoint 同步到 Postgres。同一来源、同一时间段完成后再次执行会跳过。
</p>
</div>
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<DatabaseZap className="size-4" />
SQLite 来源
</div>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={sourcesLoading}
title="刷新"
>
<RefreshCwIcon className="size-4" />
</Button>
</div>
{sourcesLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
加载中
</div>
) : (sources?.source_paths.length ?? 0) > 0 ? (
<div className="space-y-2">
{sources?.source_paths.map((path) => (
<div
key={path}
className="rounded border bg-muted/40 px-3 py-2 font-mono text-xs break-all"
>
{path}
</div>
))}
</div>
) : (
<div className="text-muted-foreground rounded border border-dashed px-3 py-6 text-center text-sm">
未配置 SQLite 来源路径
</div>
)}
</section>
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">开始时间</span>
<Input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">结束时间</span>
<Input
type="datetime-local"
value={endAt}
onChange={(e) => setEndAt(e.target.value)}
/>
</label>
</div>
<div className="mt-5 flex justify-end">
<Button
onClick={handleStart}
disabled={running || !(sources?.source_paths.length ?? 0)}
>
{running && <Loader2Icon className="size-4 animate-spin" />}
开始同步
</Button>
</div>
</section>
{task && (
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm font-medium">任务状态</div>
<Badge variant={statusVariant(task.status)}>{task.status}</Badge>
</div>
<div className="grid gap-3 text-sm sm:grid-cols-4">
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">总线程</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.total_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">已复制</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.copied_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">已跳过</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.skipped_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">跳过时间段</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.skipped_ranges}
</div>
</div>
</div>
{(task.message || task.error) && (
<div className="mt-4 rounded border bg-muted/40 px-3 py-2 text-sm">
{task.error || task.message}
</div>
)}
</section>
)}
</div>
);
}
+1 -1
View File
@@ -310,7 +310,7 @@ const errorToastDarkOptions = {
<label className="text-muted-foreground text-xs dark:text-white">用户名</label>
<Input
value={user}
placeholder="按用户名匹配(@ 前部分)"
placeholder="按用户名模糊搜索(@ 前部分)"
onChange={(e) => {
setUser(e.target.value);
setPage(0);
+31 -4
View File
@@ -19,6 +19,11 @@ import {
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Select,
SelectContent,
@@ -361,8 +366,19 @@ export default function NewAgentPage() {
) : null}
</header>
<main className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_420px]">
<section className="flex min-h-0 flex-col border-r">
<ResizablePanelGroup
id="new-agent-config-panels"
orientation="horizontal"
defaultLayout={{ chat: 62, config: 38 }}
className="min-h-0 flex-1"
>
<ResizablePanel
id="chat"
defaultSize={62}
minSize={40}
className="min-h-0"
>
<section className="flex size-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<MessageList
className="size-full"
@@ -428,8 +444,18 @@ export default function NewAgentPage() {
</PromptInput>
</div>
</section>
</ResizablePanel>
<aside className="bg-background min-h-0 overflow-y-auto">
<ResizableHandle withHandle />
<ResizablePanel
id="config"
defaultSize={38}
minSize={24}
maxSize={60}
className="min-h-0"
>
<aside className="bg-background size-full min-h-0 overflow-y-auto">
<div className="space-y-5 p-4">
<div>
<h2 className="text-sm font-semibold">智能体配置</h2>
@@ -606,7 +632,8 @@ export default function NewAgentPage() {
</Button>
</div>
</aside>
</main>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
@@ -3,14 +3,18 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Check,
ChevronLeft,
ChevronRight,
Download,
FileText,
List,
Loader2,
Pencil,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
@@ -35,6 +39,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { reasoningPlugins } from "@/core/streamdown";
@@ -253,6 +258,11 @@ function stripTimestampSuffix(title: string): string {
}
const BODY_TAB_VALUE = "__body__";
const HTML_TAB_VALUE = "__html__";
const RUN_LIST_COLLAPSED_WIDTH = 56;
const RUN_LIST_DEFAULT_WIDTH = 380;
const RUN_LIST_MIN_WIDTH = 280;
const RUN_LIST_MAX_WIDTH = 560;
/**
* Public, unauthenticated scheduled-task viewer.
@@ -269,6 +279,9 @@ export default function PublicScheduledTaskPage() {
const { taskId } = useParams<{ taskId: string }>();
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [pendingDeleteRun, setPendingDeleteRun] = useState<{ id: string; title: string } | null>(null);
const [runListExpanded, setRunListExpanded] = useState(false);
const [runListWidth, setRunListWidth] = useState(RUN_LIST_DEFAULT_WIDTH);
const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
@@ -332,6 +345,58 @@ export default function PublicScheduledTaskPage() {
}
}, [runs, selectedRunId]);
const task = data?.task;
const visibleRunListWidth = runListExpanded ? runListWidth : RUN_LIST_COLLAPSED_WIDTH;
const expandRunList = useCallback(() => {
setRunListExpanded(true);
setRunListWidth((width) =>
Math.min(RUN_LIST_MAX_WIDTH, Math.max(RUN_LIST_MIN_WIDTH, width)),
);
}, []);
const collapseRunList = useCallback(() => {
setRunListExpanded(false);
}, []);
const handleRunListDragStart = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
dragStateRef.current = {
startX: event.clientX,
startWidth: visibleRunListWidth,
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
const drag = dragStateRef.current;
if (!drag) return;
const nextWidth = drag.startWidth + moveEvent.clientX - drag.startX;
if (nextWidth <= RUN_LIST_COLLAPSED_WIDTH + 28) {
setRunListExpanded(false);
return;
}
setRunListExpanded(true);
setRunListWidth(
Math.min(RUN_LIST_MAX_WIDTH, Math.max(RUN_LIST_MIN_WIDTH, nextWidth)),
);
};
const handlePointerUp = () => {
dragStateRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
},
[visibleRunListWidth],
);
if (!taskId) {
return (
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm">
@@ -357,12 +422,15 @@ export default function PublicScheduledTaskPage() {
);
}
const task = data?.task;
return (
<TooltipProvider delayDuration={150}>
<div className="bg-muted/20 grid h-screen min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[380px_minmax(0,1fr)]">
<Card className="flex min-h-0 flex-col">
<div className="bg-muted/20 flex h-screen min-h-0 overflow-hidden p-4">
<div
className="relative flex min-h-0 shrink-0 transition-[width] duration-200 ease-out"
style={{ width: visibleRunListWidth }}
>
{runListExpanded ? (
<Card className="flex min-h-0 w-full flex-col overflow-hidden">
<CardHeader className="gap-1">
<div className="flex items-start justify-between gap-2">
<Tooltip>
@@ -377,9 +445,21 @@ export default function PublicScheduledTaskPage() {
</TooltipContent>
)}
</Tooltip>
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0">
{task?.enabled ? "启用" : "暂停"}
</Badge>
<div className="flex shrink-0 items-center gap-1">
<Badge variant={task?.enabled ? "default" : "secondary"}>
{task?.enabled ? "启用" : "暂停"}
</Badge>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-primary size-7"
onClick={collapseRunList}
title="收起执行记录列表"
aria-label="收起执行记录列表"
>
<ChevronLeft className="size-4" />
</Button>
</div>
</div>
{task?.description && task.description.trim() && (
<p className="text-muted-foreground line-clamp-3 text-xs leading-relaxed break-all whitespace-pre-wrap">
@@ -481,9 +561,36 @@ export default function PublicScheduledTaskPage() {
</div>
</ScrollArea>
</CardContent>
</Card>
</Card>
) : (
<Card className="flex min-h-0 w-full flex-col items-center overflow-hidden py-3">
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-primary"
onClick={expandRunList}
title="展开执行记录列表"
aria-label="展开执行记录列表"
>
<List className="size-4" />
</Button>
</Card>
)}
<div
role="separator"
aria-orientation="vertical"
title="拖拽调整列表宽度"
className="group absolute top-0 -right-3 z-20 flex h-full w-6 cursor-col-resize items-center justify-center"
onPointerDown={handleRunListDragStart}
>
<div className="bg-border group-hover:bg-primary/70 h-full w-px transition-colors" />
<div className="bg-background text-muted-foreground group-hover:text-primary absolute top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full border shadow-sm transition-colors">
<ChevronRight className="size-3" />
</div>
</div>
</div>
<Card className="flex min-h-0 flex-col">
<Card className="ml-4 flex min-h-0 flex-1 flex-col">
{selectedRun ? (
<RunDetailPane key={selectedRun.id} run={selectedRun} taskId={taskId} />
) : (
@@ -546,12 +653,14 @@ export default function PublicScheduledTaskPage() {
}
type ContentTab = {
/** Stable tab identifier — `__body__` for inline content, `file-<index>` for files. */
/** Stable tab identifier — `__html__` for the HTML preview, `__body__` for
* inline content, `file-<index>` for files. */
value: string;
/** Short label rendered in the TabsTrigger. */
label: string;
/** Distinguishes body vs MD attachment so the editor knows which PUT to call. */
kind: "body" | "file";
/** Distinguishes the HTML preview vs body vs MD attachment so the renderer /
* editor knows what to show and which PUT to call. */
kind: "body" | "file" | "html";
/** File index (when `kind === "file"`). */
fileIndex?: number;
/** Filename for downloads (defaults to the run title for body). */
@@ -592,17 +701,27 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
[run.result?.files],
);
// Tabs: every MD file first (priority per spec), then the inline body.
// Tabs: HTML preview first (when present), then every MD file, then the
// inline body. For an HTML run that also produced a Markdown file, this keeps
// the page preview AND surfaces the .md instead of hiding it. The generated
// HTML document itself is never listed as a downloadable "file" tab — it has
// its own preview.
const tabs: ContentTab[] = useMemo(() => {
const list: ContentTab[] = markdownFiles.map((file, idx) => ({
value: `file-${file.index}`,
// Show the actual filename without the `.md` suffix — the user explicitly
// asked for "no '文件' label, just the name without extension".
label: stripExtension(file.name) || `文件 ${idx + 1}`,
kind: "file",
fileIndex: file.index,
fileName: file.name,
}));
const list: ContentTab[] = [];
if (isHtmlRun) {
list.push({ value: HTML_TAB_VALUE, label: "HTML 预览", kind: "html" });
}
list.push(
...markdownFiles.map((file, idx) => ({
value: `file-${file.index}`,
// Show the actual filename without the `.md` suffix — the user explicitly
// asked for "no '文件' label, just the name without extension".
label: stripExtension(file.name) || `文件 ${idx + 1}`,
kind: "file" as const,
fileIndex: file.index,
fileName: file.name,
})),
);
list.push({
value: BODY_TAB_VALUE,
label: "正文",
@@ -610,7 +729,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
fileName: run.result?.title || "scheduled-task-result",
});
return list;
}, [markdownFiles, run.result?.title]);
}, [isHtmlRun, markdownFiles, run.result?.title]);
const [activeTab, setActiveTab] = useState<string>(tabs[0]?.value ?? BODY_TAB_VALUE);
@@ -751,13 +870,13 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
下载原文件
</a>
)}
{!isHtmlRun && (
{activeTabMeta?.kind !== "html" && (
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
<FileText className="size-4" />
下载 Word
</Button>
)}
{!isHtmlRun &&
{activeTabMeta?.kind !== "html" &&
(isEditing ? (
<>
<Button
@@ -803,22 +922,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
{isHtmlRun ? (
<div className="h-full p-4">
{htmlContent ? (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={htmlContent}
className="h-full min-h-[60vh] w-full rounded-md border bg-white"
/>
) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
HTML 页面尚未生成或正在生成中。
</div>
)}
</div>
) : tabs.length === 0 ? (
{tabs.length === 0 ? (
<div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
) : (
<Tabs
@@ -848,7 +952,18 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
{run.error}
</div>
)}
{fileIsLoading ? (
{activeTabMeta?.kind === "html" ? (
htmlContent ? (
<HtmlPreviewFrame
html={htmlContent}
className="min-h-[60vh] w-full flex-1 rounded-md border bg-white"
/>
) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
HTML 页面尚未生成或正在生成中。
</div>
)
) : fileIsLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载文件内容...
@@ -18,6 +18,7 @@ import {
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
@@ -97,23 +98,7 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
/>
);
}
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
/**
* Sandboxed preview of a generated HTML document. The iframe has no
* ``allow-scripts`` token, so embedded JS cannot run — defense-in-depth on top
* of the server-side sanitization that already stripped script vectors.
*/
function HtmlPreviewFrame({ html }: { html: string }) {
return (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={html}
className="h-[70vh] w-full rounded-md border bg-white"
/>
);
return <pre className="whitespace-pre-wrap break-words text-sm">{file.content}</pre>;
}
type EditState = { draft: string; saving: boolean };
@@ -162,9 +147,9 @@ function ExecutionMessage({ message }: { message: ScheduledTaskRunMessage }) {
const isAi = message.event_type === "llm.ai.response";
return (
<div className="rounded-md border">
<div className="flex items-center gap-2 border-b px-3 py-1.5">
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
<div className="min-w-0 rounded-md border">
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-3 py-1.5">
<span className={cn("max-w-full break-all rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
{role.label}
{toolName ? ` · ${toolName}` : ""}
</span>
@@ -270,7 +255,16 @@ export default function ScheduledTaskRunDetailPage() {
}
};
const resultFiles = useMemo(() => result?.files ?? [], [result]);
// The generated HTML document is already shown in the dedicated "HTML 预览"
// tab, so it is excluded from the "结果文件" list — that list surfaces the
// Markdown (and any other non-HTML artifact) only.
const resultFiles = useMemo(
() =>
(result?.files ?? []).filter(
(f) => !(f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html")),
),
[result],
);
const fileCount = resultFiles.length;
const resultMetadata = useMemo(() => {
if (!result) return "";
@@ -295,18 +289,28 @@ export default function ScheduledTaskRunDetailPage() {
}
setFilesLoading(true);
try {
const loaded = await Promise.all(
// Load each file independently. A single failing file (e.g. an archive
// that 404s) must NOT blank the whole panel — otherwise the tab shows
// "结果文件(2)" while the body says "没有生成文件". Render every file
// that loads and skip the ones that don't.
const settled = await Promise.allSettled(
resultFiles.map((f) => {
const index = result.files.indexOf(f);
return getScheduledTaskRunFileContent(run.id, index);
}),
);
if (!cancelled) {
const loaded = settled
.filter(
(r): r is PromiseFulfilledResult<ScheduledTaskResultFileContent> =>
r.status === "fulfilled",
)
.map((r) => r.value);
setFileContents(loaded);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
const failed = settled.length - loaded.length;
if (failed > 0 && loaded.length === 0) {
toast.error("结果文件已不可用(可能已被清理)");
}
}
} finally {
if (!cancelled) {
@@ -428,9 +432,9 @@ export default function ScheduledTaskRunDetailPage() {
<WorkspaceHeader />
<WorkspaceBody>
<div className="flex size-full min-h-0 flex-col p-3">
<Card className="flex min-h-0 flex-1 flex-col">
<Card className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-3 py-2">
<div className="mr-auto flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
@@ -470,15 +474,15 @@ export default function ScheduledTaskRunDetailPage() {
{/* 执行信息(可折叠) */}
{showMeta && run && (
<div className="border-b px-3 py-2">
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
<div>ID:{run.id}</div>
<div className="text-muted-foreground grid min-w-0 gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
<div className="min-w-0 break-all">ID:{run.id}</div>
<div>计划:{formatDate(run.scheduled_for)}</div>
<div>开始:{formatDate(run.started_at)}</div>
<div>结束:{formatDate(run.finished_at)}</div>
<div>耗时:{formatDuration(run)}</div>
</div>
{run.error && (
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
<div className="text-destructive mt-2 break-words rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
{run.error}
</div>
)}
@@ -502,8 +506,8 @@ export default function ScheduledTaskRunDetailPage() {
}
className="flex min-h-0 flex-1 flex-col"
>
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
<div className="min-w-0 overflow-x-auto border-b px-3 pt-1">
<TabsList className="h-8 w-max">
{result && isHtmlRun && (
<TabsTrigger value="html" className="text-xs">
<Code2 className="mr-1 size-3" />
@@ -573,7 +577,7 @@ export default function ScheduledTaskRunDetailPage() {
rehypePlugins={[]}
/>
) : (
<pre className="whitespace-pre-wrap text-sm">
<pre className="whitespace-pre-wrap break-words text-sm">
{result.content || "无正文内容"}
</pre>
)}
@@ -584,7 +588,9 @@ export default function ScheduledTaskRunDetailPage() {
)}
{!filesLoading && fileContents.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
本次执行没有生成文件。
{fileCount > 0
? `本次执行生成了 ${fileCount} 个文件,但当前无法读取(文件可能已被清理或移动)。可尝试「下载全部」。`
: "本次执行没有生成文件。"}
</div>
)}
<div className="flex flex-col gap-3">
@@ -594,9 +600,9 @@ export default function ScheduledTaskRunDetailPage() {
const editState = edits[file.index] ?? null;
const isEditing = editState !== null;
return (
<div key={file.index} className="rounded-md border">
<div key={file.index} className="min-w-0 rounded-md border">
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
@@ -679,7 +685,7 @@ export default function ScheduledTaskRunDetailPage() {
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
<pre className="bg-muted overflow-auto whitespace-pre-wrap break-words rounded-md p-3 text-xs">
{resultMetadata}
</pre>
</TabsContent>
+154 -120
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
BellOff,
@@ -113,13 +114,15 @@ const emptyForm = {
timezone: "Asia/Shanghai",
prompt: "",
execution_agent_name: "",
// Two independent output switches (may both be on): require a Markdown file
// and/or render an HTML page. The form no longer splits into "普通任务 / HTML
// 页面" tabs.
require_markdown: false,
// HTML page task fields. ``task_kind`` "agent" keeps the original behavior.
// Data gathering is delegated to the agent's own search skills — no explicit
// search query / data source. ``html_model_name`` empty means "use the first
// available model" — never a hardcoded GLM-5.
task_kind: "agent" as "agent" | "html_page",
html_page_prompt: "",
require_html: false,
// HTML-only extra fields, shown when ``require_html`` is on. The main
// ``prompt`` above doubles as the page requirement, so there is no separate
// page-prompt field. ``html_model_name`` empty means "use the first available
// model" — never a hardcoded GLM-5.
html_negative_prompt: "",
html_reference_favorite_id: "",
html_model_name: "",
@@ -133,6 +136,7 @@ const DESCRIPTION_TEXTAREA_ROWS = 4;
const PROMPT_TEXTAREA_ROWS = 10;
export default function ScheduledTasksPage() {
const queryClient = useQueryClient();
const { theme } = useTheme();
const darkToastOpts =
theme === "dark"
@@ -242,11 +246,15 @@ export default function ScheduledTasksPage() {
description: task.description ?? "",
schedule: task.schedule_text,
timezone: task.timezone,
prompt: task.prompt,
// The main prompt doubles as the HTML page requirement; legacy HTML tasks
// stored that under ``html_config.page_prompt``, so prefer it on edit.
prompt: html.page_prompt || task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
require_markdown: !!task.execution_context?.require_markdown,
task_kind: task.execution_context?.task_kind === "html_page" ? "html_page" : "agent",
html_page_prompt: html.page_prompt ?? "",
// Backward compatible: legacy HTML tasks only carry ``task_kind``.
require_html:
!!task.execution_context?.require_html ||
task.execution_context?.task_kind === "html_page",
html_negative_prompt: html.negative_prompt ?? "",
html_reference_favorite_id: html.reference_favorite_id ?? "",
html_model_name: html.model_name ?? "",
@@ -268,20 +276,26 @@ export default function ScheduledTasksPage() {
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
};
const isHtmlTask = form.task_kind === "html_page";
const isHtmlTask = form.require_html;
// The model actually shown/used: the user's choice, else the first configured
// model. Never a hardcoded GLM-5 — it follows config order and the selection.
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
// Build the execution_context for the current form. HTML tasks persist their
// config under ``html_config``; regular tasks send an explicit ``agent`` kind
// so switching a task back from HTML clears the old config.
// Build the execution_context for the current form. ``require_html`` and
// ``require_markdown`` are independent toggles (both may be on). HTML tasks
// persist their extra config under ``html_config`` and reuse the main prompt
// as the page requirement; ``task_kind`` is kept in sync only for backward
// compatibility with older readers.
const buildExecutionContext = (): ScheduledTaskExecutionContext => {
if (!isHtmlTask) return { task_kind: "agent", html_config: undefined };
if (!isHtmlTask) {
return { require_html: false, task_kind: "agent", html_config: undefined };
}
return {
require_html: true,
task_kind: "html_page",
html_config: {
page_prompt: form.html_page_prompt.trim() || null,
// The main prompt doubles as the page requirement.
page_prompt: form.prompt.trim() || null,
negative_prompt: form.html_negative_prompt.trim() || null,
reference_favorite_id: form.html_reference_favorite_id || null,
// Send the user's selection; fall back to the first configured model,
@@ -295,11 +309,10 @@ export default function ScheduledTasksPage() {
try {
const cronSchedule = buildCron(scheduleConfig);
const executionContext = buildExecutionContext();
// HTML tasks may leave the agent prompt empty — fall back to the page
// prompt so the (non-null) prompt column always has something useful.
const effectivePrompt = isHtmlTask
? form.prompt.trim() || form.html_page_prompt.trim() || "生成 HTML 页面"
: form.prompt;
// The (non-null) prompt column always needs something useful; HTML tasks
// may leave it sparse, so fall back to a generic instruction.
const effectivePrompt =
form.prompt.trim() || (isHtmlTask ? "生成 HTML 页面" : form.prompt);
if (creating) {
const task = await actions.create.mutateAsync({
name: form.name || "定时任务",
@@ -307,8 +320,8 @@ export default function ScheduledTasksPage() {
schedule: cronSchedule,
timezone: form.timezone || "Asia/Shanghai",
prompt: effectivePrompt,
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null,
require_markdown: isHtmlTask ? false : form.require_markdown,
execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
execution_context: executionContext,
});
setSelectedTaskId(task.task_id);
@@ -322,8 +335,8 @@ export default function ScheduledTasksPage() {
schedule: cronSchedule,
timezone: form.timezone,
prompt: effectivePrompt,
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null,
require_markdown: isHtmlTask ? false : form.require_markdown,
execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
execution_context: executionContext,
},
});
@@ -361,15 +374,35 @@ export default function ScheduledTasksPage() {
}
};
const handleTriggerTask = async (task: ScheduledTask) => {
setSelectedTaskId(task.task_id);
setRunsTimeFilter("all");
try {
await actions.trigger.mutateAsync(task.task_id);
await queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", task.task_id, "runs"],
});
await queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "list"],
});
toast.success("已触发执行", darkToastOpts.success);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "触发失败",
darkToastOpts.error,
);
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="grid size-full min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[420px_minmax(0,1fr)]">
<Card className="flex min-h-0 flex-col">
<div className="grid size-full min-h-0 grid-cols-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 p-4 xl:grid-cols-[420px_minmax(0,1fr)] xl:grid-rows-1">
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="gap-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="min-w-0">
<CardTitle>定时任务</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
选择任务后,在右侧查看全部执行记录
@@ -480,7 +513,8 @@ export default function ScheduledTasksPage() {
<Globe className="size-3" />已发布
</Badge>
)}
{task.execution_context?.task_kind === "html_page" && (
{(task.execution_context?.require_html ||
task.execution_context?.task_kind === "html_page") && (
<Badge
variant="outline"
className="shrink-0 border-sky-500/30 bg-sky-500/10 text-xs text-sky-600 dark:text-sky-400"
@@ -563,8 +597,7 @@ export default function ScheduledTasksPage() {
title="立即执行"
onClick={(event) => {
event.stopPropagation();
actions.trigger.mutate(task.task_id);
toast.success("已触发执行", darkToastOpts.success);
void handleTriggerTask(task);
}}
>
<RefreshCw className="size-4" />
@@ -715,10 +748,27 @@ export default function ScheduledTasksPage() {
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="gap-2">
<div className="flex items-center justify-between gap-2">
<CardTitle>执行记录</CardTitle>
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<CardTitle>执行记录</CardTitle>
{selectedTask && (
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
title="在新标签打开该任务的公开页"
onClick={() => {
const url = `${window.location.origin}${window.location.pathname}#/public/scheduled-tasks/${encodeURIComponent(selectedTask.task_id)}`;
window.open(url, "_blank", "noopener,noreferrer");
}}
>
<Globe className="size-3.5" />
公开页
</Button>
)}
</div>
<Select value={runsTimeFilter} onValueChange={(v) => setRunsTimeFilter(v as typeof runsTimeFilter)}>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
@@ -731,7 +781,7 @@ export default function ScheduledTasksPage() {
</SelectContent>
</Select>
</div>
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground min-w-0 truncate text-sm">
{selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"}
</p>
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
@@ -770,11 +820,11 @@ export default function ScheduledTasksPage() {
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{run.result?.title ? desensitize(run.result.title) : formatDate(run.scheduled_for)}
</div>
<div className="text-muted-foreground mt-1 text-xs">
<div className="text-muted-foreground mt-1 break-words text-xs">
计划:{formatDate(run.scheduled_for)} · 开始:
{formatDate(run.started_at)}
</div>
@@ -932,20 +982,6 @@ export default function ScheduledTasksPage() {
}
placeholder="任务描述(可选,将显示在公开页标题下方)"
/>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">任务类型</p>
<Tabs
value={form.task_kind}
onValueChange={(v) =>
setForm((value) => ({ ...value, task_kind: v as "agent" | "html_page" }))
}
>
<TabsList className="w-full">
<TabsTrigger value="agent" className="flex-1">普通任务</TabsTrigger>
<TabsTrigger value="html_page" className="flex-1">HTML 页面</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">执行频率</p>
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
@@ -958,77 +994,83 @@ export default function ScheduledTasksPage() {
placeholder="时区,例如:Asia/Shanghai"
/>
{!isHtmlTask && (
<>
<Select
value={form.execution_agent_name || "__default__"}
onValueChange={(v) =>
setForm((value) => ({
...value,
execution_agent_name: v === "__default__" ? "" : v,
}))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择执行智能体" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="block truncate">
{desensitize(agent.name)}
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder="任务 Prompt"
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
</>
)}
<Select
value={form.execution_agent_name || "__default__"}
onValueChange={(v) =>
setForm((value) => ({
...value,
execution_agent_name: v === "__default__" ? "" : v,
}))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择执行智能体" />
</SelectTrigger>
<SelectContent
position="popper"
className="max-h-[40vh] w-[max(var(--radix-select-trigger-width),28rem)] max-w-[min(90vw,40rem)]"
>
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="block whitespace-normal break-words">
{desensitize(agent.name)}
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder={
isHtmlTask
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
: "任务 Prompt"
}
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
{isHtmlTask && (
<>
<Textarea
className="min-h-24"
value={form.html_page_prompt}
onChange={(event) =>
setForm((value) => ({ ...value, html_page_prompt: event.target.value }))
}
placeholder="页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
/>
<Textarea
className="min-h-16"
value={form.html_negative_prompt}
onChange={(event) =>
setForm((value) => ({ ...value, html_negative_prompt: event.target.value }))
}
placeholder="限制条件(可选),例如:不要使用深色背景,不要出现外部链接脚本"
placeholder="HTML 限制条件(可选),例如:不要使用深色背景,不要出现外部链接脚本"
/>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">参考收藏页面(可选)</p>
@@ -1106,14 +1148,6 @@ export default function ScheduledTasksPage() {
</SelectContent>
</Select>
</div>
<Textarea
className="min-h-16"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder="补充说明(可选):进一步约束智能体如何检索数据、页面包含哪些内容"
/>
<p className="text-muted-foreground text-xs">
执行流程:智能体运用自身检索技能整理真实数据 → 选定模型渲染为完整 HTML → 自动安全清洗与结构校验 → 可在执行记录中预览。
</p>
+22
View File
@@ -22,6 +22,8 @@ import AgentChatPage from "./AgentChatPage";
import AgentsPage from "./AgentsPage";
import ChatPage from "./ChatPage";
import ChatsPage from "./ChatsPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
@@ -241,6 +243,26 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/user-leaderboard"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserLeaderboardPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-leaderboard/settings"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserLeaderboardSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
@@ -25,6 +25,7 @@ import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
@@ -476,6 +477,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="settings/checkpoint-migration"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<CheckpointMigrationPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</RouteBaseProvider>
);
@@ -100,3 +100,37 @@ test("maps iframe model selection into stream context model_name", () => {
model_name: "OpenAI3",
});
});
test("maps externally uploaded files into stream additional_kwargs.files", () => {
const result = parseBrainyChatSubmitMessage({
source: "brainy-extension",
type: "brainy:chat-submit",
requestId: "iframe-789",
payload: {
text: "网摘",
prompt: "网摘",
uploadedFiles: [
{
filename: "LLM Wiki 接口说明.md",
size: "7002",
path: "/mnt/user-data/uploads/LLM Wiki 接口说明.md",
status: "uploaded",
artifact_url:
"/api/threads/445ca7a7-9737-44f0-8eea-6887776f4f9c/artifacts/mnt/user-data/uploads/LLM%20Wiki%20%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E.md",
},
],
},
});
assert.equal(result.submitText, "网摘");
assert.deepEqual(result.additionalKwargs?.files, [
{
filename: "LLM Wiki 接口说明.md",
size: "7002",
path: "/mnt/user-data/uploads/LLM Wiki 接口说明.md",
status: "uploaded",
artifact_url:
"/api/threads/445ca7a7-9737-44f0-8eea-6887776f4f9c/artifacts/mnt/user-data/uploads/LLM%20Wiki%20%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E.md",
},
]);
});
+48 -1
View File
@@ -26,6 +26,14 @@ type BrainyModelSelection = {
value?: string;
};
type BrainyUploadedFile = {
filename?: string;
size?: string | number;
path?: string;
status?: string;
artifact_url?: string;
};
export type BrainyChatSubmitMessage = {
source?: string;
type?: string;
@@ -35,6 +43,7 @@ export type BrainyChatSubmitMessage = {
pageContext?: BrainyPageContext;
screenshot?: BrainyScreenshot;
model?: BrainyModelSelection;
uploadedFiles?: BrainyUploadedFile[];
};
requestId?: string;
};
@@ -253,6 +262,36 @@ function buildScreenshotFileParts(
return [];
}
function normalizeUploadedFiles(
uploadedFiles: BrainyUploadedFile[] | undefined,
): Record<string, unknown>[] {
if (!Array.isArray(uploadedFiles)) {
return [];
}
return uploadedFiles
.map((file) => {
const filename = trimToUndefined(file.filename);
if (!filename) {
return null;
}
const size = String(file.size ?? "0");
const path = trimToUndefined(file.path);
const status = trimToUndefined(file.status);
const artifactUrl = trimToUndefined(file.artifact_url);
return {
filename,
size,
...(path ? { path } : {}),
...(status ? { status } : {}),
...(artifactUrl ? { artifact_url: artifactUrl } : {}),
};
})
.filter((file): file is Record<string, unknown> => file !== null);
}
export function parseBrainyChatSubmitMessage(
data: BrainyChatSubmitMessage,
): ParsedBrainyChatSubmitMessage {
@@ -268,6 +307,7 @@ export function parseBrainyChatSubmitMessage(
});
const requestId = trimToUndefined(data.requestId);
const modelName = trimToUndefined(data.payload?.model?.value);
const uploadedFiles = normalizeUploadedFiles(data.payload?.uploadedFiles);
const iframeContext: Record<string, unknown> = {};
if (requestId) {
@@ -281,6 +321,13 @@ export function parseBrainyChatSubmitMessage(
submitText,
files,
...(Object.keys(iframeContext).length > 0 ? { iframeContext } : {}),
...(promptPrefix ? { additionalKwargs: { prompt_prefix: promptPrefix } } : {}),
...(promptPrefix || uploadedFiles.length > 0
? {
additionalKwargs: {
...(promptPrefix ? { prompt_prefix: promptPrefix } : {}),
...(uploadedFiles.length > 0 ? { files: uploadedFiles } : {}),
},
}
: {}),
};
}
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Download, FileText, Loader2, X } from "lucide-react";
@@ -9,6 +10,11 @@ import {
isMermaidFile,
renderMermaidToSvg,
} from "@/core/utils/mermaid";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
interface ArtifactPreviewModalProps {
@@ -23,15 +29,32 @@ interface ArtifactPreviewModalProps {
onClose: () => void;
}
const MARKDOWN_COMPONENTS = {
function extractTextFromChildren(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
return "";
}
function createMarkdownComponents(content: string) {
const anchorResolver = createMarkdownAnchorResolver(content);
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6, className: string) => {
const Tag = `h${level}` as const;
return ({ children, id }: any) => (
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} className={className}>
{children}
</Tag>
);
};
return {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-base font-bold mt-3 mb-2">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
h1: heading(1, "text-base font-bold mt-3 mb-2 scroll-mt-4"),
h2: heading(2, "text-sm font-bold mt-3 mb-1.5 scroll-mt-4"),
h3: heading(3, "text-xs font-bold mt-2 mb-1 scroll-mt-4"),
h4: heading(4, "text-xs font-bold mt-1.5 mb-1 scroll-mt-4"),
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
@@ -48,18 +71,45 @@ const MARKDOWN_COMPONENTS = {
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-3 my-2 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
a: ({ children, href }: any) => {
const resolvedHref = anchorResolver.resolveHref(href, extractTextFromChildren(children));
if (resolvedHref === null) {
return (
<span
className="text-primary/80 underline decoration-dotted underline-offset-2 cursor-not-allowed"
title="未在当前 Markdown 中找到对应章节"
>
{children}
</span>
);
}
const finalHref = resolvedHref ?? href;
const isExternal = typeof href === "string" && /^https?:\/\//i.test(href);
return (
<a
href={finalHref}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="text-primary underline hover:opacity-80"
onClick={(event) => {
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
}}
>
{children}
</a>
);
},
table: ({ children }: any) => <table className="my-2 border-collapse text-[12px] w-full">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-2 py-1 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-2 py-1">{children}</td>,
hr: () => <hr className="my-3 border-slate-200" />,
};
};
}
function isMarkdown(mimeType: string | null, name: string): boolean {
if (mimeType === "text/markdown") return true;
@@ -159,6 +209,10 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
cancelled = true;
};
}, [content, renderAsMermaid]);
const markdownComponents = useMemo(
() => createMarkdownComponents(content ?? ""),
[content],
);
return (
<div
@@ -198,7 +252,10 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700">
<div
data-markdown-anchor-root
className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700"
>
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-slate-400">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
@@ -221,7 +278,8 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
</div>
)
) : renderAsMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content}
</ReactMarkdown>
) : (
@@ -12,6 +12,7 @@
*
* 圆桌可视化 + 博弈边界调控台目前**未启用**(原代码用 `false &&` 隐藏),
* 这里保留同样的占位逻辑,后续要恢复时改 `SHOW_VISUALIZER` 即可。
* 「后台挂起」按钮同理,改 `SHOW_BACKGROUND_SUSPEND` 即可恢复。
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
@@ -23,10 +24,12 @@ import {
Loader2,
Pause,
Play,
Rocket,
Sparkles,
} from "lucide-react";
/** 后台挂起入口 —— 暂时隐藏,恢复时改为 true。 */
const SHOW_BACKGROUND_SUSPEND = false;
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -367,17 +370,20 @@ export function Step2Panel(props: Step2PanelProps) {
干预并发送
</button>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
{SHOW_BACKGROUND_SUSPEND && (
<>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 cursor-pointer dark:bg-secondary dark:text-foreground dark:border-border"
>
<Rocket className="w-3.5 h-3.5 text-primary" />
后台挂起
</button>
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 cursor-pointer dark:bg-secondary dark:text-foreground dark:border-border"
>
后台挂起
</button>
</>
)}
<button
type="button"
+3 -1
View File
@@ -21,7 +21,9 @@
"paths": {
"@/env": ["./src/env.ts"],
"@/*": ["./src/*"],
"@vite/*": ["./src/*"]
"@vite/*": ["./src/*"],
"next/navigation": ["./src/shims/next-navigation.ts"],
"next/link": ["./src/shims/next-link.tsx"]
},
"types": ["vite/client"]
},
@@ -263,12 +263,22 @@ Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` →
**HTML page tasks (`task_kind == "html_page"`)**: a second task kind that renders a full HTML document each run instead of a Markdown report. The kind + its config (`html_config`: `page_prompt`, `negative_prompt`, `reference_favorite_id`, `model_name`) live inside `execution_context` — no schema change to `scheduled_tasks`. Data gathering is delegated to the agent's own search skills (driven by `page_prompt`), so there is no explicit search-query/data-source field; the render model is user-selectable (`model_name`, default `zai-org/GLM-5-FP8`). `_run_task_attempt()` dispatches by kind; `_run_html_page_attempt()` runs the pipeline: gather real data via the lead agent (its search tools) → optionally load a reference favorite's reusable style/layout → render with the chosen model (thinking off) → fault-tolerant JSON parse → sanitize (BeautifulSoup, strips `<script>`/`<iframe>`/`on*`/`javascript:`) → structural validate → one GLM5 repair pass if invalid → **completeness gate**: if the HTML is still not a full valid document (missing doctype/html/head/body, empty body, or residual scripts) after the repair pass, raise `HtmlGenerationError` so the run is marked **failed** (a page only counts as successful when it is a complete, previewable HTML document) → best-effort Playwright screenshot (no-op when Playwright absent) → archive `index.html` → persist a `scheduled_html_pages` row → return `result` with `format: "html"` and the full HTML inline. Pure helpers live in `deerflow/runtime/scheduler/html_page.py`. The frontend previews the HTML in a sandboxed `iframe` (no `allow-scripts`) on the run-detail and public pages; a user can bookmark a page (style summary extracted via GLM5) and later pick it as a style reference. New tables `scheduled_html_pages` + `html_page_favorites` (`deerflow/persistence/html_pages/`, wired as `app.state.html_page_store`) are created by `Base.metadata.create_all` with no migration. Tests: `tests/test_html_page_tasks.py`.
**Unified output switches (`require_html` + `require_markdown`).** The task form no longer splits "普通任务 / HTML 页面" into separate kinds. `execution_context` now carries two **independent** booleans that may both be on: `require_html` (必须生成 HTML) and `require_markdown` (必须生成 Markdown). `_task_requires_html()` returns true for `require_html` **or** legacy `task_kind == "html_page"` (so old tasks need no data migration), and `_run_task_attempt()` dispatches to `_run_html_page_attempt` when it is true, else the regular agent attempt. When **both** switches are on, `_gather_html_data()` returns `(text, files)` — the gather agent run is additionally told to save a `.md` file — and `_run_html_page_attempt` validates it (`MarkdownRequirementError` on failure) and surfaces it alongside the generated page, so the run delivers both artifacts. The single main `prompt` doubles as the HTML page requirement (`html_config.page_prompt`); there is no separate page-prompt field. Tests: `tests/test_scheduled_task_require_html.py` + the markdown-coexistence cases in `tests/test_html_page_tasks.py`.
### Per-user Prompts & UI Preferences
**Custom prompts can be scoped to one agent.** `user_custom_prompts` gains a nullable `agent_id` column (migration `20260603_02`): `NULL` = global (the chip shows in every chat), otherwise the prompt only surfaces when that agent is selected. The visibility filter is applied in the frontend `CustomPromptBar` (it receives the current agent id from `ChatPage`'s selector or `AgentChatPage`'s `assistantId`); the backend (`/api/user-prompts` CRUD) just stores/returns/clears `agent_id` (update accepts an explicit `null` to clear). Tests: `tests/test_user_prompts_agent_id.py`.
**Per-user UI preferences** (`deerflow/persistence/user_preferences/`, wired as `app.state.user_preferences_store`; migration `20260603_03`, also auto-created by `create_all`): one JSON-blob row per user so new toggles need no schema change. Router `/api/user-preferences` (`GET`/`PUT`, account-scoped). Current key: `hide_admin_prompt_prefix` — when true the chat input hides the admin-configured prompt-prefix switch and never applies the prefix (the user uses only their own prompts; enforced in `ChatPage` for both `shouldApplyPrefix` and `promptPrefixProp`). Toggled from the "管理常用提示词" dialog. Tests: `tests/test_user_preferences.py`.
**HTML skills (tools the render step uses).** Two offline skills under `skills/public/` shape the generated HTML and are toggled in the skills settings page (enabled by default): `html-page-builder` (a self-contained inline-CSS design system — colors/cards/tables/KPI/responsive) and `html-charts` (inline-SVG charts that render in the no-`allow-scripts` preview iframe; optional local ECharts documented in its `assets/README.md`). A skill opts into HTML injection via `metadata.kind: html_page` in its SKILL.md frontmatter; `_collect_html_skill_instructions()` reads the bodies of **enabled** such skills and `build_generation_prompt(skill_instructions=…)` injects them, so the (raw-model) render step honors the user's skill selection. The data-gathering step and the render/repair/style passes all use the task's selected `model_name` (no silent fallback to the default model — and there is **no hardcoded GLM-5**; an unset selection falls back to config `models[0]`). To stop auxiliary LLM calls from leaking onto the default model, **`TitleMiddleware` is skipped when `is_scheduled_run` is set** (`_build_middlewares` in `lead_agent/agent.py`) — scheduled/HTML runs use throwaway threads whose titles are never shown, and `config.title.model_name` is usually unset (→ would call `models[0]`, not the chosen model). The completeness gate (`validate_html`) also requires a non-empty `<title>` and forbids external stylesheets, on top of the doctype/html/head/body + non-empty-body + no-script checks.
### Custom Agent Persistence
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
**Listing cache + pagination** — to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term→abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter. `POST /api/agents/private-square/list` takes the same pagination params. Repository entry point: `AgentStore.list_paginated(...)`.
**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
### Portable Column Types (`deerflow/persistence/types.py`)
@@ -511,6 +521,8 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_
Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`.
- `log_system` - Admin-only external log-system entry (`deerflow/config/log_system_config.py`): `{enabled, url, label}`. Surfaced read-only by `GET /api/system-settings/log-system` (admin only); the frontend renders it as a "日志系统" item in the bottom-right settings menu (`workspace-nav-menu.tsx`) that opens `url` in a new tab. Disabled by default.
**`extensions_config.json`**:
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description)
- `skills` - Map of skill name → state (enabled)
@@ -30,8 +30,9 @@ from app.gateway.routers import (
artifacts,
assistants_compat,
auth,
channels,
feedback,
channels,
checkpoint_migration,
feedback,
html_page_favorites,
intent,
llm_metrics,
@@ -59,6 +60,7 @@ from app.gateway.routers import (
thread_shares,
threads,
uploads,
user_preferences,
user_prompts,
writing,
)
@@ -859,6 +861,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(user_prompts.router)
# Per-user UI preferences at /api/user-preferences
app.include_router(user_preferences.router)
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
app.include_router(roundtable_drafts.router)
@@ -902,7 +907,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(tool_metrics.router)
# Channels API is mounted at /api/channels
app.include_router(channels.router)
app.include_router(channels.router)
# Admin checkpoint migration API: SQLite legacy checkpoints -> Postgres.
app.include_router(checkpoint_migration.router)
# Assistants compatibility API (LangGraph Platform stub)
app.include_router(assistants_compat.router)
@@ -942,7 +950,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(recommend.router) # Step 1→2:/api/recommend/stream(无 init)
# Step 3 产物列表/下载复用下方 artifacts.router(/api/threads/{id}/artifacts)
# 探活端点:无需鉴权(见 auth_middleware._PUBLIC_PATH_PREFIXES)。
# 同时暴露裸 ``/health`` 与带 API 前缀的 ``/api/health``,方便监控按
# 任意 base URL 直接探活,不会被认证中间件拦成 401。
@app.get("/health", tags=["health"])
@app.get("/api/health", tags=["health"])
async def health_check() -> dict:
"""Health check endpoint.
@@ -71,6 +71,14 @@ class LocalAuthProvider(AuthProvider):
"""
return await self._repo.find_user_by_email_local_part(local_part)
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]:
"""Fuzzy-match users by a substring of their email local part.
Backs the admin LLM-metrics username filter — returns every account
whose username contains ``substring`` (case-insensitive).
"""
return await self._repo.find_users_by_email_local_part_like(substring, limit=limit)
async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User:
"""Create a new local user.
@@ -81,6 +81,24 @@ class UserRepository(ABC):
"""
raise NotImplementedError
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list["User"]:
"""Fuzzy-match users whose email local part *contains* ``substring``.
Backs the admin LLM-metrics username filter (按用户名模糊搜索). Unlike
:meth:`find_user_by_email_local_part` (exact prefix → single account),
this returns every matching account. Case-insensitive substring match
against the part before ``@``. Non-overriding repositories return an
empty list.
Args:
substring: Case-insensitive substring to match within the local part.
limit: Maximum number of users to return.
Returns:
Matching users (may be empty).
"""
return []
@abstractmethod
async def update_user(self, user: User) -> User:
"""Update an existing user.
@@ -109,6 +109,33 @@ class SQLiteUserRepository(UserRepository):
row = result.scalar_one_or_none()
return self._row_to_user(row) if row is not None else None
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]:
term = (substring or "").strip().lower()
if not term:
return []
# Escape SQL LIKE wildcards so a literal ``_``/``%`` in the query does
# not turn into a wildcard. ``\`` is the chosen escape char.
escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped}%"
# Prefilter in SQL on the whole email (portable across sqlite/postgres/
# mysql), then keep only rows whose *local part* contains the term so a
# domain-only hit (e.g. searching "gmail") doesn't pull unrelated users.
stmt = (
select(UserRow)
.where(func.lower(UserRow.email).like(pattern, escape="\\"))
.order_by(UserRow.created_at.asc())
.limit(max(1, limit))
)
async with self._sf() as session:
result = await session.execute(stmt)
rows = result.scalars().all()
out: list[User] = []
for row in rows:
local = (row.email or "").split("@", 1)[0].lower()
if term in local:
out.append(self._row_to_user(row))
return out
async def update_user(self, user: User) -> User:
async with self._sf() as session:
row = await session.get(UserRow, str(user.id))
@@ -29,6 +29,7 @@ from deerflow.runtime.user_context import reset_current_user, set_current_user
# safe read-only routes under it — everything there bypasses auth entirely.
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/health",
"/api/health",
"/docs",
"/redoc",
"/openapi.json",
@@ -163,6 +163,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.user_prompt_store = make_user_prompt_store(sf)
from deerflow.persistence.user_preferences import make_user_preferences_store
app.state.user_preferences_store = make_user_preferences_store(sf)
from deerflow.persistence.roundtable_drafts import make_roundtable_draft_store
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
File diff suppressed because it is too large. Load diff
@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.auth.password import verify_password_async
from app.gateway.deps import get_agent_store, get_config, get_tag_store
from app.gateway.deps import get_agent_store, get_config, get_tag_store
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
@@ -62,13 +62,27 @@ class AgentResponse(BaseModel):
is_favorite: bool = Field(
default=False,
description="Whether the requesting user has favorited (收藏) this agent.",
)
)
is_pinned: bool = Field(
default=False,
description="Whether the requesting user has pinned this agent.",
)
class AgentsListResponse(BaseModel):
"""Response model for listing agents.
class AgentsListResponse(BaseModel):
"""Response model for listing all visible agents."""
``total`` / ``page`` / ``page_size`` describe server-side pagination. When
a caller does not opt into pagination they default to "everything on one
page" (``total == len(agents)``, ``page == 1``), so existing unpaginated
callers keep working unchanged.
"""
agents: list[AgentResponse]
total: int = Field(default=0, description="Total agents matching the query across all pages")
page: int = Field(default=1, description="1-based page index of this response")
page_size: int = Field(default=0, description="Page size; 0 means the result was not paginated")
class AgentCreateRequest(BaseModel):
@@ -101,7 +115,7 @@ class AgentUpdateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
class AgentSelectorResponse(BaseModel):
class AgentSelectorResponse(BaseModel):
"""Response for the homepage agent selector dropdown.
The list is fully curated by admins (``featured_order`` on each row).
@@ -111,10 +125,10 @@ class AgentSelectorResponse(BaseModel):
agents: list[AgentResponse] = Field(
default_factory=list,
description="Admin-curated agents, sorted by featured_order ascending.",
)
class AgentFeaturedRequest(BaseModel):
)
class AgentFeaturedRequest(BaseModel):
"""Request body for setting/clearing an agent's homepage featured rank."""
order: int | None = Field(
@@ -215,6 +229,7 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
handlers as a safety net for environments that bypassed the lifespan hook.
"""
try:
pairs: list[tuple[str, Any]] = []
for agent_cfg in list_custom_agents():
agent_id = agent_cfg.id or agent_cfg.name
if not agent_id:
@@ -227,10 +242,41 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
"published": True,
}
)
pairs.append((agent_id, agent_cfg))
await _backfill_listing_cache(store, pairs)
except Exception:
logger.exception("Failed to sync legacy folder-backed agents")
async def _backfill_listing_cache(store: AgentStore, pairs: list[tuple[str, Any]]) -> None:
"""Mirror each agent's config.yaml skills/tool_groups/model into the DB cache.
The list endpoint reads skills/tool_groups from these cache rows instead of
stat+parsing every ``config.yaml`` per request. We fetch the current cache
in one batch and only re-write rows whose values actually changed, so a
steady-state sync (every ``_LEGACY_SYNC_TTL_SECONDS``) is cheap and does not
churn DELETE/INSERTs. For the cache, ``None`` and ``[]`` skills are
equivalent ("no skills") — the tri-state only matters to the agent runtime,
which reads config.yaml directly.
"""
if not pairs:
return
cached = await store.get_extras_for([aid for aid, _ in pairs])
for agent_id, cfg in pairs:
want_skills = [s for s in (cfg.skills or []) if isinstance(s, str)]
want_tool_groups = cfg.tool_groups
want_model = cfg.model
existing = cached.get(agent_id)
if existing is None or existing.get("skills") != want_skills:
await store.replace_skills(agent_id, want_skills)
if (
existing is None
or existing.get("tool_groups") != want_tool_groups
or existing.get("model") != want_model
):
await store.set_extras(agent_id, tool_groups=want_tool_groups, model=want_model)
# Throttle the per-request legacy-sync fallback. The real sync runs once at
# startup (lifespan); request handlers used to call ``_sync_legacy_agents`` on
# every hit, which scanned the filesystem + did N DB upserts per agent each
@@ -302,7 +348,7 @@ def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
logger.exception("Failed to self-heal agent dir for '%s'", agent_id)
def _record_to_response(
def _record_to_response(
record: dict[str, Any],
*,
include_soul: bool = False,
@@ -344,10 +390,21 @@ def _record_to_response(
tool_groups=extras["tool_groups"],
skills=extras["skills"],
featured_order=record.get("featured_order"),
)
async def _record_to_response_async(
)
def _sort_agent_responses(responses: list[AgentResponse], sort: str) -> list[AgentResponse]:
reverse = sort not in {"time_asc", "updated_asc", "name_asc"}
if sort in {"name_asc", "name_desc"}:
ordered = sorted(responses, key=lambda r: (r.name or "").lower(), reverse=reverse)
elif sort in {"updated_desc", "updated_asc"}:
ordered = sorted(responses, key=lambda r: str(r.updated_at or ""), reverse=reverse)
else:
ordered = sorted(responses, key=lambda r: str(r.created_at or ""), reverse=reverse)
return sorted(ordered, key=lambda r: 0 if r.is_pinned else 1)
async def _record_to_response_async(
record: dict[str, Any],
*,
include_soul: bool = False,
@@ -403,6 +460,40 @@ async def _get_editable_or_raise(
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
async def _build_list_responses(
store: AgentStore,
tag_store: TagStore,
user_id: str,
records: list[dict[str, Any]],
) -> list[AgentResponse]:
"""Build gallery responses for a set of agent rows WITHOUT touching disk.
skills / tool_groups / model come from the denormalized DB cache
(``get_extras_for`` — one batched query) instead of stat+parsing every
agent's ``config.yaml`` per request, which was the list-endpoint hot spot.
SOUL.md is skipped (only the edit dialog reads it). An agent absent from
the cache (never mirrored) keeps its ``None`` extras until the next sync.
"""
responses = [
_record_to_response(a, include_soul=False, include_extras=False) for a in records
]
ids = [r.id for r in responses]
extras = await store.get_extras_for(ids)
assignments = await tag_store.list_assignments("agent", ids)
favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id)
for resp in responses:
cached = extras.get(resp.id)
if cached is not None:
resp.skills = cached.get("skills")
resp.tool_groups = cached.get("tool_groups")
resp.model = cached.get("model")
resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids
resp.is_pinned = resp.id in pin_ids
return responses
@router.get(
"/agents",
response_model=AgentsListResponse,
@@ -411,9 +502,21 @@ async def _get_editable_or_raise(
)
async def list_agents(
request: Request,
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized display form)"),
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
admin: bool = Query(
scope: str | None = Query(
default=None,
pattern="^(mine|square)$",
description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.",
),
page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."),
page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."),
sort: str = Query(
default="time_desc",
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.",
),
admin: bool = Query(
default=False,
description=(
"Admin-only override. When ``true`` and the caller is a system admin, "
@@ -429,38 +532,38 @@ async def list_agents(
tag_store: TagStore = get_tag_store(request)
await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request)
if admin:
if not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
agents = await store.list_all()
else:
agents = await store.list_visible(user_id)
# Load lightweight extras (skills / model / tool_groups from config.yaml)
# so the gallery cards can render skill badges. SOUL.md is still skipped
# (only the edit dialog reads it). The per-agent config.yaml reads run in
# worker threads concurrently, so the event loop stays responsive even
# with many agents.
responses = list(
await asyncio.gather(
*(
_record_to_response_async(a, include_soul=False, include_extras=True)
for a in agents
)
)
)
if admin and not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
favorite_ids = await store.list_favorite_ids(user_id)
for resp in responses:
resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids
paginate = scope is not None or page is not None
if paginate:
eff_page = page or 1
rows, total = await store.list_paginated(
user_id=user_id,
scope=scope,
search=search,
tag_ids=tag_ids,
page=eff_page,
page_size=page_size,
admin=admin,
sort=sort,
)
responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
# Legacy unpaginated path — returns everything (admin console, callers
# that have not migrated to pagination). Extras now come from the DB
# cache too, so this path no longer reads config.yaml per agent.
agents = await store.list_all() if admin else await store.list_visible(user_id)
responses = await _build_list_responses(store, tag_store, user_id, agents)
if search:
needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()]
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
return AgentsListResponse(agents=responses)
if tag_ids:
wanted = set(tag_ids)
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
responses = _sort_agent_responses(responses, sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException:
raise
except Exception as e:
@@ -473,14 +576,14 @@ async def list_agents(
summary="Check Agent Name",
description="Validate an agent display name. Duplicate names are allowed.",
)
async def check_agent_name(name: str) -> dict[str, Any]:
_require_agents_api_enabled()
normalized = _validate_display_name(name)
return {"available": True, "name": normalized}
@router.get(
"/agents/selector",
async def check_agent_name(name: str) -> dict[str, Any]:
_require_agents_api_enabled()
normalized = _validate_display_name(name)
return {"available": True, "name": normalized}
@router.get(
"/agents/selector",
response_model=AgentSelectorResponse,
summary="Agent Homepage Selector",
description=(
@@ -532,10 +635,12 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
# Detail view legitimately reads SOUL.md + config.yaml — hop to a
# worker thread so the asyncio loop stays responsive under concurrent
# detail fetches.
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id)
response.is_favorite = agent_id in favorite_ids
return response
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id)
pin_ids = await store.list_pin_ids(user_id)
response.is_favorite = agent_id in favorite_ids
response.is_pinned = agent_id in pin_ids
return response
except HTTPException:
raise
except Exception as e:
@@ -587,6 +692,10 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
"is_private": request.is_private,
}
)
# Mirror skills/tool_groups/model into the listing cache so the gallery
# can render this agent's badges without reading its config.yaml.
await store.replace_skills(agent_id, request.skills)
await store.set_extras(agent_id, tool_groups=request.tool_groups, model=request.model)
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
return await _record_to_response_async(record, include_soul=True, include_extras=True)
@@ -653,6 +762,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
model=next_model,
tool_groups=next_tool_groups,
)
# Keep the listing cache in lock-step with config.yaml on every write.
await store.replace_skills(agent_id, next_skills)
await store.set_extras(agent_id, tool_groups=next_tool_groups, model=next_model)
if request.soul is not None:
_write_agent_soul(agent_id, request.soul)
@@ -762,9 +874,17 @@ async def delete_agent(agent_id: str, request: Request) -> None:
await get_tag_store(request).unassign_all("agent", agent_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass
try:
await store.delete_favorites_for_agent(agent_id)
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
try:
await store.delete_favorites_for_agent(agent_id)
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
pass
try:
await store.delete_pins_for_agent(agent_id)
except Exception: # noqa: BLE001 - pin cleanup must not block deletion
pass
try:
await store.delete_extras_for_agent(agent_id)
except Exception: # noqa: BLE001 — cache cleanup must not block deletion
pass
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
except HTTPException:
@@ -805,7 +925,7 @@ async def favorite_agent(agent_id: str, request: Request) -> None:
summary="Unfavorite Agent",
description="Remove an agent from the current user's favorites. No-op when not favorited.",
)
async def unfavorite_agent(agent_id: str, request: Request) -> None:
async def unfavorite_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
@@ -816,13 +936,60 @@ async def unfavorite_agent(agent_id: str, request: Request) -> None:
logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
@router.put(
"/agents/{agent_id}/featured",
except Exception as e:
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
@router.post(
"/agents/{agent_id}/pin",
status_code=204,
summary="Pin Agent",
description="Pin a visible agent to the top of the current user's lists. Idempotent.",
)
async def pin_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
await store.add_pin(user_id, agent_id)
logger.info("User '%s' pinned agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to pin agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to pin agent: {str(e)}") from e
@router.delete(
"/agents/{agent_id}/pin",
status_code=204,
summary="Unpin Agent",
description="Remove the current user's pin from an agent. No-op when not pinned.",
)
async def unpin_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
await store.remove_pin(user_id, agent_id)
logger.info("User '%s' unpinned agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to unpin agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unpin agent: {str(e)}") from e
@router.put(
"/agents/{agent_id}/featured",
response_model=AgentResponse,
summary="Set Agent Featured Rank (admin)",
description=(
@@ -864,10 +1031,19 @@ async def set_agent_featured(
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
class PrivateSquareListRequest(BaseModel):
class PrivateSquareListRequest(BaseModel):
"""Request body for listing all private-square agents."""
password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.")
search: str | None = Field(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized).")
tag_ids: list[str] | None = Field(default=None, description="Keep agents carrying any of these tags (OR).")
page: int | None = Field(default=None, ge=1, description="1-based page index. Providing this enables server-side pagination.")
page_size: int = Field(default=20, ge=1, le=200, description="Items per page when paginating.")
sort: str = Field(
default="time_desc",
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created first.",
)
class AgentPrivateUpdateRequest(BaseModel):
@@ -899,14 +1075,28 @@ async def list_private_square(body: PrivateSquareListRequest, request: Request)
try:
store = get_agent_store(request)
tag_store: TagStore = get_tag_store(request)
await _sync_legacy_agents(store)
records = await store.list_private()
responses = [_record_to_response(r, include_soul=True) for r in records]
await _maybe_sync_legacy_agents(store)
# user_id is unused by the "private" scope filter (the password is the
# gate), but list_paginated requires it for its signature.
user_id = _current_user_id(request)
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
for resp in responses:
resp.tags = assignments.get(resp.id, [])
return AgentsListResponse(agents=responses)
if body.page is not None:
rows, total = await store.list_paginated(
user_id=user_id,
scope="private",
search=body.search,
tag_ids=body.tag_ids,
page=body.page,
page_size=body.page_size,
sort=body.sort,
)
responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size)
records = await store.list_private()
responses = await _build_list_responses(store, tag_store, user_id, records)
responses = _sort_agent_responses(responses, body.sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException:
raise
except Exception as e:
@@ -22,7 +22,7 @@ from app.gateway.auth import (
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.csrf_middleware import is_secure_request
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_skill_store
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store
logger = logging.getLogger(__name__)
@@ -336,7 +336,7 @@ def _set_session_cookie(response: Response, token: str, request: Request) -> Non
# production multi-worker setups, replace this with a shared store (Redis,
# database-backed counter) to enforce a true per-IP limit.
_MAX_LOGIN_ATTEMPTS = 5
_MAX_LOGIN_ATTEMPTS = 100
_LOCKOUT_SECONDS = 300 # 5 minutes
# ip → (fail_count, lock_until_timestamp)
@@ -749,6 +749,7 @@ class UserListItem(BaseModel):
system_role: Literal["admin", "user"]
skill_count: int
agent_count: int
qa_count: int = 0 # 问答次数: number of runs (one run = one question)
@router.get("/users", response_model=list[UserListItem])
@@ -757,11 +758,19 @@ async def list_all_users(
skill_store: SkillStore = Depends(get_skill_store),
agent_store: AgentStore = Depends(get_agent_store),
):
"""List all users with their skill and agent counts. Admin only."""
"""List all users with their skill, agent and Q&A counts. Admin only."""
user = await get_current_user_from_request(request)
if user.system_role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
users = await get_local_provider().list_users()
# One GROUP BY query for everyone's 问答次数, so the list stays a single
# aggregate read instead of a per-user count. Best-effort: if the run store
# is unavailable the column simply renders as 0 rather than failing the list.
try:
qa_counts = await get_run_store(request).count_by_user()
except Exception:
logger.warning("Failed to load per-user run counts for user list", exc_info=True)
qa_counts = {}
result = []
for u in users:
uid = str(u.id)
@@ -772,6 +781,7 @@ async def list_all_users(
system_role=u.system_role,
skill_count=await skill_store.count_owned(uid),
agent_count=await agent_store.count_owned(uid),
qa_count=qa_counts.get(uid, 0),
)
)
return result
@@ -0,0 +1,276 @@
"""Admin checkpoint migration endpoints.
These endpoints support the SQLite -> Postgres transition for LangGraph
checkpoints. The live checkpointer should be Postgres with SQLite dual-read
enabled; this router only starts manual, bounded migrations for selected time
ranges.
"""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.deps import get_current_user_from_request
from deerflow.config.runtime_paths import runtime_home
from deerflow.runtime.checkpointer.checkpoint_migration import (
SQLiteCheckpointSource,
copy_thread_state,
sqlite_recent_threads,
target_has_thread,
)
from deerflow.runtime.store._sqlite_utils import resolve_sqlite_conn_str
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/checkpoint-migration", tags=["checkpoint-migration"])
_TASKS: dict[str, dict[str, Any]] = {}
class MigrationStartRequest(BaseModel):
start_at: str = Field(description="Inclusive start datetime, ISO string.")
end_at: str = Field(description="Inclusive end datetime, ISO string.")
source_paths: list[str] | None = Field(default=None, description="Optional SQLite sources. Defaults to configured dual-read paths.")
class MigrationTaskResponse(BaseModel):
task_id: str
status: str
start_at: str
end_at: str
source_paths: list[str]
total_threads: int = 0
copied_threads: int = 0
skipped_threads: int = 0
failed_threads: int = 0
skipped_ranges: int = 0
message: str = ""
error: str | None = None
created_at: str
updated_at: str
async def _require_admin(request: Request) -> None:
if is_auth_disabled():
return
user = await get_current_user_from_request(request)
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Checkpoint migration requires admin role")
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_dt(value: str) -> datetime:
try:
normalized = value.replace("Z", "+00:00")
dt = datetime.fromisoformat(normalized)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid datetime: {value}") from exc
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _sqlite_datetime(dt: datetime) -> str:
# Existing SQLite rows store naive wall-clock strings like
# "2026-05-18 06:41:10.989427"; match that format for comparisons.
return dt.astimezone(timezone.utc).replace(tzinfo=None).isoformat(sep=" ")
def _configured_sources(request: Request) -> list[str]:
config = getattr(request.app.state, "config", None)
cp = getattr(config, "checkpointer", None)
paths = list(getattr(cp, "dual_read_sqlite_paths", []) or [])
raw_env = os.getenv("DEER_FLOW_CHECKPOINT_SQLITE_PATHS", "")
paths.extend(part.strip() for part in raw_env.split(",") if part.strip())
resolved: list[str] = []
seen: set[str] = set()
for path in paths:
conn_str = resolve_sqlite_conn_str(path)
if conn_str == ":memory:" or conn_str.startswith("file:"):
continue
if conn_str not in seen:
resolved.append(conn_str)
seen.add(conn_str)
return resolved
def _target_checkpointer(request: Request):
checkpointer = getattr(request.app.state, "checkpointer", None)
if checkpointer is None:
raise HTTPException(status_code=503, detail="Checkpointer is not available")
return getattr(checkpointer, "primary", checkpointer)
def _state_path() -> Path:
return runtime_home() / "checkpoint_migration_state.json"
def _load_completed_ranges() -> set[str]:
try:
payload = json.loads(_state_path().read_text(encoding="utf-8"))
except FileNotFoundError:
return set()
except Exception:
logger.warning("Ignoring unreadable checkpoint migration state file", exc_info=True)
return set()
return {str(item) for item in payload.get("completed_ranges", [])}
def _save_completed_ranges(items: set[str]) -> None:
path = _state_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps({"completed_ranges": sorted(items)}, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
def _range_key(source: str, start_at: str, end_at: str) -> str:
raw = json.dumps({"source": str(Path(source).resolve()), "start_at": start_at, "end_at": end_at}, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def _open_sqlite_source(path: str) -> SQLiteCheckpointSource:
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
ctx = AsyncSqliteSaver.from_conn_string(path)
saver = await ctx.__aenter__()
await saver.setup()
return SQLiteCheckpointSource(
path=path,
saver=saver,
context=ctx,
progress_path=Path(path).with_suffix(Path(path).suffix + ".pg-migration.json"),
)
def _task_response(task: dict[str, Any]) -> MigrationTaskResponse:
return MigrationTaskResponse(**task)
async def _run_task(task_id: str, target, start_at: datetime, end_at: datetime) -> None:
task = _TASKS[task_id]
completed = await asyncio.to_thread(_load_completed_ranges)
start_sql = _sqlite_datetime(start_at)
end_sql = _sqlite_datetime(end_at)
range_start = start_at.isoformat()
range_end = end_at.isoformat()
try:
task["status"] = "running"
task["updated_at"] = _now_iso()
for source_path in task["source_paths"]:
if not Path(source_path).exists():
task["message"] = f"SQLite source not found: {source_path}"
task["skipped_ranges"] += 1
continue
key = _range_key(source_path, range_start, range_end)
if key in completed:
task["skipped_ranges"] += 1
task["message"] = "Selected range was already migrated; skipped"
task["updated_at"] = _now_iso()
continue
thread_ids = await asyncio.to_thread(sqlite_recent_threads, source_path, since=start_sql, until=end_sql)
if not thread_ids:
task["message"] = f"No threads found in selected range for {Path(source_path).name}"
completed.add(key)
await asyncio.to_thread(_save_completed_ranges, completed)
task["updated_at"] = _now_iso()
continue
task["total_threads"] += len(thread_ids)
source = await _open_sqlite_source(source_path)
try:
for thread_id in thread_ids:
if await target_has_thread(target, thread_id):
task["skipped_threads"] += 1
else:
copied = await copy_thread_state(source.saver, target, thread_id)
if copied:
task["copied_threads"] += 1
else:
task["skipped_threads"] += 1
task["updated_at"] = _now_iso()
await asyncio.sleep(0)
finally:
with contextlib.suppress(Exception):
await source.close()
completed.add(key)
await asyncio.to_thread(_save_completed_ranges, completed)
task["status"] = "completed"
task["message"] = task["message"] or "Migration completed"
except Exception as exc:
logger.exception("Checkpoint migration task failed: %s", task_id)
task["status"] = "failed"
task["error"] = str(exc)
finally:
task["updated_at"] = _now_iso()
@router.post("/tasks", response_model=MigrationTaskResponse)
async def start_migration(body: MigrationStartRequest, request: Request):
await _require_admin(request)
start_at = _parse_dt(body.start_at)
end_at = _parse_dt(body.end_at)
if end_at <= start_at:
raise HTTPException(status_code=400, detail="end_at must be later than start_at")
sources = [resolve_sqlite_conn_str(p) for p in (body.source_paths or _configured_sources(request))]
sources = [p for p in dict.fromkeys(sources) if p != ":memory:" and not p.startswith("file:")]
if not sources:
raise HTTPException(status_code=400, detail="No SQLite source paths configured")
target = _target_checkpointer(request)
task_id = hashlib.sha256(f"{_now_iso()}:{sources}:{body.start_at}:{body.end_at}".encode("utf-8")).hexdigest()[:16]
now = _now_iso()
_TASKS[task_id] = {
"task_id": task_id,
"status": "queued",
"start_at": start_at.isoformat(),
"end_at": end_at.isoformat(),
"source_paths": sources,
"total_threads": 0,
"copied_threads": 0,
"skipped_threads": 0,
"failed_threads": 0,
"skipped_ranges": 0,
"message": "",
"error": None,
"created_at": now,
"updated_at": now,
}
asyncio.create_task(_run_task(task_id, target, start_at, end_at))
return _task_response(_TASKS[task_id])
@router.get("/tasks/{task_id}", response_model=MigrationTaskResponse)
async def get_migration_task(task_id: str, request: Request):
await _require_admin(request)
task = _TASKS.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Migration task not found")
return _task_response(task)
@router.get("/sources")
async def list_sources(request: Request):
await _require_admin(request)
return {"source_paths": _configured_sources(request)}
@@ -121,6 +121,26 @@ async def _resolve_user_name_to_id(name: str | None) -> str | None:
return str(user.id) if user is not None else None
async def _resolve_user_names_to_ids(name: str | None) -> list[str]:
"""Fuzzy-resolve a username fragment to every matching account's UUID.
Powers the 按用户名模糊搜索 filter: typing part of a username matches all
users whose email local part contains it. Returns an empty list when the
fragment matches nobody, so the caller can short-circuit to zero rows.
"""
if not name or not name.strip():
return []
try:
provider = get_local_provider()
except Exception:
return []
try:
users = await provider.find_users_by_email_local_part_like(name.strip())
except Exception:
return []
return [str(u.id) for u in users]
async def _attach_usernames(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Resolve each row's ``user_id`` UUID to a friendly ``user_name``.
@@ -161,9 +181,11 @@ def _build_query(
until: str | None,
limit: int,
offset: int,
user_ids: list[str] | None = None,
) -> LlmMetricsQuery:
return LlmMetricsQuery(
user_id=user_id or None,
user_ids=user_ids or None,
model_name=model_name or None,
status=status or None,
since=_parse_dt(since),
@@ -187,14 +209,15 @@ async def list_llm_metrics(
) -> LlmMetricsListResponse:
await _require_admin(request)
store = _require_store(request)
user_ids: list[str] | None = None
if not user_id and user_name:
resolved = await _resolve_user_name_to_id(user_name)
if resolved is None:
# Username unknown → no rows; short-circuit instead of a global scan.
user_ids = await _resolve_user_names_to_ids(user_name)
if not user_ids:
# No username matches → no rows; short-circuit the global scan.
return LlmMetricsListResponse(items=[], total=0, limit=limit, offset=offset)
user_id = resolved
query = _build_query(
user_id=user_id,
user_ids=user_ids,
model_name=model_name,
status=status,
since=since,
@@ -236,14 +259,13 @@ async def export_llm_metrics(
"""Stream the full filtered metric set as an ``.xlsx`` file."""
await _require_admin(request)
store = _require_store(request)
user_ids: list[str] | None = None
if not user_id and user_name:
resolved = await _resolve_user_name_to_id(user_name)
if resolved is None:
# No matching user — return an empty xlsx so the user still gets a
# file (matching the list view's empty state).
user_id = "__no_such_user__"
else:
user_id = resolved
user_ids = await _resolve_user_names_to_ids(user_name)
if not user_ids:
# No matching user — sentinel id that matches nothing, so the export
# still produces an empty xlsx (matching the list view's empty state).
user_ids = ["__no_such_user__"]
try:
from openpyxl import Workbook
@@ -252,6 +274,7 @@ async def export_llm_metrics(
query = _build_query(
user_id=user_id,
user_ids=user_ids,
model_name=model_name,
status=status,
since=since,
@@ -7,7 +7,7 @@ import re
import subprocess
import sys
import tempfile
from datetime import datetime
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -119,8 +119,10 @@ class SkillResponse(BaseModel):
view_count: int = Field(default=0, description="Number of times the skill has been viewed")
patch_count: int = Field(default=0, description="Number of times the skill has been patched")
last_activity_at: datetime | None = Field(default=None, description="Most recent activity timestamp")
created_at: datetime | None = Field(default=None, description="Skill creation timestamp when known")
state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
featured: bool = Field(default=False, description="Whether this skill is pinned to the top of the list (sort only)")
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
@@ -241,8 +243,20 @@ def _skill_to_response(
from deerflow.skills.usage import get_entry, latest_activity_at
entry = get_entry(skill.name)
created_at_str = entry.get("created_at")
last_at_str = latest_activity_at(skill.name)
created_at: datetime | None = None
last_at: datetime | None = None
if created_at_str:
try:
created_at = datetime.fromisoformat(created_at_str)
except Exception:
pass
if created_at is None:
try:
created_at = datetime.fromtimestamp(skill.skill_file.stat().st_mtime, tz=UTC)
except Exception:
pass
if last_at_str:
try:
last_at = datetime.fromisoformat(last_at_str)
@@ -278,8 +292,10 @@ def _skill_to_response(
view_count=entry.get("view_count", 0),
patch_count=entry.get("patch_count", 0),
last_activity_at=last_at,
created_at=created_at,
state=entry.get("state", "active"),
pinned=entry.get("pinned", False),
featured=entry.get("featured", False),
source=entry.get("source", "upload"),
owner_user_id=owner_user_id,
published=published,
@@ -688,6 +704,17 @@ async def _attach_and_filter_tags(
return responses
def _sort_skill_responses(responses: list[SkillResponse], sort: str) -> list[SkillResponse]:
reverse = sort not in {"time_asc", "activity_asc", "name_asc", "usage_asc"}
if sort in {"name_asc", "name_desc"}:
return sorted(responses, key=lambda r: (r.name or "").lower(), reverse=reverse)
if sort in {"usage_desc", "usage_asc"}:
return sorted(responses, key=lambda r: int(r.use_count or 0), reverse=reverse)
if sort in {"activity_desc", "activity_asc"}:
return sorted(responses, key=lambda r: str(r.last_activity_at or r.created_at or ""), reverse=reverse)
return sorted(responses, key=lambda r: str(r.created_at or r.last_activity_at or ""), reverse=reverse)
@router.get(
"/skills",
response_model=SkillsListResponse,
@@ -701,6 +728,11 @@ async def list_skills(
tag_store: TagStore = Depends(get_tag_store),
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name"),
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
sort: str = Query(
default="time_desc",
pattern="^(time_desc|time_asc|activity_desc|activity_asc|usage_desc|usage_asc|name_asc|name_desc)$",
description="Sort order. Default is newest created/modified first.",
),
admin: bool = Query(
default=False,
description=(
@@ -754,6 +786,7 @@ async def list_skills(
)
)
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
responses = _sort_skill_responses(responses, sort)
return SkillsListResponse(skills=responses)
except HTTPException:
raise
@@ -1438,6 +1471,42 @@ async def update_custom_skill_pinned(
raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}")
class SkillFeaturedRequest(BaseModel):
"""Request body for pinning a custom skill to the top of the list."""
featured: bool = Field(..., description="Whether to pin the skill to the top of the list (sort only)")
@router.put(
"/skills/custom/{skill_name}/featured",
response_model=SkillResponse,
summary="Pin or Unpin a Custom Skill to Top",
description="Pin a skill to the top of the list (sort only; orthogonal to pinned/anti-archive). Only the owner or admins may invoke.",
)
async def update_custom_skill_featured(
skill_name: str,
body: SkillFeaturedRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
from deerflow.skills.usage import set_featured as _usage_set_featured
_usage_set_featured(skill_name, body.featured)
return _reload_skill_response(skill_name, record, config)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update featured for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update featured: {e}")
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
try:
@@ -10,7 +10,9 @@ from pydantic import BaseModel, Field
from app.gateway.auth.password import hash_password_async
from app.gateway.deps import get_optional_user_from_request
from deerflow.config import get_app_config
from deerflow.config.system_settings import (
LeaderboardSettings,
PromptPrefixSettings,
SystemSettings,
load_system_settings,
@@ -57,6 +59,22 @@ class CitationDisplayUpdateRequest(BaseModel):
cleanup_words: list[str] | None = None
class LeaderboardSettingsResponse(BaseModel):
exclude_user_ids: list[str] = Field(default_factory=list)
include_scheduled: bool = True
include_admins: bool = True
include_failed: bool = True
cleanup_system_questions: bool = True
class LeaderboardSettingsUpdateRequest(BaseModel):
exclude_user_ids: list[str] | None = None
include_scheduled: bool | None = None
include_admins: bool | None = None
include_failed: bool | None = None
cleanup_system_questions: bool | None = None
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
return PromptPrefixResponse(
enabled=settings.enabled,
@@ -91,6 +109,44 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
return _to_response(saved.prompt_prefix)
def _leaderboard_to_response(settings: LeaderboardSettings) -> LeaderboardSettingsResponse:
return LeaderboardSettingsResponse(
exclude_user_ids=list(dict.fromkeys([uid for uid in settings.exclude_user_ids if uid])),
include_scheduled=settings.include_scheduled,
include_admins=settings.include_admins,
include_failed=settings.include_failed,
cleanup_system_questions=settings.cleanup_system_questions,
)
@router.get("/leaderboard", response_model=LeaderboardSettingsResponse)
async def get_leaderboard_settings(request: Request) -> LeaderboardSettingsResponse:
"""Return global leaderboard aggregation settings. Admin only."""
await _require_admin(request)
return _leaderboard_to_response(load_system_settings().leaderboard)
@router.put("/leaderboard", response_model=LeaderboardSettingsResponse)
async def update_leaderboard_settings(
body: LeaderboardSettingsUpdateRequest,
request: Request,
) -> LeaderboardSettingsResponse:
"""Update global leaderboard aggregation settings. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "exclude_user_ids" in fields and fields["exclude_user_ids"] is not None:
update["exclude_user_ids"] = list(dict.fromkeys([str(uid).strip() for uid in fields["exclude_user_ids"] if str(uid).strip()]))
for key in ("include_scheduled", "include_admins", "include_failed", "cleanup_system_questions"):
if key in fields and fields[key] is not None:
update[key] = bool(fields[key])
leaderboard = current.leaderboard.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"leaderboard": leaderboard}))
return _leaderboard_to_response(saved.leaderboard)
@router.get("/citation-display", response_model=CitationDisplayResponse)
async def get_citation_display() -> CitationDisplayResponse:
"""Return global citation display cleanup settings."""
@@ -167,17 +223,28 @@ class AppearanceResponse(BaseModel):
"""Effective system-wide appearance defaults."""
default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
class AppearanceUpdateRequest(BaseModel):
default_theme: Literal["light", "dark"] | None = Field(default=None)
default_skill_view: Literal["card", "list"] | None = Field(default=None)
default_agent_view: Literal["card", "list"] | None = Field(default=None)
def _appearance_response(appearance) -> AppearanceResponse:
return AppearanceResponse(
default_theme=appearance.default_theme,
default_skill_view=appearance.default_skill_view,
default_agent_view=appearance.default_agent_view,
)
@router.get("/appearance", response_model=AppearanceResponse)
async def get_appearance_settings() -> AppearanceResponse:
"""Return the global default theme. Any logged-in user may read this."""
settings = load_system_settings().appearance
return AppearanceResponse(default_theme=settings.default_theme)
"""Return the global appearance defaults. Any logged-in user may read this."""
return _appearance_response(load_system_settings().appearance)
@router.put("/appearance", response_model=AppearanceResponse)
@@ -190,8 +257,9 @@ async def update_appearance_settings(
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "default_theme" in fields and fields["default_theme"] is not None:
update["default_theme"] = fields["default_theme"]
for key in ("default_theme", "default_skill_view", "default_agent_view"):
if key in fields and fields[key] is not None:
update[key] = fields[key]
if update:
updated_appearance = current.appearance.model_copy(update=update)
saved = save_system_settings(
@@ -199,4 +267,24 @@ async def update_appearance_settings(
)
else:
saved = current
return AppearanceResponse(default_theme=saved.appearance.default_theme)
return _appearance_response(saved.appearance)
class LogSystemResponse(BaseModel):
"""External log-system entry shown in the admin settings menu.
Sourced from ``config.yaml`` (``log_system`` section), so it is read-only
here — there is no PUT counterpart.
"""
enabled: bool = Field(..., description="Whether the entry is shown in the settings menu")
url: str = Field(..., description="External log-system URL opened in a new tab")
label: str = Field(..., description="Menu label shown for the entry")
@router.get("/log-system", response_model=LogSystemResponse)
async def get_log_system(request: Request) -> LogSystemResponse:
"""Return the admin-configured external log-system link from config.yaml. Admin only."""
await _require_admin(request)
cfg = get_app_config().log_system
return LogSystemResponse(enabled=cfg.enabled, url=cfg.url, label=cfg.label)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,66 @@
"""Per-user UI preferences (account-bound, follow the user across devices).
Currently exposes a single toggle: ``hide_admin_prompt_prefix`` — when true the
chat input hides the admin-configured prompt-prefix switch so the user only uses
their own prompts. Kept as a small key-value (JSON) store so new toggles do not
each need a schema change.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/user-preferences", tags=["user-preferences"])
class UserPreferencesResponse(BaseModel):
hide_admin_prompt_prefix: bool = False
class UserPreferencesUpdateRequest(BaseModel):
hide_admin_prompt_prefix: bool | None = Field(default=None)
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "user_preferences_store", None)
if store is None:
raise HTTPException(status_code=503, detail="User preferences store not available")
return store
def _to_response(prefs: dict) -> UserPreferencesResponse:
return UserPreferencesResponse(
hide_admin_prompt_prefix=bool(prefs.get("hide_admin_prompt_prefix", False)),
)
@router.get("", response_model=UserPreferencesResponse)
async def get_user_preferences(request: Request) -> UserPreferencesResponse:
store = _get_store(request)
user_id = _current_user_id(request)
prefs = await store.get_for_user(user_id)
return _to_response(prefs)
@router.put("", response_model=UserPreferencesResponse)
async def update_user_preferences(request: Request, body: UserPreferencesUpdateRequest) -> UserPreferencesResponse:
store = _get_store(request)
user_id = _current_user_id(request)
patch = body.model_dump(exclude_unset=True, exclude_none=True)
if not patch:
return _to_response(await store.get_for_user(user_id))
prefs = await store.merge_for_user(user_id, patch)
return _to_response(prefs)
@@ -24,6 +24,8 @@ class UserPromptResponse(BaseModel):
title: str = ""
content: str
sort_order: int = 0
# Optional single-agent association. NULL = global (shown for every agent).
agent_id: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -36,12 +38,16 @@ class UserPromptCreateRequest(BaseModel):
title: str = Field(default="", max_length=255)
content: str = Field(..., min_length=1)
sort_order: int = Field(default=0)
agent_id: str | None = Field(default=None, max_length=64)
class UserPromptUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=255)
content: str | None = Field(default=None, min_length=1)
sort_order: int | None = None
# Sent on every save; an empty string / null clears the association. Use a
# sentinel so "field omitted" (don't touch) differs from "set to null".
agent_id: str | None = Field(default=None, max_length=64)
def _current_user_id(request: Request) -> str:
@@ -77,6 +83,7 @@ async def create_user_prompt(request: Request, body: UserPromptCreateRequest) ->
"title": (body.title or "").strip(),
"content": body.content,
"sort_order": body.sort_order,
"agent_id": (body.agent_id or "").strip() or None,
},
)
return UserPromptResponse(**row)
@@ -93,6 +100,11 @@ async def update_user_prompt(request: Request, prompt_id: str, body: UserPromptU
data["content"] = body.content
if body.sort_order is not None:
data["sort_order"] = body.sort_order
# ``agent_id`` is nullable: only act when the caller actually sent the field
# (``model_fields_set``), so an explicit null clears the association while an
# omitted field leaves it untouched.
if "agent_id" in body.model_fields_set:
data["agent_id"] = (body.agent_id or "").strip() or None
row = await store.update(prompt_id, user_id, data)
if row is None:
raise HTTPException(status_code=404, detail="Prompt not found")
+19 -2
View File
@@ -207,6 +207,10 @@ auth_login:
verify_ssl: false
ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
database:
backend: sqlite
sqlite_dir: .deer-flow/data
@@ -214,8 +218,16 @@ database:
echo_sql: false
checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
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
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
@@ -292,3 +304,8 @@ ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
log_system:
enabled: true
url: "https://your-log-system.example.com"
label: "日志系统"
@@ -1,8 +1,10 @@
"""Middleware to inject uploaded files information into agent context."""
import logging
from pathlib import Path
from typing import NotRequired, override
import logging
import shutil
from pathlib import Path
from typing import NotRequired, override
from urllib.parse import unquote
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
@@ -13,7 +15,7 @@ from deerflow.config.paths import Paths, get_paths
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.file_conversion import extract_outline
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
_OUTLINE_PREVIEW_LINES = 5
@@ -22,7 +24,8 @@ _OUTLINE_PREVIEW_LINES = 5
# configured vision model), never by reading their bytes. Listing them with the
# generic read_file/grep guidance made the agent try to parse image binaries
# with bash/python, which fails and wastes turns.
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
_ARTIFACT_URL_MARKER = "/artifacts/"
def _extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]:
@@ -69,7 +72,7 @@ class UploadsMiddlewareState(AgentState):
uploaded_files: NotRequired[list[dict] | None]
class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
"""Middleware to inject uploaded files information into the agent context.
Reads file metadata from the current message's additional_kwargs.files
@@ -122,7 +125,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
lines.append(" Use `grep` to search for keywords (e.g. `grep(pattern='keyword', path='/mnt/user-data/uploads/')`).")
lines.append("")
def _create_files_message(self, new_files: list[dict], historical_files: list[dict]) -> str:
def _create_files_message(self, new_files: list[dict], historical_files: list[dict]) -> str:
"""Create a formatted message listing uploaded files.
Args:
@@ -160,9 +163,61 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
lines.append("- Only fall back to web search if the file content is clearly insufficient to answer the question.")
lines.append("</uploaded_files>")
return "\n".join(lines)
def _files_from_kwargs(self, message: HumanMessage, uploads_dir: Path | None = None) -> list[dict] | None:
return "\n".join(lines)
def _import_file_from_artifact_url(
self,
file_info: dict,
uploads_dir: Path | None,
) -> bool:
"""Copy a previously uploaded file into the current thread when possible.
iframe-style callers may pass file metadata that points at an upload
from another DeerFlow thread. The current run thread still needs a real
file under its own ``/mnt/user-data/uploads`` directory, otherwise this
middleware will filter the entry out during existence checks.
"""
filename = file_info.get("filename") or ""
artifact_url = str(file_info.get("artifact_url") or "").strip()
if not filename or uploads_dir is None or not artifact_url:
return False
if not artifact_url.startswith("/api/threads/") or _ARTIFACT_URL_MARKER not in artifact_url:
return False
prefix, encoded_virtual_path = artifact_url.split(_ARTIFACT_URL_MARKER, 1)
source_thread_id = prefix.removeprefix("/api/threads/").strip("/")
if not source_thread_id or not encoded_virtual_path:
return False
virtual_path = "/" + unquote(encoded_virtual_path.lstrip("/"))
if Path(virtual_path).name != filename:
return False
try:
source_path = self._paths.resolve_virtual_path(
source_thread_id,
virtual_path,
user_id=get_effective_user_id(),
)
except ValueError:
logger.debug("Failed to resolve uploaded artifact path: %s", artifact_url, exc_info=True)
return False
if not source_path.is_file():
return False
uploads_dir.mkdir(parents=True, exist_ok=True)
target_path = uploads_dir / filename
shutil.copy2(source_path, target_path)
source_markdown = source_path.with_suffix(".md")
if source_markdown.is_file() and source_markdown != source_path:
shutil.copy2(source_markdown, target_path.with_suffix(".md"))
return True
def _files_from_kwargs(self, message: HumanMessage, uploads_dir: Path | None = None) -> list[dict] | None:
"""Extract file info from message additional_kwargs.files.
The frontend sends uploaded file metadata in additional_kwargs.files
@@ -183,16 +238,17 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
files = []
for f in kwargs_files:
if not isinstance(f, dict):
continue
filename = f.get("filename") or ""
if not filename or Path(filename).name != filename:
continue
if uploads_dir is not None and not (uploads_dir / filename).is_file():
continue
files.append(
{
"filename": filename,
if not isinstance(f, dict):
continue
filename = f.get("filename") or ""
if not filename or Path(filename).name != filename:
continue
if uploads_dir is not None and not (uploads_dir / filename).is_file():
if not self._import_file_from_artifact_url(f, uploads_dir):
continue
files.append(
{
"filename": filename,
"size": int(f.get("size") or 0),
"path": f"/mnt/user-data/uploads/{filename}",
"extension": Path(filename).suffix,
@@ -243,6 +299,17 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
# Get newly uploaded files from the current message's additional_kwargs.files
new_files = self._files_from_kwargs(last_message, uploads_dir) or []
# Only inject when the *current* message actually carries new uploads.
# Turns without a fresh upload must not re-inject previously uploaded
# files: the original upload turn already injected (and persisted) their
# <uploaded_files> block, and the files remain physically available for
# read_file/grep/view_image. Re-injecting on every later message made the
# agent re-reference old images/docs, and — since the frontend parses
# files out of the <uploaded_files> content when additional_kwargs.files
# is empty — re-rendered the old attachment on each new question.
if not new_files:
return None
# Collect historical files from the uploads directory (all except the new ones)
new_filenames = {f["filename"] for f in new_files}
historical_files: list[dict] = []
@@ -270,9 +337,6 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
file["outline"] = outline
file["outline_preview"] = preview
if not new_files and not historical_files:
return None
logger.debug(f"New files: {[f['filename'] for f in new_files]}, historical: {[f['filename'] for f in historical_files]}")
# Create files message and prepend to the last human message content
@@ -0,0 +1,92 @@
"""Server-side mirror of the frontend agent-name desensitization map.
The web UI displays agent names through a static term→abbreviation map
(``frontend-web/src/lib/desensitize.ts``) — e.g. the stored name "态势分析" is
shown as "TS分析". The gallery search box therefore matches against the
*desensitized* display form, not the raw stored name.
When search moves server-side (paginated listing), the backend must match the
same way, so this module mirrors that static map verbatim. The frontend map is
never extended at runtime (``addDesensitizeEntries`` is defined but never
called), so a static copy stays in sync.
Used by the SQL listing query to match "raw name OR desensitized name".
"""
from __future__ import annotations
# Mirror of DESENSITIZE_MAP in frontend-web/src/lib/desensitize.ts.
# Keep entries identical to the frontend so server-side and client-side search
# behave the same. Order does not affect membership tests.
DESENSITIZE_MAP: dict[str, str] = {
"态势": "TS",
"你好": "nh",
"滨海作战体系": "第一张网",
"滨海作战部队": "第一张网",
"Taiwan Air Force Flight Units": "第二张网",
"台海空军飞行部队": "第二张网",
"空军": "KJ",
"海军": "HJ",
"陆军": "LJ",
"陆战队": "LZD",
"海巡": "HX",
"憲兵": "XB",
"資通電軍": "ZTDJ",
"情报": "QB",
"指挥": "ZH",
"信息准备": "XX准备",
"行为体": "XWT",
"驱动因素": "QDYS",
"叙事": "XS",
"前三定": "QSD",
"受众目标": "SZMB",
"作战": "ZZ",
"目的": "MD",
"关键传播节点": "GJCBJD",
"战法": "ZF",
"行动": "XD",
"打击": "DJ",
"目标": "MB",
"战力": "ZL",
"筹划": "COH",
"策划": "CEH",
"模拟": "MN",
"推演": "TY",
"认知": "RZ",
"触及": "CJ",
"军": "J",
"美军": "MJ",
"台军": "TJ",
}
def desensitize_name(name: str) -> str:
"""Return ``name`` with every mapped term replaced by its abbreviation.
Mirrors the frontend ``desensitize()`` — a sequence of literal replacements.
"""
out = name
for term, abbr in DESENSITIZE_MAP.items():
if term in out:
out = out.replace(term, abbr)
return out
def name_matches(name: str, needle: str) -> bool:
"""True when ``needle`` (case-insensitive) is in the raw OR desensitized name."""
n = needle.strip().lower()
if not n:
return True
return n in name.lower() or n in desensitize_name(name).lower()
def source_terms_for_needle(needle: str) -> list[str]:
"""Return the raw source terms whose abbreviation contains ``needle``.
Lets the SQL search add ``name LIKE %term%`` clauses so a user typing an
abbreviation (e.g. "TS") still matches the stored raw term (e.g. "态势").
"""
n = needle.strip().lower()
if not n:
return []
return [term for term, abbr in DESENSITIZE_MAP.items() if n in abbr.lower()]
@@ -15,6 +15,7 @@ from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.log_system_config import LogSystemConfig
from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig
@@ -108,6 +109,7 @@ class AppConfig(BaseModel):
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
vision: VisionConfig = Field(default_factory=VisionConfig, description="Image recognition (识图) configuration")
auth_login: AuthLoginConfig = Field(default_factory=AuthLoginConfig, description="Custom login modes (username password gate, token exchange)")
log_system: LogSystemConfig = Field(default_factory=LogSystemConfig, description="Admin-only external log-system entry shown in the settings menu")
sandbox: SandboxConfig = Field(description="Sandbox configuration")
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
@@ -2,7 +2,7 @@
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field
CheckpointerType = Literal["memory", "sqlite", "postgres"]
@@ -16,13 +16,41 @@ class CheckpointerConfig(BaseModel):
"'sqlite' persists to a local file (requires langgraph-checkpoint-sqlite). "
"'postgres' persists to PostgreSQL (requires langgraph-checkpoint-postgres)."
)
connection_string: str | None = Field(
default=None,
description="Connection string for sqlite (file path) or postgres (DSN). "
connection_string: str | None = Field(
default=None,
description="Connection string for sqlite (file path) or postgres (DSN). "
"Required for sqlite and postgres types. "
"For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. "
"For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.",
)
"For sqlite, use a file path like '.deer-flow/checkpoints.db' or ':memory:' for in-memory. "
"For postgres, use a DSN like 'postgresql://user:pass@localhost:5432/db'.",
)
use_postgres: bool = Field(
default=True,
description=(
"When type='postgres', set false to keep the Postgres DSN in config "
"but run the checkpointer on SQLite instead."
),
)
sqlite_connection_string: str | None = Field(
default=None,
description=(
"SQLite file used when type='postgres' and use_postgres=false. "
"Defaults to the first dual_read_sqlite_paths entry or 'store.db'."
),
)
dual_read_enabled: bool = Field(
default=False,
description=(
"When type='postgres', read from Postgres first and fall back to legacy "
"SQLite checkpoint files when Postgres does not have the requested data."
),
)
dual_read_sqlite_paths: list[str] = Field(
default_factory=list,
description=(
"Legacy SQLite checkpoint database paths used for read fallback. "
"Plain paths are resolved the same way as SQLite connection_string."
),
)
# Global configuration instance — None means no checkpointer is configured.
@@ -0,0 +1,18 @@
"""Configuration for the admin-only external log-system entry.
A small read-only section in ``config.yaml`` that lets a deployment point the
"日志系统" entry (shown in the bottom-right settings menu for admins) at an
external log/observability system. The URL is opened in a new browser tab.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class LogSystemConfig(BaseModel):
"""External log-system link surfaced in the admin settings menu."""
enabled: bool = Field(default=False, description="Whether the log-system entry is shown in the settings menu")
url: str = Field(default="", description="External log-system URL opened in a new tab when the entry is clicked")
label: str = Field(default="日志系统", description="Menu label shown for the entry")
@@ -51,6 +51,14 @@ class AppearanceSettings(BaseModel):
default="light",
description="Theme applied for users who haven't picked their own (light / dark)",
)
default_skill_view: Literal["card", "list"] = Field(
default="list",
description="Default skill list display mode for users who haven't picked their own (card / list)",
)
default_agent_view: Literal["card", "list"] = Field(
default="card",
description="Default agent list display mode for users who haven't picked their own (card / list)",
)
model_config = ConfigDict(extra="ignore")
@@ -78,6 +86,17 @@ class PrivateSquareSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class LeaderboardSettings(BaseModel):
"""Admin-configurable defaults for the user leaderboard analytics."""
exclude_user_ids: list[str] = Field(default_factory=list, description="User ids excluded from leaderboard aggregation.")
include_scheduled: bool = Field(default=True, description="Whether scheduled task runs participate in leaderboard statistics.")
include_admins: bool = Field(default=True, description="Whether admin accounts participate in leaderboard statistics.")
include_failed: bool = Field(default=True, description="Whether failed runs and provider calls participate in leaderboard statistics.")
cleanup_system_questions: bool = Field(default=True, description="Hide attachment/system marker questions such as <uploaded_files>.")
model_config = ConfigDict(extra="ignore")
class SystemSettings(BaseModel):
"""Top-level system settings document."""
@@ -85,6 +104,7 @@ class SystemSettings(BaseModel):
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
model_config = ConfigDict(extra="ignore")
@@ -4,13 +4,20 @@ from __future__ import annotations
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.memory import MemoryAgentStore
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.agents.sql import AgentRepository
__all__ = [
"AgentExtrasRow",
"AgentFavoriteRow",
"AgentRepository",
"AgentRow",
"AgentSkillRow",
"AgentStore",
"MemoryAgentStore",
"make_agent_store",
@@ -118,6 +118,69 @@ class AgentStore(ABC):
"""
raise NotImplementedError
@abstractmethod
async def list_paginated(
self,
*,
user_id: str,
scope: str | None,
search: str | None,
tag_ids: list[str] | None,
page: int,
page_size: int,
admin: bool = False,
sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]:
"""Return one page of agents plus the total matching count.
``scope``:
- ``"mine"`` — owned by ``user_id`` OR favorited by ``user_id``.
- ``"square"`` — built-in or published, and not private-square.
- ``"private"``— flagged ``is_private=True`` (caller gates access).
- ``None`` — the default listing visibility (``list_visible``).
``admin=True`` bypasses the visibility/scope filter entirely (every
agent), but still honors ``search`` / ``tag_ids``. Caller authorizes.
``search`` matches the raw name OR its desensitized display form.
``tag_ids`` keeps agents carrying ANY of the tags (OR). Both filters,
the COUNT, and LIMIT/OFFSET all execute in SQL.
Returns ``(rows, total)`` where ``rows`` is at most ``page_size`` items
for the 1-based ``page``.
"""
raise NotImplementedError
# --- Listing cache (skills / tool_groups / model) ---
@abstractmethod
async def replace_skills(self, agent_id: str, skills: list[str] | None) -> None:
"""Replace the cached skill list for ``agent_id``.
``None`` and ``[]`` both clear the cache to "no skills" (mirrors the
tri-state semantics of ``AgentConfig.skills``).
"""
raise NotImplementedError
@abstractmethod
async def set_extras(self, agent_id: str, *, tool_groups: list[str] | None, model: str | None) -> None:
"""Upsert the cached ``tool_groups`` / ``model`` for ``agent_id``."""
raise NotImplementedError
@abstractmethod
async def get_extras_for(self, agent_ids: list[str]) -> dict[str, dict[str, Any]]:
"""Batch-fetch cached listing extras for ``agent_ids``.
Returns ``{agent_id: {"skills": list[str] | None, "tool_groups":
list[str] | None, "model": str | None}}``. An agent that was never
mirrored is absent from the mapping (caller treats it as unknown).
"""
raise NotImplementedError
@abstractmethod
async def delete_extras_for_agent(self, agent_id: str) -> None:
"""Drop the cached skills + extras rows for ``agent_id`` (on delete)."""
raise NotImplementedError
# --- Favorites ("收藏") ---
@abstractmethod
@@ -136,6 +199,28 @@ class AgentStore(ABC):
raise NotImplementedError
@abstractmethod
async def delete_favorites_for_agent(self, agent_id: str) -> None:
"""Drop every user's favorite of ``agent_id`` (called on agent delete)."""
raise NotImplementedError
async def delete_favorites_for_agent(self, agent_id: str) -> None:
"""Drop every user's favorite of ``agent_id`` (called on agent delete)."""
raise NotImplementedError
# --- Per-user pins ("置顶") ---
@abstractmethod
async def add_pin(self, user_id: str, agent_id: str) -> None:
"""Pin ``agent_id`` for ``user_id``. Idempotent."""
raise NotImplementedError
@abstractmethod
async def remove_pin(self, user_id: str, agent_id: str) -> bool:
"""Remove a per-user pin. Returns ``False`` when none existed."""
raise NotImplementedError
@abstractmethod
async def list_pin_ids(self, user_id: str) -> set[str]:
"""Return the set of agent ids pinned by ``user_id``."""
raise NotImplementedError
@abstractmethod
async def delete_pins_for_agent(self, agent_id: str) -> None:
"""Drop every user's pin of ``agent_id`` (called on agent delete)."""
raise NotImplementedError
@@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.config.agent_name_desensitize import name_matches
from deerflow.persistence.agents.base import AgentStore
@@ -20,8 +21,12 @@ def _copy(data: dict[str, Any]) -> dict[str, Any]:
class MemoryAgentStore(AgentStore):
def __init__(self) -> None:
self._agents: dict[str, dict[str, Any]] = {}
self._favorites: set[tuple[str, str]] = set()
self._agents: dict[str, dict[str, Any]] = {}
self._favorites: set[tuple[str, str]] = set()
self._pins: set[tuple[str, str]] = set()
# Listing cache mirrors (see AgentExtrasRow / AgentSkillRow):
self._skills: dict[str, list[str]] = {}
self._extras: dict[str, dict[str, Any]] = {}
@staticmethod
def _is_visible(agent: dict[str, Any], user_id: str) -> bool:
@@ -166,6 +171,96 @@ class MemoryAgentStore(AgentStore):
self._agents.pop(agent_id, None)
return True
# --- Paginated listing ---
def _matches_scope(self, agent: dict[str, Any], *, user_id: str, scope: str | None, admin: bool) -> bool:
if admin:
return True
if scope == "square":
return (agent.get("user_id") is None or bool(agent.get("published"))) and not bool(agent.get("is_private"))
if scope == "mine":
owned = agent.get("user_id") is not None and agent.get("user_id") == user_id
favorited = (user_id, agent["id"]) in self._favorites
return owned or favorited
if scope == "private":
return bool(agent.get("is_private"))
return self._is_visible(agent, user_id)
async def list_paginated(
self,
*,
user_id: str,
scope: str | None,
search: str | None,
tag_ids: list[str] | None,
page: int,
page_size: int,
admin: bool = False,
sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]:
# NOTE: the in-memory store has no tag-assignment data, so ``tag_ids``
# is ignored here. Tag-filter behavior is covered by the SQL repo tests.
matched = [
a
for a in self._agents.values()
if self._matches_scope(a, user_id=user_id, scope=scope, admin=admin)
and (not search or not search.strip() or name_matches(a.get("name") or "", search))
]
if sort == "time_asc":
matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or ""))
elif sort == "updated_desc":
matched.sort(
key=lambda a: (a.get("updated_at") or "", a.get("id") or ""),
reverse=True,
)
elif sort == "updated_asc":
matched.sort(key=lambda a: (a.get("updated_at") or "", a.get("id") or ""))
elif sort == "name_asc":
matched.sort(key=lambda a: ((a.get("name") or "").lower(), a.get("id") or ""))
elif sort == "name_desc":
matched.sort(
key=lambda a: ((a.get("name") or "").lower(), a.get("id") or ""),
reverse=True,
)
else:
matched.sort(
key=lambda a: (a.get("created_at") or "", a.get("id") or ""),
reverse=True,
)
pin_ids = {agent_id for uid, agent_id in self._pins if uid == user_id}
matched.sort(key=lambda a: (0 if a.get("id") in pin_ids else 1,))
total = len(matched)
start = max(page - 1, 0) * page_size
page_rows = matched[start : start + page_size]
return [_copy(a) for a in page_rows], total
# --- Listing cache (skills / tool_groups / model) ---
async def replace_skills(self, agent_id: str, skills: list[str] | None) -> None:
self._skills[agent_id] = [s for s in (skills or []) if isinstance(s, str)]
async def set_extras(self, agent_id: str, *, tool_groups: list[str] | None, model: str | None) -> None:
self._extras[agent_id] = {"tool_groups": tool_groups, "model": model}
async def get_extras_for(self, agent_ids: list[str]) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for agent_id in agent_ids:
has_extras = agent_id in self._extras
has_skills = agent_id in self._skills
if not has_extras and not has_skills:
continue
extras = self._extras.get(agent_id, {})
out[agent_id] = {
"skills": list(self._skills.get(agent_id, [])),
"tool_groups": extras.get("tool_groups"),
"model": extras.get("model"),
}
return out
async def delete_extras_for_agent(self, agent_id: str) -> None:
self._skills.pop(agent_id, None)
self._extras.pop(agent_id, None)
# --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None:
@@ -180,5 +275,23 @@ class MemoryAgentStore(AgentStore):
async def list_favorite_ids(self, user_id: str) -> set[str]:
return {agent_id for (uid, agent_id) in self._favorites if uid == user_id}
async def delete_favorites_for_agent(self, agent_id: str) -> None:
self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id}
async def delete_favorites_for_agent(self, agent_id: str) -> None:
self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id}
# --- Pins ("置顶") ---
async def add_pin(self, user_id: str, agent_id: str) -> None:
self._pins.add((user_id, agent_id))
async def remove_pin(self, user_id: str, agent_id: str) -> bool:
key = (user_id, agent_id)
if key not in self._pins:
return False
self._pins.remove(key)
return True
async def list_pin_ids(self, user_id: str) -> set[str]:
return {agent_id for uid, agent_id in self._pins if uid == user_id}
async def delete_pins_for_agent(self, agent_id: str) -> None:
self._pins = {(uid, aid) for (uid, aid) in self._pins if aid != agent_id}
@@ -4,11 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import BigInteger, Boolean, String, UniqueConstraint
from sqlalchemy import BigInteger, Boolean, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class AgentRow(Base):
@@ -37,7 +37,7 @@ class AgentRow(Base):
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class AgentFavoriteRow(Base):
class AgentFavoriteRow(Base):
"""A user's bookmark of a (typically public-square) agent.
Lets a user collect agents from the 广场 (square) into their "我的" tab
@@ -51,4 +51,60 @@ class AgentFavoriteRow(Base):
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
class AgentPinRow(Base):
"""A per-user pin that keeps an agent near the top of that user's lists."""
__tablename__ = "agent_pins"
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_pins_user_agent"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
class AgentSkillRow(Base):
"""Denormalized cache of a custom agent's selected skills.
``config.yaml`` under ``.deer-flow/agents/{id}/`` stays the source of truth
for the agent runtime (the lead-agent prompt reads it directly). This table
is a read cache so the gallery list endpoint can render skill badges with a
single batched ``IN`` query instead of stat+YAML-parsing every agent's
``config.yaml`` on each request. Written through on create/update and
backfilled from disk by ``_sync_legacy_agents``.
One row per (agent, skill); ``position`` preserves the configured order.
"""
__tablename__ = "agent_skills"
__table_args__ = (UniqueConstraint("agent_id", "skill_name", name="uq_agent_skills_agent_skill"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
skill_name: Mapped[str] = mapped_column(String(191), nullable=False, index=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class AgentExtrasRow(Base):
"""Denormalized cache of a custom agent's ``tool_groups`` / ``model``.
Companion to :class:`AgentSkillRow` — same rationale (avoid per-agent
``config.yaml`` reads in the list path). One row per agent. The presence of
a row also marks "this agent has been mirrored": the list path treats a
missing row as "unknown / not yet backfilled" (``skills=None``) so it can
be told apart from an explicitly empty skill list.
``skills_synced_at`` records when the cache was last written from disk so
the backfill can skip agents whose ``config.yaml`` has not changed.
"""
__tablename__ = "agent_extras"
agent_id: Mapped[str] = mapped_column(String(64), primary_key=True)
# list[str] | None — the agent's tool_groups whitelist from config.yaml.
tool_groups: Mapped[list | None] = mapped_column(PortableJSON(), nullable=True, default=None)
model: Mapped[str | None] = mapped_column(String(191), nullable=True, default=None)
skills_synced_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -6,12 +6,20 @@ from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import and_, delete, func, or_, select
from sqlalchemy import and_, case, delete, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.config.agent_name_desensitize import source_terms_for_needle
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.agents.model import (
AgentExtrasRow,
AgentFavoriteRow,
AgentPinRow,
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.tags.model import TagAssignmentRow
def _row_to_dict(row: AgentRow) -> dict[str, Any]:
@@ -233,6 +241,167 @@ class AgentRepository(AgentStore):
await session.commit()
return True
# --- Paginated listing ---
def _scope_filter(self, *, user_id: str, scope: str | None, admin: bool):
"""Build the visibility predicate for a paginated listing."""
if admin:
return None # no visibility filter — every agent
if scope == "square":
return and_(
or_(AgentRow.user_id.is_(None), AgentRow.published.is_(True)),
AgentRow.is_private.is_(False),
)
if scope == "mine":
# owned OR favorited. A correlated IN-subquery (not a JOIN) keeps a
# single row per agent even when it is both owned and favorited.
fav_subq = select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq))
if scope == "private":
return AgentRow.is_private.is_(True)
return self._listing_filter(user_id)
@staticmethod
def _search_clause(search: str):
"""Match the raw name OR (via the desensitize mirror) its display form."""
needle = search.strip().lower()
clauses = [func.lower(AgentRow.name).like(f"%{needle}%")]
for term in source_terms_for_needle(needle):
clauses.append(AgentRow.name.like(f"%{term}%"))
return or_(*clauses)
@staticmethod
def _tag_clause(tag_ids: list[str]):
subq = select(TagAssignmentRow.target_id).where(
TagAssignmentRow.target_type == "agent",
TagAssignmentRow.tag_id.in_(tag_ids),
)
return AgentRow.id.in_(subq)
async def list_paginated(
self,
*,
user_id: str,
scope: str | None,
search: str | None,
tag_ids: list[str] | None,
page: int,
page_size: int,
admin: bool = False,
sort: str = "time_desc",
) -> tuple[list[dict[str, Any]], int]:
filters = []
scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin)
if scope_filter is not None:
filters.append(scope_filter)
if search and search.strip():
filters.append(self._search_clause(search))
if tag_ids:
filters.append(self._tag_clause(tag_ids))
count_stmt = select(func.count()).select_from(AgentRow)
pin_subq = select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id)
pin_rank = case((AgentRow.id.in_(pin_subq), 0), else_=1)
if sort == "time_asc":
order_by = (AgentRow.created_at.asc(), AgentRow.id.asc())
elif sort == "updated_desc":
order_by = (AgentRow.updated_at.desc(), AgentRow.id.asc())
elif sort == "updated_asc":
order_by = (AgentRow.updated_at.asc(), AgentRow.id.asc())
elif sort == "name_asc":
order_by = (func.lower(AgentRow.name).asc(), AgentRow.id.asc())
elif sort == "name_desc":
order_by = (func.lower(AgentRow.name).desc(), AgentRow.id.asc())
else:
order_by = (AgentRow.created_at.desc(), AgentRow.id.asc())
page_stmt = (
select(AgentRow)
.order_by(pin_rank.asc(), *order_by)
.limit(page_size)
.offset(max(page - 1, 0) * page_size)
)
if filters:
where = and_(*filters)
count_stmt = count_stmt.where(where)
page_stmt = page_stmt.where(where)
async with self._sf() as session:
total = await session.scalar(count_stmt) or 0
result = await session.execute(page_stmt)
rows = [_row_to_dict(row) for row in result.scalars()]
return rows, total
# --- Listing cache (skills / tool_groups / model) ---
async def replace_skills(self, agent_id: str, skills: list[str] | None) -> None:
async with self._sf() as session:
await session.execute(delete(AgentSkillRow).where(AgentSkillRow.agent_id == agent_id))
for position, name in enumerate(skills or []):
if not isinstance(name, str):
continue
session.add(
AgentSkillRow(
id=uuid4().hex,
agent_id=agent_id,
skill_name=name,
position=position,
)
)
await session.commit()
async def set_extras(self, agent_id: str, *, tool_groups: list[str] | None, model: str | None) -> None:
async with self._sf() as session:
row = await session.get(AgentExtrasRow, agent_id)
now = datetime.now(UTC)
if row is None:
session.add(
AgentExtrasRow(
agent_id=agent_id,
tool_groups=tool_groups,
model=model,
skills_synced_at=now,
)
)
else:
row.tool_groups = tool_groups
row.model = model
row.skills_synced_at = now
await session.commit()
async def get_extras_for(self, agent_ids: list[str]) -> dict[str, dict[str, Any]]:
if not agent_ids:
return {}
out: dict[str, dict[str, Any]] = {}
async with self._sf() as session:
extras = await session.execute(
select(AgentExtrasRow).where(AgentExtrasRow.agent_id.in_(agent_ids))
)
for row in extras.scalars():
out[row.agent_id] = {
"skills": [], # default; filled below if skill rows exist
"tool_groups": row.tool_groups,
"model": row.model,
}
skill_rows = await session.execute(
select(AgentSkillRow.agent_id, AgentSkillRow.skill_name)
.where(AgentSkillRow.agent_id.in_(agent_ids))
.order_by(AgentSkillRow.agent_id.asc(), AgentSkillRow.position.asc())
)
for agent_id, skill_name in skill_rows.all():
entry = out.setdefault(
agent_id, {"skills": [], "tool_groups": None, "model": None}
)
entry["skills"].append(skill_name)
return out
async def delete_extras_for_agent(self, agent_id: str) -> None:
async with self._sf() as session:
await session.execute(delete(AgentSkillRow).where(AgentSkillRow.agent_id == agent_id))
await session.execute(delete(AgentExtrasRow).where(AgentExtrasRow.agent_id == agent_id))
await session.commit()
# --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None:
@@ -272,7 +441,49 @@ class AgentRepository(AgentStore):
)
return {row[0] for row in result.all()}
async def delete_favorites_for_agent(self, agent_id: str) -> None:
async with self._sf() as session:
await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id))
await session.commit()
async def delete_favorites_for_agent(self, agent_id: str) -> None:
async with self._sf() as session:
await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id))
await session.commit()
# --- Pins ("置顶") ---
async def add_pin(self, user_id: str, agent_id: str) -> None:
async with self._sf() as session:
existing = (
await session.execute(
select(AgentPinRow.id)
.where(AgentPinRow.user_id == user_id, AgentPinRow.agent_id == agent_id)
.limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
session.add(AgentPinRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
try:
await session.commit()
except IntegrityError:
await session.rollback()
async def remove_pin(self, user_id: str, agent_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(
delete(AgentPinRow).where(
AgentPinRow.user_id == user_id,
AgentPinRow.agent_id == agent_id,
)
)
await session.commit()
return (result.rowcount or 0) > 0
async def list_pin_ids(self, user_id: str) -> set[str]:
async with self._sf() as session:
result = await session.execute(
select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id)
)
return {row[0] for row in result.all()}
async def delete_pins_for_agent(self, agent_id: str) -> None:
async with self._sf() as session:
await session.execute(delete(AgentPinRow).where(AgentPinRow.agent_id == agent_id))
await session.commit()
@@ -34,6 +34,9 @@ class LlmMetricsQuery:
"""Filter set for the admin list / export endpoints."""
user_id: str | None = None
# Multiple user ids — set when a fuzzy username search resolves to several
# accounts. Takes precedence over ``user_id`` when non-empty.
user_ids: list[str] | None = None
model_name: str | None = None
status: str | None = None
since: datetime | None = None
@@ -58,7 +58,9 @@ class SqlLlmMetricsStore(LlmMetricsStore):
logger.warning("Failed to persist llm_call_metrics row %s", metric.id, exc_info=True)
def _apply_filters(self, stmt, query: LlmMetricsQuery):
if query.user_id:
if query.user_ids:
stmt = stmt.where(LlmCallMetricRow.user_id.in_(query.user_ids))
elif query.user_id:
stmt = stmt.where(LlmCallMetricRow.user_id == query.user_id)
if query.model_name:
stmt = stmt.where(LlmCallMetricRow.model_name == query.model_name)
@@ -92,6 +94,7 @@ class SqlLlmMetricsStore(LlmMetricsStore):
while True:
page_query = LlmMetricsQuery(
user_id=query.user_id,
user_ids=query.user_ids,
model_name=query.model_name,
status=query.status,
since=query.since,
@@ -0,0 +1,45 @@
"""Add ``agent_id`` to ``user_custom_prompts`` for per-agent prompt scoping.
NULL = global (shown for every agent); otherwise the prompt only surfaces when
that agent is selected.
Revision ID: 20260603_02
Revises: 20260603_01
Create Date: 2026-06-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260603_02"
down_revision = "20260603_01"
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_column(table_name: str, column_name: str) -> bool:
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if not _has_table("user_custom_prompts"):
return
if not _has_column("user_custom_prompts", "agent_id"):
op.add_column("user_custom_prompts", sa.Column("agent_id", sa.String(64), nullable=True))
op.create_index("ix_user_custom_prompts_agent_id", "user_custom_prompts", ["agent_id"])
def downgrade() -> None:
if _has_table("user_custom_prompts") and _has_column("user_custom_prompts", "agent_id"):
op.drop_index("ix_user_custom_prompts_agent_id", table_name="user_custom_prompts")
op.drop_column("user_custom_prompts", "agent_id")
@@ -0,0 +1,42 @@
"""Add ``user_preferences`` for per-user UI toggles (account-bound).
Holds a single JSON blob per user — e.g. ``hide_admin_prompt_prefix`` so a user
can choose to use only their own prompts and hide the admin-configured prefix.
Revision ID: 20260603_03
Revises: 20260603_02
Create Date: 2026-06-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260603_03"
down_revision = "20260603_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 upgrade() -> None:
if not _has_table("user_preferences"):
op.create_table(
"user_preferences",
sa.Column("user_id", sa.String(64), primary_key=True),
sa.Column("preferences", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
if _has_table("user_preferences"):
op.drop_table("user_preferences")
@@ -0,0 +1,56 @@
"""Per-user pinned agents.
Revision ID: 20260604_01
Revises: 20260603_03
Create Date: 2026-06-04
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260604_01"
down_revision = "20260603_03"
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 upgrade() -> None:
if not _has_table("agent_pins"):
op.create_table(
"agent_pins",
sa.Column("id", sa.String(64), nullable=False),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("agent_id", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "agent_id", name="uq_agent_pins_user_agent"),
)
if not _has_index("agent_pins", "ix_agent_pins_user_id"):
op.create_index("ix_agent_pins_user_id", "agent_pins", ["user_id"])
if not _has_index("agent_pins", "ix_agent_pins_agent_id"):
op.create_index("ix_agent_pins_agent_id", "agent_pins", ["agent_id"])
def downgrade() -> None:
if _has_index("agent_pins", "ix_agent_pins_agent_id"):
op.drop_index("ix_agent_pins_agent_id", table_name="agent_pins")
if _has_index("agent_pins", "ix_agent_pins_user_id"):
op.drop_index("ix_agent_pins_user_id", table_name="agent_pins")
if _has_table("agent_pins"):
op.drop_table("agent_pins")
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more