Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
406c02ad07
122 files changed
+17849
-1694
No files matched your search
@@ -15,8 +15,11 @@ import { HashRouter, useLocation } from "react-router-dom";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import PageRoutes from "./pages/PageRoutes";
|
||||
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
|
||||
import PublicScheduledTaskPage, {
|
||||
PublicScheduledTaskFullscreenPage,
|
||||
} from "./pages/PublicScheduledTaskPage";
|
||||
import PublicSharePage from "./pages/PublicSharePage";
|
||||
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
|
||||
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
@@ -64,6 +67,8 @@ export function App() {
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
{/* Public read-only conversation viewer — no authentication. */}
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
{/* Public read-only user leaderboard publish page — no authentication. */}
|
||||
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
|
||||
{/* Public read-only roundtable share — landing + full result report. */}
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode"
|
||||
@@ -78,6 +83,10 @@ export function App() {
|
||||
path="/public/scheduled-tasks/:taskId"
|
||||
element={<PublicScheduledTaskPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId/runs/:runId/fullscreen"
|
||||
element={<PublicScheduledTaskFullscreenPage />}
|
||||
/>
|
||||
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
|
||||
<Route
|
||||
path="/embed/chats/:thread_id"
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
PinIcon,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
StarIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -73,6 +75,9 @@ interface AgentCardProps {
|
||||
/** "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";
|
||||
/** Click the 发布者 line to filter the gallery to that publisher. Receives the
|
||||
* owner user_id + its desensitized display label. */
|
||||
onPublisherClick?: (publisher: { id: string; label: string }) => void;
|
||||
}
|
||||
|
||||
interface BadgeItem {
|
||||
@@ -184,7 +189,7 @@ function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCardProps) {
|
||||
const isList = layout === "list";
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -207,6 +212,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||
agent.skills ?? [],
|
||||
);
|
||||
const [skillSearch, setSkillSearch] = useState("");
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
|
||||
// The /api/agents list response intentionally omits `soul` (perf optimization
|
||||
@@ -244,6 +250,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
setEditPublished(agent.published);
|
||||
setEditIsPrivate(agent.is_private);
|
||||
setSelectedSkills(agent.skills ?? []);
|
||||
setSkillSearch("");
|
||||
}, [
|
||||
agent.description,
|
||||
agent.is_private,
|
||||
@@ -436,7 +443,24 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
});
|
||||
}
|
||||
|
||||
const enabledSkills = skills.filter((skill) => skill.enabled);
|
||||
const enabledSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[skills],
|
||||
);
|
||||
|
||||
// Search over English name, Chinese name (name_zh) and description, so both
|
||||
// 中英文 work. Already-selected skills stay searchable too (selection is
|
||||
// independent of this filter).
|
||||
const filteredSkills = useMemo(() => {
|
||||
const q = skillSearch.trim().toLowerCase();
|
||||
if (!q) return enabledSkills;
|
||||
return enabledSkills.filter(
|
||||
(skill) =>
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [enabledSkills, skillSearch]);
|
||||
|
||||
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||
// both the card header and the compact list row.
|
||||
@@ -497,6 +521,47 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
</>
|
||||
);
|
||||
|
||||
// 发布者(仅自定义/已发布的智能体显示;内置无 owner 不显示)。点击可按发布者筛选。
|
||||
const publisherLabel = agent.owner_username
|
||||
? desensitize(agent.owner_username)
|
||||
: "";
|
||||
const canFilterByPublisher =
|
||||
!isBuiltin && !!agent.owner_username && !!agent.user_id && !!onPublisherClick;
|
||||
const publisherNode =
|
||||
!isBuiltin && agent.owner_username ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canFilterByPublisher}
|
||||
onClick={(e) => {
|
||||
if (!canFilterByPublisher) return;
|
||||
e.stopPropagation();
|
||||
onPublisherClick!({ id: agent.user_id!, label: publisherLabel });
|
||||
}}
|
||||
className={cn(
|
||||
"text-muted-foreground flex max-w-full items-center gap-1 text-sm font-medium",
|
||||
canFilterByPublisher &&
|
||||
"hover:text-primary cursor-pointer transition-colors hover:underline",
|
||||
)}
|
||||
title={
|
||||
canFilterByPublisher
|
||||
? `点击筛选发布者:${publisherLabel}`
|
||||
: `发布者:${publisherLabel}`
|
||||
}
|
||||
>
|
||||
<UserIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">发布者:{publisherLabel}</span>
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
|
||||
const skillNameZhMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const s of skills) {
|
||||
if (s.name_zh?.trim()) map.set(s.name, s.name_zh.trim());
|
||||
}
|
||||
return map;
|
||||
}, [skills]);
|
||||
|
||||
const skillBadgeItems = [
|
||||
...(agent.tool_groups ?? []).map((group) => ({
|
||||
key: `tg:${group}`,
|
||||
@@ -519,7 +584,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
setDetailSkillName(skill);
|
||||
}}
|
||||
>
|
||||
{desensitize(skill)}
|
||||
{skillNameZhMap.get(skill) ?? desensitize(skill)}
|
||||
</Badge>
|
||||
),
|
||||
})),
|
||||
@@ -585,15 +650,15 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
<SparklesIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<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"
|
||||
@@ -702,6 +767,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
{publisherNode && <div className="mt-2">{publisherNode}</div>}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
@@ -747,6 +813,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
{desensitize(agent.description)}
|
||||
</p>
|
||||
)}
|
||||
{publisherNode && <div className="mt-0.5">{publisherNode}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showActionRow && actionButtons}
|
||||
@@ -792,6 +859,17 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
已选 {selectedSkills.length} 个
|
||||
</span>
|
||||
</div>
|
||||
{enabledSkills.length > 0 && (
|
||||
<div className="relative">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="搜索技能(支持中英文名称、描述)…"
|
||||
value={skillSearch}
|
||||
onChange={(e) => setSkillSearch(e.currentTarget.value)}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
|
||||
{isSkillsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||
@@ -799,9 +877,11 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可用技能
|
||||
</p>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">没有匹配的技能</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{enabledSkills.map((skill) => {
|
||||
{filteredSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<div
|
||||
@@ -816,7 +896,11 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
title={desensitize(skill.description ?? "")}
|
||||
className="h-8 min-w-0 flex-1 rounded-r-none"
|
||||
>
|
||||
<span className="truncate">{desensitize(skill.name)}</span>
|
||||
<span className="truncate">
|
||||
{skill.name_zh?.trim()
|
||||
? skill.name_zh
|
||||
: desensitize(skill.name)}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -934,13 +1018,13 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="agent"
|
||||
targetId={agent.id}
|
||||
currentTags={agent.tags ?? []}
|
||||
/>
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="agent"
|
||||
targetId={agent.id}
|
||||
currentTags={agent.tags ?? []}
|
||||
/>
|
||||
|
||||
|
||||
<SkillDetailDialog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
|
||||
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -36,6 +36,11 @@ export function AgentGallery() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<AgentSort>("time_desc");
|
||||
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
||||
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||
null,
|
||||
);
|
||||
// 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 },
|
||||
@@ -48,7 +53,7 @@ export function AgentGallery() {
|
||||
const tagKey = tagIds.join(",");
|
||||
useEffect(() => {
|
||||
setPages({ all: 1, mine: 1, private: 1 });
|
||||
}, [search, tagKey, sort]);
|
||||
}, [search, tagKey, sort, publisher?.id]);
|
||||
|
||||
const { data: privateStatus } = usePrivateSquareStatus();
|
||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||
@@ -69,6 +74,7 @@ export function AgentGallery() {
|
||||
agents: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
isFetching: mainFetching,
|
||||
} = useAgentsPaginated({
|
||||
scope: mainScope,
|
||||
search,
|
||||
@@ -77,12 +83,14 @@ export function AgentGallery() {
|
||||
page: mainPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab !== "private",
|
||||
owner: publisher?.id,
|
||||
});
|
||||
|
||||
const {
|
||||
agents: privateAgents,
|
||||
total: privateTotal,
|
||||
isLoading: isPrivateLoading,
|
||||
isFetching: isPrivateFetching,
|
||||
error: privateError,
|
||||
} = usePrivateAgentsPaginated(privatePassword, {
|
||||
search,
|
||||
@@ -91,6 +99,7 @@ export function AgentGallery() {
|
||||
page: pages.private,
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab === "private",
|
||||
owner: publisher?.id,
|
||||
});
|
||||
|
||||
// If the server reports the stored password no longer works (likely the
|
||||
@@ -103,6 +112,12 @@ export function AgentGallery() {
|
||||
}
|
||||
}, [privateError, privatePassword]);
|
||||
|
||||
// The private tab is only "unlocked" once the feature is usable and a
|
||||
// password has been verified. Until then we show the gate (or the lock
|
||||
// prompt) and hide the keyword/tag search bar — there is no list to filter.
|
||||
const privateUnlocked = privateFeatureUsable && !!privatePassword;
|
||||
const showSearchBar = activeTab !== "private" || privateUnlocked;
|
||||
|
||||
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
|
||||
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
|
||||
setPages((prev) => ({ ...prev, [tab]: page }));
|
||||
@@ -127,17 +142,29 @@ export function AgentGallery() {
|
||||
setPrivatePassword(null);
|
||||
};
|
||||
|
||||
const handlePublisherClick = (p: { id: string; label: string }) =>
|
||||
setPublisher(p);
|
||||
|
||||
const renderGrid = (list: Agent[]) =>
|
||||
viewMode === "list" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{list.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} layout="list" />
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
layout="list"
|
||||
onPublisherClick={handlePublisherClick}
|
||||
/>
|
||||
))}
|
||||
</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} />
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onPublisherClick={handlePublisherClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -296,9 +323,16 @@ export function AgentGallery() {
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) =>
|
||||
setActiveTab(v as "all" | "mine" | "private")
|
||||
}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v as "all" | "mine" | "private");
|
||||
// Each tab starts from a clean slate: switching tabs clears the
|
||||
// keyword / tag / sort / publisher filters instead of carrying them
|
||||
// over. The TagSearchBar's own input box is reset via its key below.
|
||||
setSearch("");
|
||||
setTagIds([]);
|
||||
setSort("time_desc");
|
||||
setPublisher(null);
|
||||
}}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
@@ -331,31 +365,53 @@ export function AgentGallery() {
|
||||
)}
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
{showSearchBar && (
|
||||
<TagSearchBar
|
||||
// Remount per tab so the internal keyword input / 展开 state also
|
||||
// resets when the parent-owned filters are cleared on tab switch.
|
||||
key={activeTab}
|
||||
targetType="agent"
|
||||
searching={
|
||||
activeTab === "private" ? isPrivateFetching : mainFetching
|
||||
}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publisher && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPublisher(null)}
|
||||
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
|
||||
title="点击清除发布者筛选"
|
||||
>
|
||||
{publisher.label}
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Database } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
/**
|
||||
* Per-conversation toggle for knowledge-base RAG injection (phase 2).
|
||||
*
|
||||
* Writes ``context.knowledge_rag_enabled`` into the thread settings, which the
|
||||
* run forwards as ``runtime.context`` so ``KnowledgeRagMiddleware`` injects
|
||||
* recalled knowledge before each model call for this conversation only.
|
||||
*/
|
||||
export function KnowledgeRagToggle({ threadId }: { threadId: string }) {
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const enabled = settings.context.knowledge_rag_enabled === true;
|
||||
|
||||
const toggle = () => {
|
||||
setSettings("context", {
|
||||
...settings.context,
|
||||
knowledge_rag_enabled: !enabled,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content={enabled ? "知识库召回:已开启" : "知识库召回:已关闭"}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
enabled && "text-primary hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Database />
|
||||
知识库
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { BookMarked } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useKnowledgeActions } from "@/core/knowledge/hooks";
|
||||
|
||||
import { ModelNameSelect } from "./model-name-select";
|
||||
import { useThread } from "./messages/context";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export function KnowledgeSaveTrigger({ threadId }: { threadId: string }) {
|
||||
const { thread } = useThread();
|
||||
const { ingestThread } = useKnowledgeActions();
|
||||
// Optional model override for the extraction (null = configured default).
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
|
||||
if (thread.messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// One click → fire-and-forget. The backend sediments the thread into the
|
||||
// wiki asynchronously (title/tags auto-generated), so the click never blocks.
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await ingestThread.mutateAsync({
|
||||
threadId,
|
||||
payload: { background: true, model_name: model },
|
||||
});
|
||||
toast.success("已加入知识库保存队列,正在后台整理…");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ModelNameSelect
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={ingestThread.isPending}
|
||||
triggerClassName="h-8 w-[8.5rem]"
|
||||
/>
|
||||
<Tooltip content="保存到知识库(用所选大模型后台异步整理)">
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={ingestThread.isPending}
|
||||
>
|
||||
<BookMarked />
|
||||
{ingestThread.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Sentinel for "use the backend's default/configured model" (no override). */
|
||||
const DEFAULT_VALUE = "__default__";
|
||||
|
||||
/**
|
||||
* Compact dropdown to optionally pick an LLM model for a one-off action
|
||||
* (skill-name generation, knowledge ingestion, …). The value is the model
|
||||
* `name`; `null` means "use the configured default" (no override sent).
|
||||
*/
|
||||
export function ModelNameSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
triggerClassName,
|
||||
defaultLabel = "默认模型",
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (modelName: string | null) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
defaultLabel?: string;
|
||||
}) {
|
||||
const { models } = useModels();
|
||||
return (
|
||||
<Select
|
||||
value={value ?? DEFAULT_VALUE}
|
||||
onValueChange={(v) => onChange(v === DEFAULT_VALUE ? null : v)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
||||
>
|
||||
<SelectValue placeholder={defaultLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value={DEFAULT_VALUE}>{defaultLabel}</SelectItem>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Share2,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -54,9 +57,17 @@ import {
|
||||
useDeleteThread,
|
||||
useRenameThread,
|
||||
useThreads,
|
||||
useToggleThreadFavorite,
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import {
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
pathOfThread,
|
||||
sortThreadsPinnedFirst,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
@@ -82,6 +93,8 @@ export function RecentChatList() {
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const { mutate: renameThread } = useRenameThread();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
||||
|
||||
// Rename dialog state
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
@@ -160,11 +173,13 @@ export function RecentChatList() {
|
||||
[t],
|
||||
);
|
||||
|
||||
const visibleThreads = threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
const visibleThreads = sortThreadsPinnedFirst(
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
),
|
||||
);
|
||||
|
||||
if (visibleThreads.length === 0) {
|
||||
@@ -195,6 +210,8 @@ export function RecentChatList() {
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{visibleThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={thread.thread_id}
|
||||
@@ -210,10 +227,15 @@ export function RecentChatList() {
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
@@ -243,6 +265,36 @@ export function RecentChatList() {
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>{t.common.rename}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
togglePin(thread.thread_id, !pinned)
|
||||
}
|
||||
>
|
||||
{pinned ? (
|
||||
<PinOff className="text-muted-foreground" />
|
||||
) : (
|
||||
<Pin className="text-muted-foreground" />
|
||||
)}
|
||||
<span>{pinned ? t.common.unpin : t.common.pin}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
toggleFavorite(thread.thread_id, !favorited)
|
||||
}
|
||||
>
|
||||
<Star
|
||||
className={
|
||||
favorited
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{favorited
|
||||
? t.common.unfavorite
|
||||
: t.common.favorite}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleShare(thread)}
|
||||
>
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
||||
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
const languageOptions: { value: Locale; label: string }[] = [
|
||||
@@ -336,6 +340,17 @@ export function AppearanceSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<RecommendedQuestionsPage embedded />
|
||||
<Separator />
|
||||
<PromptPrefixSettingsPage embedded />
|
||||
<Separator />
|
||||
<PrivateSquareSettingsPage embedded />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert } from "lucide-react";
|
||||
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert, Briefcase, UserRound, Sparkles, Clock, History, Archive } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -458,12 +458,24 @@ const CTX_LABELS: Record<string, string> = {
|
||||
topOfMind: "当前重点",
|
||||
};
|
||||
|
||||
const CTX_ICONS: Record<string, React.ElementType> = {
|
||||
workContext: Briefcase,
|
||||
personalContext: UserRound,
|
||||
topOfMind: Sparkles,
|
||||
};
|
||||
|
||||
const HISTORY_LABELS: Record<string, string> = {
|
||||
recentMonths: "近期",
|
||||
earlierContext: "早期背景",
|
||||
longTermBackground: "长期背景",
|
||||
};
|
||||
|
||||
const HISTORY_ICONS: Record<string, React.ElementType> = {
|
||||
recentMonths: Clock,
|
||||
earlierContext: History,
|
||||
longTermBackground: Archive,
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
preference: "偏好",
|
||||
@@ -572,12 +584,15 @@ function MemoryV1View({
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{categoryLabel}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
<div
|
||||
className="flex flex-col items-end gap-1"
|
||||
title={`置信度:系统对这条记忆准确性的把握程度,${Math.round(fact.confidence * 100)}%`}
|
||||
>
|
||||
<span className="text-[11px] text-muted-foreground font-semibold tracking-wide">
|
||||
置信度 {Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
{/* Confidence Progress Bar */}
|
||||
<div className="h-1 w-12 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-1 w-16 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
@@ -611,44 +626,77 @@ function MemoryV1View({
|
||||
</TabsContent>
|
||||
|
||||
{/* 用户画像 & 历史摘要 Tab */}
|
||||
<TabsContent value="profile" className="mt-3 space-y-4">
|
||||
<TabsContent value="profile" className="mt-3 space-y-5">
|
||||
{/* 用户画像 (userContext) */}
|
||||
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
|
||||
<h3 className="text-base font-medium">用户画像</h3>
|
||||
<section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary dark:bg-primary/20">
|
||||
<UserRound className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-base font-semibold">用户画像</h3>
|
||||
</div>
|
||||
{ctxEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
||||
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground dark:border-[#213D5E]">
|
||||
暂无画像信息,对话后系统会自动归纳。
|
||||
</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{ctxEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{CTX_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{ctxEntries.map(([key, value]) => {
|
||||
const Icon = CTX_ICONS[key] ?? UserRound;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="group rounded-lg border border-border/50 bg-gradient-to-br from-card to-card/40 p-3.5 shadow-sm transition-all hover:border-primary/30 hover:shadow-md dark:border-[#213D5E]/60 dark:from-[#022248]/40 dark:to-[#022248]/10"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5 text-primary/80" />
|
||||
{CTX_LABELS[key] ?? key}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90 [overflow-wrap:anywhere]">
|
||||
{desensitize(value)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 历史摘要 (history) */}
|
||||
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
|
||||
<h3 className="text-base font-medium">历史摘要</h3>
|
||||
<section className="rounded-xl border border-border/60 bg-card/40 p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10 text-primary dark:bg-primary/20">
|
||||
<History className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-base font-semibold">历史摘要</h3>
|
||||
</div>
|
||||
{historyEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
||||
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground dark:border-[#213D5E]">
|
||||
暂无历史摘要。
|
||||
</p>
|
||||
) : (
|
||||
<dl className="space-y-2">
|
||||
{historyEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="font-medium text-muted-foreground mb-0.5">
|
||||
{HISTORY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="space-y-3">
|
||||
{historyEntries.map(([key, value]) => {
|
||||
const Icon = HISTORY_ICONS[key] ?? History;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="relative rounded-lg border border-border/50 bg-card/60 p-3.5 pl-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md dark:border-[#213D5E]/60 dark:bg-[#022248]/30"
|
||||
>
|
||||
<span className="absolute inset-y-2 left-0 w-1 rounded-full bg-primary/30" />
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5 text-primary/80" />
|
||||
{HISTORY_LABELS[key] ?? key}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90 [overflow-wrap:anywhere]">
|
||||
{desensitize(value)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
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 { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
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";
|
||||
@@ -67,9 +70,12 @@ import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteCustomSkill,
|
||||
useDuplicateSkill,
|
||||
useGenerateMissingSkillNameZh,
|
||||
useGenerateSkillNameZh,
|
||||
useInstallSkillUpload,
|
||||
useReconcileCustomSkills,
|
||||
useSetSkillFeatured,
|
||||
useSetSkillNameZh,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkills,
|
||||
@@ -152,8 +158,16 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<SkillSort>("time_desc");
|
||||
const [page, setPage] = useState(1);
|
||||
// Publisher filter: click a skill's 发布者 to keep only that user's skills.
|
||||
// {id} is owner_user_id; {label} the desensitized display name.
|
||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||
null,
|
||||
);
|
||||
const [viewMode, setViewMode] = useViewMode("skill");
|
||||
const reconcileMutation = useReconcileCustomSkills();
|
||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||
const [fillModel, setFillModel] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
||||
|
||||
@@ -200,7 +214,14 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
};
|
||||
}, [skills, currentUserId]);
|
||||
|
||||
const filteredSkills = filter === "mine" ? activeMine : activeAll;
|
||||
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
||||
const filteredSkills = useMemo(
|
||||
() =>
|
||||
publisher
|
||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||
: scopedSkills,
|
||||
[scopedSkills, publisher],
|
||||
);
|
||||
const orderedSkills = useMemo(
|
||||
() =>
|
||||
filteredSkills
|
||||
@@ -225,12 +246,34 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
}, [page, totalPages]);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, tagIds, sort]);
|
||||
}, [search, tagIds, sort, publisher?.id, filter]);
|
||||
const pagedSkills = useMemo(
|
||||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[orderedSkills, page],
|
||||
);
|
||||
|
||||
const handleFillNames = async () => {
|
||||
try {
|
||||
const result = await fillNamesMutation.mutateAsync(fillModel);
|
||||
const okCount = result.generated.length;
|
||||
const failCount = result.failed.length;
|
||||
if (okCount === 0 && failCount === 0) {
|
||||
toast.info("所有技能均已配置中文名称");
|
||||
} else if (failCount === 0) {
|
||||
toast.success(`已为 ${okCount} 个技能补充中文名称`);
|
||||
} else if (result.model_error) {
|
||||
// Failures came from the model itself — say so explicitly.
|
||||
toast.warning(
|
||||
`已补充 ${okCount} 个,${failCount} 个因大模型报错失败:${result.model_error}`,
|
||||
);
|
||||
} else {
|
||||
toast.warning(`已补充 ${okCount} 个,${failCount} 个生成失败,可稍后重试或手动填写`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
try {
|
||||
const result = await reconcileMutation.mutateAsync();
|
||||
@@ -321,6 +364,37 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
? t.settings.skills.rescanSkillsRunning
|
||||
: t.settings.skills.rescanSkills}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<ModelNameSelect
|
||||
value={fillModel}
|
||||
onChange={setFillModel}
|
||||
disabled={
|
||||
fillNamesMutation.isPending ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
triggerClassName="h-8 w-[9.5rem]"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-violet-300 bg-violet-50 font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
|
||||
onClick={() => void handleFillNames()}
|
||||
disabled={
|
||||
fillNamesMutation.isPending ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
title="用所选大模型为所有尚未配置中文名称的技能自动生成中文名(仅管理员)"
|
||||
>
|
||||
{fillNamesMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-4" />
|
||||
)}
|
||||
{fillNamesMutation.isPending ? "补充中…" : "一键补充中文名"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -362,6 +436,20 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{publisher && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPublisher(null)}
|
||||
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
|
||||
title="点击清除发布者筛选"
|
||||
>
|
||||
{publisher.label}
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
)}
|
||||
@@ -384,6 +472,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
layout={viewMode === "card" ? "card" : "row"}
|
||||
onPublisherClick={setPublisher}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -441,12 +530,16 @@ export function SkillRow({
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
layout = "row",
|
||||
onPublisherClick,
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
isAdmin: boolean;
|
||||
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
||||
layout?: "row" | "card";
|
||||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||||
* owner_user_id + its desensitized display label. */
|
||||
onPublisherClick?: (publisher: { id: string; label: string }) => void;
|
||||
}) {
|
||||
const isCard = layout === "card";
|
||||
const { t } = useI18n();
|
||||
@@ -549,8 +642,13 @@ export function SkillRow({
|
||||
const badgeGroup = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn(isCard && "truncate font-medium")}>
|
||||
{desensitize(skill.name)}
|
||||
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
|
||||
</span>
|
||||
{skill.name_zh?.trim() && (
|
||||
<span className="text-muted-foreground truncate text-xs font-normal">
|
||||
{desensitize(skill.name)}
|
||||
</span>
|
||||
)}
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.settings.skills.badgeBuiltin}
|
||||
@@ -631,6 +729,33 @@ export function SkillRow({
|
||||
|
||||
const metaNodes = (
|
||||
<>
|
||||
{!isBuiltin && skill.owner_username && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!skill.owner_user_id || !onPublisherClick}
|
||||
onClick={(e) => {
|
||||
if (!skill.owner_user_id || !onPublisherClick) return;
|
||||
e.stopPropagation();
|
||||
onPublisherClick({
|
||||
id: skill.owner_user_id,
|
||||
label: desensitize(skill.owner_username!),
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"text-muted-foreground mt-1 flex max-w-full items-center gap-1 text-sm font-medium",
|
||||
skill.owner_user_id && onPublisherClick &&
|
||||
"hover:text-primary cursor-pointer transition-colors hover:underline",
|
||||
)}
|
||||
title={
|
||||
skill.owner_user_id && onPublisherClick
|
||||
? `点击筛选发布者:${desensitize(skill.owner_username)}`
|
||||
: `发布者:${desensitize(skill.owner_username)}`
|
||||
}
|
||||
>
|
||||
<UserIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
|
||||
</button>
|
||||
)}
|
||||
{skill.use_count !== undefined && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{skill.use_count > 0
|
||||
@@ -649,15 +774,15 @@ export function SkillRow({
|
||||
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -842,13 +967,13 @@ export function SkillRow({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="skill"
|
||||
targetId={skill.name}
|
||||
currentTags={skill.tags ?? []}
|
||||
/>
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="skill"
|
||||
targetId={skill.name}
|
||||
currentTags={skill.tags ?? []}
|
||||
/>
|
||||
<SkillDetailDialog
|
||||
skillName={skill.name}
|
||||
open={detailOpen}
|
||||
@@ -892,8 +1017,16 @@ function InstallSkillDialog({
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
|
||||
// After a successful install the dialog switches to a "set Chinese name" step.
|
||||
// `installedName` holds the just-installed skill name (null = still on the upload step).
|
||||
const [installedName, setInstalledName] = useState<string | null>(null);
|
||||
const [nameZhDraft, setNameZhDraft] = useState("");
|
||||
// Optional model override for the "AI generate" button (null = configured default).
|
||||
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
|
||||
const validateMutation = useValidateSkillUpload();
|
||||
const installMutation = useInstallSkillUpload();
|
||||
const setNameZhMutation = useSetSkillNameZh();
|
||||
const generateNameZhMutation = useGenerateSkillNameZh();
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFile(null);
|
||||
@@ -901,14 +1034,30 @@ function InstallSkillDialog({
|
||||
setValidationError(null);
|
||||
setIsDragging(false);
|
||||
setTargetCategory("custom");
|
||||
setInstalledName(null);
|
||||
setNameZhDraft("");
|
||||
setNameZhModel(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (validateMutation.isPending || installMutation.isPending) return;
|
||||
if (
|
||||
validateMutation.isPending ||
|
||||
installMutation.isPending ||
|
||||
setNameZhMutation.isPending ||
|
||||
generateNameZhMutation.isPending
|
||||
)
|
||||
return;
|
||||
reset();
|
||||
onClose();
|
||||
}, [installMutation.isPending, onClose, reset, validateMutation.isPending]);
|
||||
}, [
|
||||
generateNameZhMutation.isPending,
|
||||
installMutation.isPending,
|
||||
onClose,
|
||||
reset,
|
||||
setNameZhMutation.isPending,
|
||||
validateMutation.isPending,
|
||||
]);
|
||||
|
||||
const acceptFile = useCallback(
|
||||
async (selected: File) => {
|
||||
@@ -964,15 +1113,47 @@ function InstallSkillDialog({
|
||||
result.skill_name,
|
||||
),
|
||||
);
|
||||
reset();
|
||||
onClose();
|
||||
// Stay open and advance to the optional "set Chinese name" step.
|
||||
setInstalledName(result.skill_name);
|
||||
setNameZhDraft("");
|
||||
setNameZhModel(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
|
||||
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, targetCategory],
|
||||
);
|
||||
|
||||
const handleGenerateNameZh = useCallback(async () => {
|
||||
if (!installedName) return;
|
||||
try {
|
||||
const result = await generateNameZhMutation.mutateAsync({
|
||||
skillName: installedName,
|
||||
modelName: nameZhModel,
|
||||
});
|
||||
setNameZhDraft(result.name_zh ?? "");
|
||||
toast.success(dialog.nameZhGenerated);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [dialog.nameZhGenerated, generateNameZhMutation, installedName, nameZhModel]);
|
||||
|
||||
const handleSaveNameZh = useCallback(async () => {
|
||||
if (!installedName) return;
|
||||
const trimmed = nameZhDraft.trim();
|
||||
try {
|
||||
// Empty draft = keep whatever is stored (or none); just close.
|
||||
if (trimmed) {
|
||||
await setNameZhMutation.mutateAsync({ skillName: installedName, nameZh: trimmed });
|
||||
toast.success(dialog.nameZhSaved);
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
|
||||
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
// Admin targeting public directory can overwrite an existing public skill.
|
||||
@@ -984,8 +1165,70 @@ function InstallSkillDialog({
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialog.title}</DialogTitle>
|
||||
<DialogTitle>{installedName ? dialog.nameZhStepTitle : dialog.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{installedName && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{dialog.nameZhStepHint.replace("{name}", desensitize(installedName))}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={nameZhDraft}
|
||||
onChange={(e) => setNameZhDraft(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSaveNameZh();
|
||||
}}
|
||||
placeholder={dialog.nameZhPlaceholder}
|
||||
className="h-9 flex-1"
|
||||
autoFocus
|
||||
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
|
||||
/>
|
||||
<ModelNameSelect
|
||||
value={nameZhModel}
|
||||
onChange={setNameZhModel}
|
||||
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
|
||||
triggerClassName="h-9 w-[9rem] shrink-0"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 shrink-0 gap-1 border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
|
||||
onClick={() => void handleGenerateNameZh()}
|
||||
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
|
||||
title={dialog.nameZhGenerate}
|
||||
>
|
||||
{generateNameZhMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-4" />
|
||||
)}
|
||||
{dialog.nameZhGenerate}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
|
||||
>
|
||||
{dialog.nameZhSkip}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSaveNameZh()}
|
||||
disabled={setNameZhMutation.isPending || generateNameZhMutation.isPending}
|
||||
>
|
||||
{setNameZhMutation.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!installedName && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
@@ -1148,6 +1391,8 @@ function InstallSkillDialog({
|
||||
{installMutation.isPending ? dialog.installing : dialog.install}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PencilIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -24,16 +25,20 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import { SkillDisplayAdapterPanel } from "@/components/workspace/skill-display-adapter-panel";
|
||||
import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog";
|
||||
import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useGenerateSkillNameZh,
|
||||
useSetSkillDetail,
|
||||
useSetSkillNameZh,
|
||||
useSkill,
|
||||
useSkillContent,
|
||||
useSkillFiles,
|
||||
@@ -98,9 +103,15 @@ export function SkillDetailDialog({
|
||||
const activeName = open ? skillName : null;
|
||||
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
|
||||
const setDetail = useSetSkillDetail();
|
||||
const setNameZh = useSetSkillNameZh();
|
||||
const generateNameZh = useGenerateSkillNameZh();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [editingNameZh, setEditingNameZh] = useState(false);
|
||||
const [nameZhDraft, setNameZhDraft] = useState("");
|
||||
// Optional model override for the "AI 生成" button (null = configured default).
|
||||
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"detail" | "display" | "source" | "files" | null
|
||||
>(null);
|
||||
@@ -109,13 +120,20 @@ export function SkillDetailDialog({
|
||||
|
||||
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
||||
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
|
||||
// Admins may browse any skill's files (the backend authorizes this); owners
|
||||
// may browse their own custom skill. Edit-ability is decided separately by
|
||||
// the backend's `filesData.editable` flag.
|
||||
const canViewFiles = useMemo(() => {
|
||||
if (!skill) return false;
|
||||
return isOwner || getStoredAuth()?.system_role === "admin";
|
||||
}, [skill, isOwner]);
|
||||
const {
|
||||
data: contentData,
|
||||
isLoading: isContentLoading,
|
||||
error: contentError,
|
||||
} = useSkillContent(open ? skillName : null);
|
||||
|
||||
const filesEnabled = open && isOwner && activeTab === "files";
|
||||
const filesEnabled = open && canViewFiles && activeTab === "files";
|
||||
const {
|
||||
data: filesData,
|
||||
isLoading: isFilesLoading,
|
||||
@@ -124,11 +142,16 @@ export function SkillDetailDialog({
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setEditingNameZh(false);
|
||||
setActiveTab(null);
|
||||
setShowRawSource(false);
|
||||
setOpenedFilePath(null);
|
||||
}, [open, skillName]);
|
||||
|
||||
useEffect(() => {
|
||||
setNameZhDraft(skill?.name_zh ?? "");
|
||||
}, [skill?.name, skill?.name_zh]);
|
||||
|
||||
const skillBody = useMemo(() => {
|
||||
return stripYamlFrontmatter(contentData?.content ?? "");
|
||||
}, [contentData]);
|
||||
@@ -152,6 +175,39 @@ export function SkillDetailDialog({
|
||||
}
|
||||
}
|
||||
|
||||
function startEditingNameZh() {
|
||||
setNameZhDraft(skill?.name_zh ?? "");
|
||||
setEditingNameZh(true);
|
||||
}
|
||||
|
||||
async function handleSaveNameZh() {
|
||||
if (!skill) return;
|
||||
try {
|
||||
await setNameZh.mutateAsync({
|
||||
skillName: skill.name,
|
||||
nameZh: nameZhDraft.trim(),
|
||||
});
|
||||
toast.success("中文名称已保存");
|
||||
setEditingNameZh(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateNameZh() {
|
||||
if (!skill) return;
|
||||
try {
|
||||
const result = await generateNameZh.mutateAsync({
|
||||
skillName: skill.name,
|
||||
modelName: nameZhModel,
|
||||
});
|
||||
setNameZhDraft(result.name_zh ?? "");
|
||||
toast.success("已生成中文名称");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function copySource() {
|
||||
await navigator.clipboard.writeText(contentData?.content ?? "");
|
||||
toast.success("Copied SKILL.md content");
|
||||
@@ -174,25 +230,105 @@ export function SkillDetailDialog({
|
||||
<SparklesIcon className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DialogTitle className="truncate text-base">
|
||||
{desensitize(skill?.name ?? skillName ?? "")}
|
||||
</DialogTitle>
|
||||
{skill?.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{tx.badgeBuiltin}
|
||||
</Badge>
|
||||
)}
|
||||
{skill?.published && (
|
||||
<Badge
|
||||
{editingNameZh ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<DialogTitle className="sr-only">
|
||||
{desensitize(skill?.name ?? skillName ?? "")}
|
||||
</DialogTitle>
|
||||
<Input
|
||||
value={nameZhDraft}
|
||||
onChange={(event) => setNameZhDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleSaveNameZh();
|
||||
if (event.key === "Escape") setEditingNameZh(false);
|
||||
}}
|
||||
placeholder={desensitize(skill?.name ?? skillName ?? "")}
|
||||
className="h-8 max-w-[14rem] flex-1 text-sm"
|
||||
autoFocus
|
||||
disabled={generateNameZh.isPending || setNameZh.isPending}
|
||||
/>
|
||||
<ModelNameSelect
|
||||
value={nameZhModel}
|
||||
onChange={setNameZhModel}
|
||||
disabled={generateNameZh.isPending || setNameZh.isPending}
|
||||
triggerClassName="h-8 w-[8.5rem] shrink-0"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
|
||||
className="h-8 shrink-0 gap-1 border-violet-300 bg-violet-50 px-2.5 text-xs font-medium text-violet-700 hover:bg-violet-100 hover:text-violet-800 dark:border-violet-500/40 dark:bg-violet-500/15 dark:text-violet-300 dark:hover:bg-violet-500/25"
|
||||
onClick={() => void handleGenerateNameZh()}
|
||||
disabled={generateNameZh.isPending || setNameZh.isPending}
|
||||
title="用所选大模型自动生成中文名"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
{tx.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{generateNameZh.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-3.5" />
|
||||
)}
|
||||
AI 生成
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-3 text-xs"
|
||||
onClick={() => void handleSaveNameZh()}
|
||||
disabled={setNameZh.isPending || generateNameZh.isPending}
|
||||
>
|
||||
{setNameZh.isPending && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{t.common.save}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground size-8 shrink-0"
|
||||
onClick={() => setEditingNameZh(false)}
|
||||
disabled={setNameZh.isPending}
|
||||
title={t.common.cancel}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group flex flex-wrap items-center gap-2">
|
||||
<DialogTitle className="truncate text-base">
|
||||
{skill?.name_zh?.trim()
|
||||
? skill.name_zh
|
||||
: desensitize(skill?.name ?? skillName ?? "")}
|
||||
</DialogTitle>
|
||||
{skill?.name_zh?.trim() && (
|
||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
||||
{desensitize(skill?.name ?? skillName ?? "")}
|
||||
</span>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground size-6 shrink-0 opacity-60 transition-opacity hover:opacity-100"
|
||||
onClick={startEditingNameZh}
|
||||
title={skill?.name_zh?.trim() ? "编辑中文名" : "设置中文名"}
|
||||
>
|
||||
<PencilIcon className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
{skill?.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{tx.badgeBuiltin}
|
||||
</Badge>
|
||||
)}
|
||||
{skill?.published && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
{tx.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
|
||||
{skill?.description ? desensitize(skill.description) : "-"}
|
||||
</DialogDescription>
|
||||
@@ -227,7 +363,7 @@ export function SkillDetailDialog({
|
||||
展示适配
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isOwner && (
|
||||
{canViewFiles && (
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
<FilesIcon className="mr-1 size-3.5" />
|
||||
{tx.filesSectionTitle}
|
||||
@@ -349,7 +485,7 @@ export function SkillDetailDialog({
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
{canViewFiles && (
|
||||
<TabsContent
|
||||
value="files"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, Loader2, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useTags, type TagType } from "@/core/tags";
|
||||
@@ -18,27 +19,42 @@ const COLLAPSE_THRESHOLD = 16;
|
||||
*
|
||||
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
|
||||
* caller can feed the value straight into a server-side query without a
|
||||
* request per keystroke.
|
||||
* request per keystroke. Pressing Enter or clicking the 搜索 button flushes the
|
||||
* pending debounce immediately, so users never wait on the timer.
|
||||
*
|
||||
* Pass ``searching`` while a query is in flight to show an inline spinner — it
|
||||
* makes server-side searches feel responsive instead of looking like they kept
|
||||
* the previous results.
|
||||
*
|
||||
* Tags are rendered inline as a multi-select chip row (instead of a dropdown):
|
||||
* "全部" is selected when no tag is picked, and any number of tags can be
|
||||
* toggled. Filtering is OR — a resource matches if it carries any picked tag.
|
||||
* When there are many tags the row collapses behind a 展开/收起 toggle.
|
||||
* When there are many tags the row collapses behind a 展开/收起 toggle. Pass
|
||||
* ``hideTags`` for resources without tags (e.g. the published-task plaza).
|
||||
*/
|
||||
export function TagSearchBar({
|
||||
targetType,
|
||||
onSearchChange,
|
||||
tagIds,
|
||||
tagIds = [],
|
||||
onTagIdsChange,
|
||||
className,
|
||||
actions,
|
||||
searching = false,
|
||||
hideTags = false,
|
||||
searchPlaceholder,
|
||||
}: {
|
||||
targetType: TagType;
|
||||
onSearchChange: (value: string) => void;
|
||||
tagIds: string[];
|
||||
onTagIdsChange: (value: string[]) => void;
|
||||
tagIds?: string[];
|
||||
onTagIdsChange?: (value: string[]) => void;
|
||||
className?: string;
|
||||
actions?: ReactNode;
|
||||
/** Show an inline spinner while the caller's query is fetching. */
|
||||
searching?: boolean;
|
||||
/** Hide the tag chip row for resources that have no tags. */
|
||||
hideTags?: boolean;
|
||||
/** Override the search input placeholder. */
|
||||
searchPlaceholder?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: tags } = useTags(targetType);
|
||||
@@ -47,11 +63,21 @@ export function TagSearchBar({
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const cbRef = useRef(onSearchChange);
|
||||
cbRef.current = onSearchChange;
|
||||
// Keep the pending debounce timer so an explicit search (Enter / 搜索 button)
|
||||
// can cancel it and fire immediately instead of racing the 300ms delay.
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => cbRef.current(input.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
timerRef.current = setTimeout(() => cbRef.current(input.trim()), 300);
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [input]);
|
||||
|
||||
const flushSearch = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
cbRef.current(input.trim());
|
||||
};
|
||||
|
||||
const allTags = tags ?? [];
|
||||
const canCollapse = allTags.length > COLLAPSE_THRESHOLD;
|
||||
// When collapsed, always keep the selected tags visible so the active
|
||||
@@ -62,7 +88,7 @@ export function TagSearchBar({
|
||||
: allTags;
|
||||
|
||||
const toggleTag = (id: string) => {
|
||||
onTagIdsChange(
|
||||
onTagIdsChange?.(
|
||||
tagIds.includes(id) ? tagIds.filter((x) => x !== id) : [...tagIds, id],
|
||||
);
|
||||
};
|
||||
@@ -75,8 +101,14 @@ export function TagSearchBar({
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t.adminTags.searchPlaceholder}
|
||||
className="pl-8"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
flushSearch();
|
||||
}
|
||||
}}
|
||||
placeholder={searchPlaceholder ?? t.adminTags.searchPlaceholder}
|
||||
className={cn("pl-8", searching && "pr-9")}
|
||||
name="tag-search"
|
||||
// A `type="password"` field elsewhere on the page (e.g. the private
|
||||
// square access gate) makes browsers / password managers pick the
|
||||
@@ -88,7 +120,24 @@ export function TagSearchBar({
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
{searching && (
|
||||
<Loader2 className="text-muted-foreground absolute right-2.5 top-1/2 size-4 -translate-y-1/2 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={flushSearch}
|
||||
className="shrink-0 gap-1.5"
|
||||
title="搜索"
|
||||
>
|
||||
{searching ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-4" />
|
||||
)}
|
||||
搜索
|
||||
</Button>
|
||||
{actions && (
|
||||
<div className="flex w-full shrink-0 justify-end sm:w-auto">
|
||||
{actions}
|
||||
@@ -96,11 +145,11 @@ export function TagSearchBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
{!hideTags && allTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTagIdsChange([])}
|
||||
onClick={() => onTagIdsChange?.([])}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all",
|
||||
tagIds.length === 0
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
import { WorkspaceNavStudio } from "./workspace-nav-studio";
|
||||
@@ -164,6 +164,18 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/knowledge")}
|
||||
asChild
|
||||
className={NAV_ITEM_CLASS}
|
||||
>
|
||||
<Link href="/page/workspace/knowledge">
|
||||
<BookOpen />
|
||||
<span>知识库</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
|
||||
@@ -9,15 +9,12 @@ import {
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageSquareCodeIcon,
|
||||
MessageSquarePlusIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
TrophyIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -148,36 +145,6 @@ export function WorkspaceNavMenu() {
|
||||
<Settings2Icon />
|
||||
{t.common.settings}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/recommended-questions")
|
||||
}
|
||||
>
|
||||
<MessageSquarePlusIcon />
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/prompt-prefix")
|
||||
}
|
||||
>
|
||||
<MessageSquareCodeIcon />
|
||||
问答前缀配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/private-square")
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon />
|
||||
私密广场配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
@@ -200,10 +167,12 @@ export function WorkspaceNavMenu() {
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin")}
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<UsersIcon />
|
||||
用户中心
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
@@ -232,12 +201,12 @@ export function WorkspaceNavMenu() {
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
|
||||
@@ -247,6 +247,32 @@ export async function getUserLeaderboard(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a synchronous rebuild of *today's* leaderboard snapshot so the latest
|
||||
* real-time records are included, then return the leaderboard for `range`.
|
||||
*/
|
||||
export async function refreshTodayLeaderboard(
|
||||
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/refresh-today?${params}`, {
|
||||
method: "POST",
|
||||
}),
|
||||
"Failed to refresh today's leaderboard",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserAnalytics(
|
||||
userId: string,
|
||||
range: { since: string; until: string; days?: number },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getUserMemory,
|
||||
getUserAnalytics,
|
||||
getUserLeaderboard,
|
||||
refreshTodayLeaderboard,
|
||||
listUserQa,
|
||||
listUserThreadMessages,
|
||||
listUserThreads,
|
||||
@@ -41,6 +42,36 @@ export function useUserLeaderboard(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-rebuild today's snapshot, then prime + invalidate the matching
|
||||
* leaderboard query so the page shows the freshly recomputed numbers.
|
||||
*/
|
||||
export function useRefreshTodayLeaderboard() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
range,
|
||||
limit = 20,
|
||||
}: {
|
||||
range: { since: string; until: string; days?: number };
|
||||
limit?: number;
|
||||
}) => refreshTodayLeaderboard(range, limit),
|
||||
onSuccess: (data, variables) => {
|
||||
qc.setQueryData(
|
||||
[
|
||||
"admin-activity",
|
||||
"leaderboard",
|
||||
variables.range.since,
|
||||
variables.range.until,
|
||||
variables.limit ?? 20,
|
||||
],
|
||||
data,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: ["admin-activity", "leaderboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserAnalytics(
|
||||
userId: string | null,
|
||||
range: { since: string; until: string; days?: number },
|
||||
|
||||
@@ -2,13 +2,13 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentSelectorResponse,
|
||||
AgentSort,
|
||||
CreateAgentRequest,
|
||||
PaginatedAgents,
|
||||
UpdateAgentRequest,
|
||||
} from "./types";
|
||||
Agent,
|
||||
AgentSelectorResponse,
|
||||
AgentSort,
|
||||
CreateAgentRequest,
|
||||
PaginatedAgents,
|
||||
UpdateAgentRequest,
|
||||
} from "./types";
|
||||
|
||||
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
|
||||
|
||||
@@ -28,21 +28,24 @@ export interface ListAgentsParams {
|
||||
/** 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;
|
||||
}
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: AgentSort;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
}
|
||||
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);
|
||||
if (params.owner) qs.set("owner", params.owner);
|
||||
return qs.toString();
|
||||
}
|
||||
|
||||
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
|
||||
const query = buildAgentsQuery(params);
|
||||
@@ -129,13 +132,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",
|
||||
});
|
||||
@@ -145,35 +148,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 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(
|
||||
@@ -211,14 +214,16 @@ 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;
|
||||
}
|
||||
export interface ListPrivateAgentsParams {
|
||||
password: string;
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: AgentSort;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/** Paginated private-square listing (password-gated). */
|
||||
export async function listPrivateAgentsPaginated(
|
||||
@@ -232,11 +237,12 @@ export async function listPrivateAgentsPaginated(
|
||||
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,
|
||||
}),
|
||||
tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined,
|
||||
page: params.page,
|
||||
page_size: params.pageSize,
|
||||
sort: params.sort,
|
||||
owner: params.owner || undefined,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -3,34 +3,34 @@ import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
adminSyncAgentSkills,
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
favoriteAgent,
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
listAgentsPaginated,
|
||||
listAllAgentsAdmin,
|
||||
listPrivateAgents,
|
||||
listPrivateAgentsPaginated,
|
||||
pinAgent,
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
setAgentPrivate,
|
||||
unfavoriteAgent,
|
||||
unpinAgent,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type {
|
||||
Agent,
|
||||
AgentSort,
|
||||
CreateAgentRequest,
|
||||
PaginatedAgents,
|
||||
UpdateAgentRequest,
|
||||
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``)
|
||||
@@ -79,31 +79,43 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
/** 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({
|
||||
scope: "mine" | "square";
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: AgentSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
enabled?: boolean;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
}) {
|
||||
const { scope, page, pageSize, enabled = true } = params;
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const owner = params.owner ?? "";
|
||||
const { data, isLoading, isFetching, error } = useQuery({
|
||||
queryKey: [
|
||||
"agents",
|
||||
"paginated",
|
||||
scope,
|
||||
search,
|
||||
[...tagIds].sort(),
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
queryFn: () =>
|
||||
listAgentsPaginated({ scope, search, tagIds, sort, page, pageSize }),
|
||||
scope,
|
||||
search,
|
||||
[...tagIds].sort(),
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner,
|
||||
],
|
||||
queryFn: () =>
|
||||
listAgentsPaginated({
|
||||
scope,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner: owner || undefined,
|
||||
}),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
@@ -111,6 +123,9 @@ export function useAgentsPaginated(params: {
|
||||
agents: data?.agents ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
// keepPreviousData keeps isLoading false on subsequent searches; expose
|
||||
// isFetching so callers can show a "searching" indicator on every query.
|
||||
isFetching,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -180,7 +195,7 @@ 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 }) =>
|
||||
@@ -196,30 +211,30 @@ export function useToggleAgentFavorite() {
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
});
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -230,9 +245,9 @@ export function useAgentSelector() {
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
}
|
||||
|
||||
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
}
|
||||
|
||||
@@ -277,39 +292,44 @@ export function usePrivateAgents(password: string | null) {
|
||||
* 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({
|
||||
params: {
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: AgentSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
enabled?: boolean;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
},
|
||||
) {
|
||||
const { page, pageSize, enabled = true } = params;
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const owner = params.owner ?? "";
|
||||
const { data, isLoading, isFetching, error, refetch } = useQuery({
|
||||
queryKey: [
|
||||
"agents",
|
||||
"private-square",
|
||||
password ?? "",
|
||||
"paginated",
|
||||
search,
|
||||
[...tagIds].sort(),
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
"paginated",
|
||||
search,
|
||||
[...tagIds].sort(),
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner,
|
||||
],
|
||||
queryFn: () =>
|
||||
listPrivateAgentsPaginated({
|
||||
password: password!,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
password: password!,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
owner: owner || undefined,
|
||||
}),
|
||||
enabled: !!password && enabled,
|
||||
staleTime: 30_000,
|
||||
@@ -320,6 +340,7 @@ export function usePrivateAgentsPaginated(
|
||||
agents: data?.agents ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
export interface Agent {
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
user_id: string | null;
|
||||
/** Publisher username (email local part); null for built-in agents. */
|
||||
owner_username?: string | null;
|
||||
published: boolean;
|
||||
is_private: boolean;
|
||||
builtin: boolean;
|
||||
@@ -17,15 +19,15 @@ export interface Agent {
|
||||
tags?: Tag[];
|
||||
featured_order?: number | null;
|
||||
/** Whether the current user has favorited (收藏) this agent. */
|
||||
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[];
|
||||
}
|
||||
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[];
|
||||
}
|
||||
|
||||
/** Pagination envelope returned by the server-side paginated agent listings. */
|
||||
export interface PaginatedAgents {
|
||||
|
||||
@@ -25,6 +25,10 @@ export const enUS: Translations = {
|
||||
edit: "Edit",
|
||||
rename: "Rename",
|
||||
share: "Share",
|
||||
pin: "Pin",
|
||||
unpin: "Unpin",
|
||||
favorite: "Favorite",
|
||||
unfavorite: "Unfavorite",
|
||||
openInNewWindow: "Open in new window",
|
||||
close: "Close",
|
||||
more: "More",
|
||||
@@ -255,6 +259,14 @@ export const enUS: Translations = {
|
||||
// Chats
|
||||
chats: {
|
||||
searchChats: "Search chats",
|
||||
pinnedSection: "Pinned",
|
||||
allChats: "All",
|
||||
favorites: "Favorites",
|
||||
onlyFavorites: "Favorites only",
|
||||
prevPage: "Previous",
|
||||
nextPage: "Next",
|
||||
noChats: "No conversations yet",
|
||||
noFavorites: "No favorited conversations yet",
|
||||
},
|
||||
|
||||
// Conversation sharing
|
||||
@@ -586,6 +598,14 @@ export const enUS: Translations = {
|
||||
targetCategoryLabel: "Install to",
|
||||
categoryCustom: "Custom skill",
|
||||
categoryPublic: "Built-in (admin)",
|
||||
nameZhStepTitle: "Set Chinese name",
|
||||
nameZhStepHint:
|
||||
"Skill '{name}' is installed. You can give it a Chinese display name, or let the model generate a default one (leave blank to use the English name).",
|
||||
nameZhPlaceholder: "Enter a Chinese name (optional)",
|
||||
nameZhGenerate: "AI generate",
|
||||
nameZhSkip: "Skip",
|
||||
nameZhGenerated: "Chinese name generated",
|
||||
nameZhSaved: "Chinese name saved",
|
||||
},
|
||||
},
|
||||
skillEvolution: {
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface Translations {
|
||||
edit: string;
|
||||
rename: string;
|
||||
share: string;
|
||||
pin: string;
|
||||
unpin: string;
|
||||
favorite: string;
|
||||
unfavorite: string;
|
||||
openInNewWindow: string;
|
||||
close: string;
|
||||
more: string;
|
||||
@@ -198,6 +202,14 @@ export interface Translations {
|
||||
// Chats
|
||||
chats: {
|
||||
searchChats: string;
|
||||
pinnedSection: string;
|
||||
allChats: string;
|
||||
favorites: string;
|
||||
onlyFavorites: string;
|
||||
prevPage: string;
|
||||
nextPage: string;
|
||||
noChats: string;
|
||||
noFavorites: string;
|
||||
};
|
||||
|
||||
// Conversation sharing
|
||||
@@ -511,6 +523,13 @@ export interface Translations {
|
||||
targetCategoryLabel: string;
|
||||
categoryCustom: string;
|
||||
categoryPublic: string;
|
||||
nameZhStepTitle: string;
|
||||
nameZhStepHint: string;
|
||||
nameZhPlaceholder: string;
|
||||
nameZhGenerate: string;
|
||||
nameZhSkip: string;
|
||||
nameZhGenerated: string;
|
||||
nameZhSaved: string;
|
||||
};
|
||||
};
|
||||
skillEvolution: {
|
||||
|
||||
@@ -25,6 +25,10 @@ export const zhCN: Translations = {
|
||||
edit: "编辑",
|
||||
rename: "重命名",
|
||||
share: "分享",
|
||||
pin: "置顶",
|
||||
unpin: "取消置顶",
|
||||
favorite: "收藏",
|
||||
unfavorite: "取消收藏",
|
||||
openInNewWindow: "在新窗口打开",
|
||||
close: "关闭",
|
||||
more: "更多",
|
||||
@@ -242,6 +246,14 @@ export const zhCN: Translations = {
|
||||
// Chats
|
||||
chats: {
|
||||
searchChats: "搜索对话",
|
||||
pinnedSection: "置顶",
|
||||
allChats: "全部",
|
||||
favorites: "收藏",
|
||||
onlyFavorites: "只看收藏",
|
||||
prevPage: "上一页",
|
||||
nextPage: "下一页",
|
||||
noChats: "暂无对话",
|
||||
noFavorites: "暂无收藏的对话",
|
||||
},
|
||||
|
||||
// Conversation sharing
|
||||
@@ -559,6 +571,14 @@ export const zhCN: Translations = {
|
||||
targetCategoryLabel: `安装到`,
|
||||
categoryCustom: `自定义技能`,
|
||||
categoryPublic: `内置技能(管理员)`,
|
||||
nameZhStepTitle: "设置中文名",
|
||||
nameZhStepHint:
|
||||
"技能 “{name}” 已安装。你可以为它填写一个中文名,或让大模型自动生成一个默认中文名(留空则使用英文名)。",
|
||||
nameZhPlaceholder: "请输入中文名(可留空)",
|
||||
nameZhGenerate: "AI 生成",
|
||||
nameZhSkip: "跳过",
|
||||
nameZhGenerated: "已生成中文名称",
|
||||
nameZhSaved: "已保存中文名",
|
||||
},
|
||||
},
|
||||
skillEvolution: {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
BatchIngestPayload,
|
||||
BatchIngestResult,
|
||||
GraphExportResult,
|
||||
IngestThreadPayload,
|
||||
KnowledgeListParams,
|
||||
KnowledgeListResponse,
|
||||
KnowledgeNote,
|
||||
KnowledgeSearchItem,
|
||||
VaultPage,
|
||||
WikilinkResolutionMap,
|
||||
} from "./types";
|
||||
|
||||
const baseUrl = () => `${getBackendBaseURL()}/api/knowledge`;
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Request failed" }));
|
||||
const detail = error.detail;
|
||||
if (typeof detail === "string") throw new Error(detail);
|
||||
throw new Error("请求失败");
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listKnowledgeNotes(params: KnowledgeListParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.keyword) qs.set("keyword", params.keyword);
|
||||
if (params.tag) qs.set("tag", params.tag);
|
||||
if (params.source_type) qs.set("source_type", params.source_type);
|
||||
if (params.status) qs.set("status", params.status);
|
||||
qs.set("limit", String(params.limit ?? 20));
|
||||
qs.set("offset", String(params.offset ?? 0));
|
||||
return parseResponse<KnowledgeListResponse>(
|
||||
await apiFetch(`${baseUrl()}/notes?${qs.toString()}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getKnowledgeNote(noteId: string) {
|
||||
return parseResponse<KnowledgeNote>(
|
||||
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createKnowledgeNote(data: {
|
||||
title: string;
|
||||
content_md: string;
|
||||
summary?: string | null;
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
}) {
|
||||
const res = await parseResponse<{ note: KnowledgeNote }>(
|
||||
await apiFetch(`${baseUrl()}/notes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
return res.note;
|
||||
}
|
||||
|
||||
export async function updateKnowledgeNote(
|
||||
noteId: string,
|
||||
data: Partial<{
|
||||
title: string;
|
||||
content_md: string;
|
||||
summary: string | null;
|
||||
tags: string[];
|
||||
status: string;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<KnowledgeNote>(
|
||||
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteKnowledgeNote(noteId: string, hard = false) {
|
||||
return parseResponse<{ ok: boolean; note_id: string }>(
|
||||
await apiFetch(
|
||||
`${baseUrl()}/notes/${encodeURIComponent(noteId)}${hard ? "?hard=true" : ""}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchKnowledge(query: string, limit = 8) {
|
||||
const res = await parseResponse<{ items: KnowledgeSearchItem[] }>(
|
||||
await apiFetch(`${baseUrl()}/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, mode: "keyword", limit }),
|
||||
}),
|
||||
);
|
||||
return res.items;
|
||||
}
|
||||
|
||||
export async function ingestThread(threadId: string, payload: IngestThreadPayload = {}) {
|
||||
return parseResponse<{
|
||||
note?: KnowledgeNote;
|
||||
created?: boolean;
|
||||
queued?: boolean;
|
||||
}>(
|
||||
await apiFetch(`${baseUrl()}/ingest/thread/${encodeURIComponent(threadId)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
|
||||
return parseResponse<BatchIngestResult>(
|
||||
await apiFetch(`${baseUrl()}/ingest/threads/batch`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve `[[wikilink]]` targets to note ids / vault pages. */
|
||||
export async function resolveWikilinks(targets: string[]) {
|
||||
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
|
||||
await apiFetch(`${baseUrl()}/resolve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ targets }),
|
||||
}),
|
||||
);
|
||||
return res.resolved;
|
||||
}
|
||||
|
||||
/** Resolve every `[[wikilink]]` inside a note's body in one call. */
|
||||
export async function resolveNoteWikilinks(noteId: string) {
|
||||
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
|
||||
await apiFetch(
|
||||
`${baseUrl()}/notes/${encodeURIComponent(noteId)}/wikilinks`,
|
||||
),
|
||||
);
|
||||
return res.resolved;
|
||||
}
|
||||
|
||||
/** Fetch a raw vault meta/hub Markdown page (no DB note) by its path. */
|
||||
export async function fetchVaultPage(path: string) {
|
||||
return parseResponse<VaultPage>(
|
||||
await apiFetch(`${baseUrl()}/vault-page?path=${encodeURIComponent(path)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportKnowledgeGraph() {
|
||||
return parseResponse<GraphExportResult>(
|
||||
await apiFetch(`${baseUrl()}/export/graph`, { method: "POST" }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Absolute URL of a served wiki-export file (for iframe / download). */
|
||||
export function knowledgeExportFileUrl(filename: string) {
|
||||
return `${baseUrl()}/export/files/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
// --- phase 2/3/4 ---
|
||||
|
||||
export interface KnowledgeVersion {
|
||||
id: string;
|
||||
note_id: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
content_md: string;
|
||||
status: string;
|
||||
tags: string[];
|
||||
edited_by?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export async function listNoteVersions(noteId: string) {
|
||||
const res = await parseResponse<{ items: KnowledgeVersion[] }>(
|
||||
await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}/versions`),
|
||||
);
|
||||
return res.items;
|
||||
}
|
||||
|
||||
export interface DuplicateGroup {
|
||||
primary: { id: string; title: string | null };
|
||||
duplicates: Array<{ id: string; title: string | null; score: number }>;
|
||||
}
|
||||
|
||||
export async function listDuplicates() {
|
||||
const res = await parseResponse<{ groups: DuplicateGroup[] }>(
|
||||
await apiFetch(`${baseUrl()}/duplicates`),
|
||||
);
|
||||
return res.groups;
|
||||
}
|
||||
|
||||
export async function mergeKnowledgeNotes(primaryId: string, duplicateIds: string[]) {
|
||||
return parseResponse<{ note: KnowledgeNote }>(
|
||||
await apiFetch(`${baseUrl()}/merge`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: duplicateIds }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function reindexKnowledge() {
|
||||
return parseResponse<{ reindexed: number; embedding_enabled: number }>(
|
||||
await apiFetch(`${baseUrl()}/reindex`, { method: "POST" }),
|
||||
);
|
||||
}
|
||||
|
||||
export interface KnowledgeGraph {
|
||||
nodes: Array<{ id: string; type: string; label: string; category?: string | null }>;
|
||||
edges: Array<{ id: string; source: string; target: string; label: string; weight: number }>;
|
||||
}
|
||||
|
||||
export async function getKnowledgeGraph() {
|
||||
return parseResponse<KnowledgeGraph>(await apiFetch(`${baseUrl()}/graph`));
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createKnowledgeNote,
|
||||
deleteKnowledgeNote,
|
||||
exportKnowledgeGraph,
|
||||
fetchVaultPage,
|
||||
getKnowledgeNote,
|
||||
ingestThread,
|
||||
ingestThreadsBatch,
|
||||
listDuplicates,
|
||||
listKnowledgeNotes,
|
||||
mergeKnowledgeNotes,
|
||||
resolveNoteWikilinks,
|
||||
searchKnowledge,
|
||||
updateKnowledgeNote,
|
||||
} from "./api";
|
||||
import type { KnowledgeListParams } from "./types";
|
||||
|
||||
const ROOT_KEY = ["knowledge"] as const;
|
||||
|
||||
export function useKnowledgeNotes(params: KnowledgeListParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "list", params],
|
||||
queryFn: () => listKnowledgeNotes(params),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useKnowledgeNote(noteId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "note", noteId],
|
||||
queryFn: () => getKnowledgeNote(noteId as string),
|
||||
enabled: !!noteId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNoteWikilinks(noteId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "wikilinks", noteId],
|
||||
queryFn: () => resolveNoteWikilinks(noteId as string),
|
||||
enabled: !!noteId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useVaultPage(path: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "vault-page", path],
|
||||
queryFn: () => fetchVaultPage(path as string),
|
||||
enabled: !!path,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useKnowledgeSearch(query: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "search", query],
|
||||
queryFn: () => searchKnowledge(query),
|
||||
enabled: enabled && query.trim().length > 0,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useKnowledgeActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ROOT_KEY });
|
||||
|
||||
return {
|
||||
create: useMutation({ mutationFn: createKnowledgeNote, onSuccess: invalidate }),
|
||||
update: useMutation({
|
||||
mutationFn: ({
|
||||
noteId,
|
||||
data,
|
||||
}: {
|
||||
noteId: string;
|
||||
data: Parameters<typeof updateKnowledgeNote>[1];
|
||||
}) => updateKnowledgeNote(noteId, data),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: ({ noteId, hard }: { noteId: string; hard?: boolean }) =>
|
||||
deleteKnowledgeNote(noteId, hard),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
ingestThread: useMutation({
|
||||
mutationFn: ({
|
||||
threadId,
|
||||
payload,
|
||||
}: {
|
||||
threadId: string;
|
||||
payload?: Parameters<typeof ingestThread>[1];
|
||||
}) => ingestThread(threadId, payload),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
batchIngest: useMutation({
|
||||
mutationFn: ingestThreadsBatch,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }),
|
||||
merge: useMutation({
|
||||
mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) =>
|
||||
mergeKnowledgeNotes(primaryId, duplicateIds),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useKnowledgeDuplicates(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "duplicates"],
|
||||
queryFn: listDuplicates,
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
export type KnowledgeStatus = "draft" | "approved" | "archived";
|
||||
export type KnowledgeSourceType =
|
||||
| "thread"
|
||||
| "search"
|
||||
| "tool"
|
||||
| "file"
|
||||
| "manual"
|
||||
| "report";
|
||||
|
||||
export interface KnowledgeSource {
|
||||
id: string;
|
||||
note_id: string;
|
||||
source_type: string;
|
||||
thread_id?: string | null;
|
||||
message_id?: string | null;
|
||||
tool_name?: string | null;
|
||||
title?: string | null;
|
||||
url?: string | null;
|
||||
snippet?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeEntity {
|
||||
name: string;
|
||||
entity_type?: string | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeNote {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
content_md: string;
|
||||
vault_path?: string | null;
|
||||
source_type: string;
|
||||
source_id?: string | null;
|
||||
status: KnowledgeStatus;
|
||||
confidence?: number;
|
||||
created_by?: string | null;
|
||||
updated_by?: string | null;
|
||||
created_by_name?: string | null;
|
||||
updated_by_name?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
tags: string[];
|
||||
entities?: KnowledgeEntity[];
|
||||
sources?: KnowledgeSource[];
|
||||
}
|
||||
|
||||
export interface KnowledgeListResponse {
|
||||
items: KnowledgeNote[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeListParams {
|
||||
keyword?: string;
|
||||
tag?: string;
|
||||
source_type?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export type WikilinkKind = "note" | "vault" | "missing";
|
||||
|
||||
export interface WikilinkResolution {
|
||||
target: string;
|
||||
normalized?: string;
|
||||
kind: WikilinkKind;
|
||||
exists: boolean;
|
||||
note_id?: string;
|
||||
vault_path?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** Map of original `[[target]]` string → its resolution. */
|
||||
export type WikilinkResolutionMap = Record<string, WikilinkResolution>;
|
||||
|
||||
export interface VaultPage {
|
||||
path: string;
|
||||
title: string;
|
||||
content_md: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeSearchItem {
|
||||
note_id: string;
|
||||
chunk_id?: string | null;
|
||||
title?: string | null;
|
||||
summary?: string | null;
|
||||
snippet?: string | null;
|
||||
score: number;
|
||||
tags: string[];
|
||||
source_type?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface IngestThreadPayload {
|
||||
mode?: "summary" | "full";
|
||||
title?: string | null;
|
||||
status?: KnowledgeStatus;
|
||||
tags?: string[];
|
||||
include_sources?: boolean;
|
||||
/** When true the backend sediments asynchronously and returns immediately. */
|
||||
background?: boolean;
|
||||
/** Optional model override for knowledge extraction (null = configured default). */
|
||||
model_name?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchIngestPayload {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: KnowledgeStatus;
|
||||
include_sources?: boolean;
|
||||
skip_existing?: boolean;
|
||||
mode?: "summary" | "full";
|
||||
/** Optional model override for knowledge extraction (null = configured default). */
|
||||
model_name?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchIngestResult {
|
||||
ok: boolean;
|
||||
processed: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
/** Notes that fell back to rule-based extraction because the model failed. */
|
||||
llm_fallback?: number;
|
||||
items: Array<{
|
||||
thread_id: string;
|
||||
note_id: string | null;
|
||||
status: string;
|
||||
llm_fallback?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GraphExportResult {
|
||||
ok: boolean;
|
||||
files: {
|
||||
graph_json: string;
|
||||
graph_html: string;
|
||||
graph_graphml: string;
|
||||
cypher: string;
|
||||
};
|
||||
stats: { nodes: number; edges: number };
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import type { ElementType } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
@@ -28,14 +29,13 @@ import {
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
MessageSquareCode,
|
||||
MessageSquarePlus,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Target,
|
||||
Trophy,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -233,27 +233,6 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: ListChecks,
|
||||
path: "/page/strategy/scheduled/tasks",
|
||||
},
|
||||
{
|
||||
id: "mgmt-recommended-questions",
|
||||
label: "推荐问题配置",
|
||||
icon: MessageSquarePlus,
|
||||
path: "/page/strategy/settings/recommended-questions",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-prompt-prefix",
|
||||
label: "问答前缀配置",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/settings/prompt-prefix",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-private-square",
|
||||
label: "私密广场配置",
|
||||
icon: ShieldCheck,
|
||||
path: "/page/strategy/settings/private-square",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-llm-metrics",
|
||||
label: "大模型调用统计",
|
||||
@@ -268,6 +247,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
path: "/page/strategy/admin/users",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-leaderboard",
|
||||
label: "用户排行榜",
|
||||
icon: Trophy,
|
||||
path: "/page/strategy/admin/user-leaderboard",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-admin-scheduled-tasks",
|
||||
label: "定时任务管理",
|
||||
@@ -295,6 +281,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: Network,
|
||||
path: "/page/strategy/resource-management/resource-network-actor",
|
||||
},
|
||||
{
|
||||
id: "resource-knowledge-native",
|
||||
label: "知识库",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/knowledge",
|
||||
},
|
||||
{
|
||||
id: "resource-knowledge-base",
|
||||
label: "知识库资源管理",
|
||||
@@ -318,6 +310,7 @@ export const defaultExpandedIds: ReadonlySet<string> = new Set([
|
||||
"light-app-note-management",
|
||||
"task-management",
|
||||
"xk-center-task",
|
||||
"comprehensive-management",
|
||||
]);
|
||||
|
||||
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项及其子项 */
|
||||
|
||||
@@ -430,6 +430,78 @@ export async function saveSkillFile(
|
||||
return response.json() as Promise<SkillFileSaveResponse>;
|
||||
}
|
||||
|
||||
export interface SkillNameZhResponse {
|
||||
name: string;
|
||||
name_zh: string | null;
|
||||
}
|
||||
|
||||
export interface GenerateMissingSkillNameZhResponse {
|
||||
generated: SkillNameZhResponse[];
|
||||
failed: string[];
|
||||
skipped: number;
|
||||
/** Representative model error when some skills failed due to the LLM. */
|
||||
model_error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a skill's Chinese display name. An empty string clears it (falls back to
|
||||
* the English name).
|
||||
*/
|
||||
export async function setSkillNameZh(
|
||||
skillName: string,
|
||||
nameZh: string,
|
||||
): Promise<SkillNameZhResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name_zh: nameZh }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to set skill Chinese name");
|
||||
}
|
||||
return response.json() as Promise<SkillNameZhResponse>;
|
||||
}
|
||||
|
||||
/** Ask the LLM to generate and persist a Chinese name for a single skill. */
|
||||
export async function generateSkillNameZh(
|
||||
skillName: string,
|
||||
modelName?: string | null,
|
||||
): Promise<SkillNameZhResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/name-zh/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_name: modelName ?? null }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to generate skill Chinese name");
|
||||
}
|
||||
return response.json() as Promise<SkillNameZhResponse>;
|
||||
}
|
||||
|
||||
/** One-click (admin): generate Chinese names for every skill that lacks one. */
|
||||
export async function generateMissingSkillNameZh(
|
||||
modelName?: string | null,
|
||||
): Promise<GenerateMissingSkillNameZhResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/name-zh/generate-missing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model_name: modelName ?? null }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to bulk-generate skill Chinese names");
|
||||
}
|
||||
return response.json() as Promise<GenerateMissingSkillNameZhResponse>;
|
||||
}
|
||||
|
||||
export interface SetSkillDetailResponse {
|
||||
name: string;
|
||||
detail: string | null;
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
deleteCustomSkill,
|
||||
duplicateSkill,
|
||||
enableSkill,
|
||||
generateMissingSkillNameZh,
|
||||
generateSkillNameZh,
|
||||
getSkill,
|
||||
getSkillContent,
|
||||
getSkillFileContent,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
reconcileCustomSkills,
|
||||
saveSkillFile,
|
||||
setSkillDetail,
|
||||
setSkillNameZh,
|
||||
setSkillDisplay,
|
||||
setSkillPinned,
|
||||
setSkillFeatured,
|
||||
@@ -90,6 +93,40 @@ export function useSetSkillDetail() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillNameZh() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, nameZh }: { skillName: string; nameZh: string }) =>
|
||||
setSkillNameZh(skillName, nameZh),
|
||||
onSuccess: (_data, { skillName }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSkillNameZh() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, modelName }: { skillName: string; modelName?: string | null }) =>
|
||||
generateSkillNameZh(skillName, modelName),
|
||||
onSuccess: (_data, { skillName }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateMissingSkillNameZh() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (modelName?: string | null) => generateMissingSkillNameZh(modelName),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillDisplay() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "k
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
// 用户配置或大模型生成的中文名称;为空时界面回退到使用英文 name。
|
||||
name_zh?: string | null;
|
||||
description: string;
|
||||
category: string;
|
||||
license: string;
|
||||
@@ -49,6 +51,9 @@ export interface Skill {
|
||||
// Multitenancy fields — owner_user_id is null for built-in / public skills.
|
||||
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
|
||||
owner_user_id?: string | null;
|
||||
// Publisher username (email local part) resolved from owner_user_id by the
|
||||
// backend. Null for built-in / public skills with no human owner.
|
||||
owner_username?: string | null;
|
||||
published?: boolean;
|
||||
builtin?: boolean;
|
||||
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||
|
||||
@@ -621,15 +621,15 @@ export function useThreadStream({
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
const mergedContext = {
|
||||
...context,
|
||||
...extraContext,
|
||||
};
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
const mergedContext = {
|
||||
...context,
|
||||
...extraContext,
|
||||
};
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
content: [
|
||||
{
|
||||
@@ -647,33 +647,33 @@ export function useThreadStream({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
threadId: threadId,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
context: {
|
||||
...mergedContext,
|
||||
thinking_enabled: mergedContext.mode !== "flash",
|
||||
is_plan_mode:
|
||||
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
||||
subagent_enabled: mergedContext.mode === "ultra",
|
||||
reasoning_effort:
|
||||
mergedContext.reasoning_effort ??
|
||||
(mergedContext.mode === "ultra"
|
||||
? "high"
|
||||
: mergedContext.mode === "pro"
|
||||
? "medium"
|
||||
: mergedContext.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled:
|
||||
mergedContext.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
{
|
||||
threadId: threadId,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
context: {
|
||||
...mergedContext,
|
||||
thinking_enabled: mergedContext.mode !== "flash",
|
||||
is_plan_mode:
|
||||
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
||||
subagent_enabled: mergedContext.mode === "ultra",
|
||||
reasoning_effort:
|
||||
mergedContext.reasoning_effort ??
|
||||
(mergedContext.mode === "ultra"
|
||||
? "high"
|
||||
: mergedContext.mode === "pro"
|
||||
? "medium"
|
||||
: mergedContext.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled:
|
||||
mergedContext.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
@@ -853,6 +853,86 @@ export function useDeleteMessage() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge metadata into a conversation's record via the Gateway
|
||||
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
|
||||
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
|
||||
* single user, so these live directly on the thread metadata (no join table).
|
||||
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
|
||||
* the toggle immediately, mirroring `useRenameThread`.
|
||||
*/
|
||||
export function useUpdateThreadMetadata() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
threadId,
|
||||
metadata,
|
||||
}: {
|
||||
threadId: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}) => {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ metadata }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "更新对话失败。" }));
|
||||
throw new Error(error.detail ?? "更新对话失败。");
|
||||
}
|
||||
},
|
||||
onSuccess(_, { threadId, metadata }) {
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["threads", "search"],
|
||||
exact: false,
|
||||
},
|
||||
(oldData: Array<AgentThread> | undefined) => {
|
||||
if (oldData == null) {
|
||||
return oldData;
|
||||
}
|
||||
return oldData.map((t) =>
|
||||
t.thread_id === threadId
|
||||
? {
|
||||
...t,
|
||||
metadata: {
|
||||
...(t.metadata as Record<string, unknown> | null),
|
||||
...metadata,
|
||||
},
|
||||
}
|
||||
: t,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle a conversation's pinned (置顶) flag. */
|
||||
export function useToggleThreadPin() {
|
||||
const { mutate, ...rest } = useUpdateThreadMetadata();
|
||||
return {
|
||||
...rest,
|
||||
toggle: (threadId: string, pinned: boolean) =>
|
||||
mutate({ threadId, metadata: { is_pinned: pinned } }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Toggle a conversation's favorite (收藏) flag. */
|
||||
export function useToggleThreadFavorite() {
|
||||
const { mutate, ...rest } = useUpdateThreadMetadata();
|
||||
return {
|
||||
...rest,
|
||||
toggle: (threadId: string, favorite: boolean) =>
|
||||
mutate({ threadId, metadata: { is_favorite: favorite } }),
|
||||
};
|
||||
}
|
||||
|
||||
export function useRenameThread() {
|
||||
const queryClient = useQueryClient();
|
||||
const apiClient = getAPIClient();
|
||||
|
||||
@@ -18,9 +18,11 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
rag_mode_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
rag_mode_enabled?: boolean;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,28 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function isThreadPinned(thread: AgentThread): boolean {
|
||||
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
|
||||
}
|
||||
|
||||
export function isThreadFavorite(thread: AgentThread): boolean {
|
||||
return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable sort that lifts pinned (置顶) threads to the top while preserving the
|
||||
* incoming order (typically `updated_at` desc) within both the pinned and the
|
||||
* unpinned group.
|
||||
*/
|
||||
export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
|
||||
const pinned: AgentThread[] = [];
|
||||
const rest: AgentThread[] = [];
|
||||
for (const thread of threads) {
|
||||
(isThreadPinned(thread) ? pinned : rest).push(thread);
|
||||
}
|
||||
return [...pinned, ...rest];
|
||||
}
|
||||
|
||||
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
|
||||
if (!title) return "Untitled";
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface UserQaItem {
|
||||
run_id: string;
|
||||
thread_id: string;
|
||||
thread_title: string;
|
||||
assistant_id: string;
|
||||
status: string;
|
||||
model_name: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
total_tokens: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserQaPageResponse {
|
||||
user_id: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
items: UserQaItem[];
|
||||
}
|
||||
|
||||
async function parse<T>(res: Response, what: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `${what} (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listMyQa(
|
||||
params: { page?: number; pageSize?: number; q?: string } = {},
|
||||
): Promise<UserQaPageResponse> {
|
||||
const search = new URLSearchParams({
|
||||
page: String(params.page ?? 1),
|
||||
page_size: String(params.pageSize ?? 20),
|
||||
});
|
||||
const query = (params.q ?? "").trim();
|
||||
if (query) search.set("q", query);
|
||||
return parse(
|
||||
await apiFetch(`${getBackendBaseURL()}/api/users/me/qa?${search}`),
|
||||
"Failed to load my Q&A",
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Bot,
|
||||
CalendarDays,
|
||||
Crown,
|
||||
ExternalLink,
|
||||
FileDown,
|
||||
Gauge,
|
||||
Layers3,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
Medal,
|
||||
MessageSquareText,
|
||||
MousePointerClick,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
@@ -54,7 +56,7 @@ import type {
|
||||
AdminLeaderboardUser,
|
||||
} from "@/core/admin/api";
|
||||
import { streamUserLeaderboardReport } from "@/core/admin/api";
|
||||
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
|
||||
import { useRefreshTodayLeaderboard, useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -68,10 +70,10 @@ const LEADERBOARD_PAGE_SIZE = 20;
|
||||
|
||||
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
||||
crimson: {
|
||||
soft: "bg-[#FFF1F1] dark:bg-[#C1121F]/15",
|
||||
text: "text-[#C1121F] dark:text-[#F2B8BB]",
|
||||
bar: "bg-[#C1121F]",
|
||||
border: "border-[#F1D6D6] dark:border-[#462123]",
|
||||
soft: "bg-[#FFF1F1] dark:bg-[#2563EB]/15",
|
||||
text: "text-[#C1121F] dark:text-[#7FACF2]",
|
||||
bar: "bg-[#C1121F] dark:bg-[#2563EB]",
|
||||
border: "border-[#F1D6D6] dark:border-[#213D5E]",
|
||||
},
|
||||
rose: {
|
||||
soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
|
||||
@@ -284,7 +286,7 @@ function StatCard({
|
||||
}) {
|
||||
const color = accentClasses[accent];
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#230D0F]", color.border)}>
|
||||
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#02244C]", color.border)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span>
|
||||
<span className={cn("flex size-8 items-center justify-center rounded-md", color.soft, color.text)}>
|
||||
@@ -337,13 +339,13 @@ function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
|
||||
return (
|
||||
<section className="grid gap-3 md:grid-cols-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]" title={item.description}>
|
||||
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#D8B8B8]">{item.label}</div>
|
||||
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]" title={item.description}>
|
||||
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#7FACF2]">{item.label}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
|
||||
{Number(item.value).toFixed(item.unit === "%" || String(item.value).includes(".") ? 1 : 0)}
|
||||
<span className="ml-1 text-xs font-normal text-[#8B6B6B]">{item.unit}</span>
|
||||
<span className="ml-1 text-xs font-normal text-[#8B6B6B] dark:text-[#7FACF2]">{item.unit}</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</p>
|
||||
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
@@ -365,14 +367,14 @@ function GroupComparison({
|
||||
const groups = buildGroupStats(users, rules);
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
|
||||
<Users className="size-4 text-[#C1121F]" />
|
||||
<Users className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
分组统计
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
输入账号前缀或关键字,用逗号分隔,例如 zero,two;未命中的用户归入其他。
|
||||
</p>
|
||||
</div>
|
||||
@@ -380,12 +382,12 @@ function GroupComparison({
|
||||
value={rulesText}
|
||||
onChange={(event) => onRulesChange(event.target.value)}
|
||||
placeholder="zero,two"
|
||||
className="h-9 w-72 border-[#F1D6D6] bg-white dark:bg-[#19090A]"
|
||||
className="h-9 w-72 border-[#F1D6D6] bg-white dark:bg-[#011226]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-3">
|
||||
{groups.map((group) => (
|
||||
<div key={group.name} className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#462123] dark:bg-[#19090A]">
|
||||
<div key={group.name} className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#213D5E] dark:bg-[#011226]">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="font-semibold text-[#231815] dark:text-white">{group.name}</div>
|
||||
<Badge variant="outline">{group.userCount} 人</Badge>
|
||||
@@ -402,9 +404,9 @@ function GroupComparison({
|
||||
key={user.user_id}
|
||||
type="button"
|
||||
onClick={() => onSelectUser(user)}
|
||||
className="grid w-full grid-cols-[24px_minmax(0,1fr)_64px] items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-white dark:hover:bg-[#230D0F]"
|
||||
className="grid w-full grid-cols-[24px_minmax(0,1fr)_64px] items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-white dark:hover:bg-[#02244C]"
|
||||
>
|
||||
<span className="font-semibold text-[#C1121F]">{index + 1}</span>
|
||||
<span className="font-semibold text-[#C1121F] dark:text-[#7FACF2]">{index + 1}</span>
|
||||
<span className="truncate">{user.email || user.user_id}</span>
|
||||
<span className="text-right tabular-nums">{fmtNumber(userScore(user, "activity"))}</span>
|
||||
</button>
|
||||
@@ -438,8 +440,8 @@ function PodiumCard({
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#462123] dark:bg-[#230D0F]/70">
|
||||
<div className="text-sm text-[#5F6368]">第 {rank} 名</div>
|
||||
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#213D5E] dark:bg-[#02244C]/70">
|
||||
<div className="text-sm text-[#5F6368] dark:text-slate-400">第 {rank} 名</div>
|
||||
<div className="text-sm text-[#80868B]">暂无数据</div>
|
||||
</div>
|
||||
);
|
||||
@@ -450,8 +452,8 @@ function PodiumCard({
|
||||
type="button"
|
||||
onClick={() => onSelect(user)}
|
||||
className={cn(
|
||||
"flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#230D0F]",
|
||||
active ? "border-[#C1121F] ring-2 ring-[#F1D6D6] dark:border-[#F2B8BB] dark:ring-[#C1121F]/20" : "border-[#DADCE0] dark:border-[#462123]",
|
||||
"flex min-h-40 flex-col justify-between rounded-lg border bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:bg-[#02244C]",
|
||||
active ? "border-[#C1121F] dark:border-[#2563EB] ring-2 ring-[#F1D6D6] dark:border-[#7FACF2] dark:ring-[#2563EB]/20" : "border-[#DADCE0] dark:border-[#213D5E]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -462,11 +464,11 @@ function PodiumCard({
|
||||
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] text-base font-semibold text-white shadow-sm">
|
||||
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] dark:bg-[#2563EB] text-base font-semibold text-white shadow-sm">
|
||||
{initials(user)}
|
||||
</div>
|
||||
<div className="truncate font-semibold text-[#202124] dark:text-white">{user.email || user.user_id}</div>
|
||||
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#F2B8BB]">{scoreLabel(user, tab)}</div>
|
||||
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#7FACF2]">{scoreLabel(user, tab)}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||
<span>运行 {fmtNumber(user.run_count)}</span>
|
||||
<span>问答 {fmtNumber(user.qa_count)}</span>
|
||||
@@ -482,15 +484,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
|
||||
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
|
||||
const chartId = useId().replace(/:/g, "");
|
||||
if (points.length === 0) {
|
||||
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368]">暂无趋势数据</div>;
|
||||
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">暂无趋势数据</div>;
|
||||
}
|
||||
|
||||
const visible = points.slice(-32);
|
||||
const series = [
|
||||
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F]", description: "每天发起的运行次数" },
|
||||
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]", description: "每天产生的有效提问次数" },
|
||||
{ key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]", description: "每天有行为的用户数量" },
|
||||
{ key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED]", description: "每天模型输入输出 Token 总量" },
|
||||
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]", description: "每天发起的运行次数" },
|
||||
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E] dark:bg-teal-300/15 dark:text-teal-100", description: "每天产生的有效提问次数" },
|
||||
{ key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309] dark:bg-amber-300/15 dark:text-amber-100", description: "每天有行为的用户数量" },
|
||||
{ key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED] dark:bg-violet-300/15 dark:text-violet-100", description: "每天模型输入输出 Token 总量" },
|
||||
] as const;
|
||||
const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
|
||||
const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0];
|
||||
@@ -533,15 +535,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
|
||||
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
|
||||
large && "px-4 py-3",
|
||||
metric === "all"
|
||||
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]"
|
||||
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
|
||||
? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
|
||||
: "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F]">全部趋势</span>
|
||||
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]">全部趋势</span>
|
||||
<span className={cn("mt-2 block font-semibold tabular-nums text-[#231815] dark:text-white", large ? "text-2xl" : "text-lg")}>
|
||||
{fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))}
|
||||
</span>
|
||||
{large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#D8B8B8]">同时显示运行、问答、活跃与 Token</span>}
|
||||
{large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">同时显示运行、问答、活跃与 Token</span>}
|
||||
</button>
|
||||
)}
|
||||
{series.map((item) => (
|
||||
@@ -553,8 +555,8 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
|
||||
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
|
||||
large && "px-4 py-3",
|
||||
metric === item.key
|
||||
? "border-[#C1121F] bg-[#FFF1F1] shadow-sm dark:border-[#F2B8BB]/40 dark:bg-[#3A1215]"
|
||||
: "border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]",
|
||||
? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
|
||||
: "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
|
||||
)}
|
||||
title={item.description}
|
||||
>
|
||||
@@ -562,7 +564,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
|
||||
<span className={cn("mt-2 block truncate font-semibold tabular-nums text-[#111827] dark:text-white", large ? "text-2xl" : compact ? "text-base" : "text-lg")}>
|
||||
{fmtNumber(totalFor(item.key))}
|
||||
</span>
|
||||
{(large || compact) && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#D8B8B8]">{item.description}</span>}
|
||||
{(large || compact) && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -583,15 +585,15 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
|
||||
</DialogHeader>
|
||||
<div className="shrink-0">{metricCards({ includeAll: true, large: true })}</div>
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">当前查看</div>
|
||||
<div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">当前查看</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-[#111827] dark:text-white">{metric === "all" ? "全部指标" : activeSeries.label}</div>
|
||||
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
|
||||
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
|
||||
<div className="mt-2 text-4xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
|
||||
<div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
|
||||
<div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
|
||||
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
|
||||
</div>
|
||||
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -679,7 +681,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
|
||||
return (
|
||||
<div className="min-w-0 space-y-3">
|
||||
{metricCards({ includeAll: false })}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
<span>每个点代表 1 天;当前显示 {metric === "all" ? "全部指标" : activeSeries.label},覆盖 {activeDays} 个活跃日。</span>
|
||||
{analysisDialog(
|
||||
"个人使用趋势",
|
||||
@@ -693,7 +695,7 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -704,13 +706,13 @@ function TrendBars({ points, compact = false }: { points: AdminLeaderboardTrendP
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
|
||||
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
|
||||
<div className="mt-2 text-3xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
|
||||
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
|
||||
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
|
||||
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -735,11 +737,11 @@ function UserTable({
|
||||
}) {
|
||||
const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
|
||||
if (users.length === 0) {
|
||||
return <div className="py-14 text-center text-sm text-[#5F6368]">暂无用户统计</div>;
|
||||
return <div className="py-14 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无用户统计</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
{users.map((user, index) => {
|
||||
const score = userScore(user, tab);
|
||||
return (
|
||||
@@ -748,11 +750,11 @@ function UserTable({
|
||||
type="button"
|
||||
onClick={() => onSelect(user)}
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 hover:bg-[#FFF7F7] dark:border-[#462123] dark:hover:bg-[#19090A]",
|
||||
selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#C1121F]/10",
|
||||
"grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 hover:bg-[#FFF7F7] dark:border-[#213D5E] dark:hover:bg-[#011226]",
|
||||
selectedUserId === user.user_id && "bg-[#FFF1F1] dark:bg-[#2563EB]/10",
|
||||
)}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:bg-[#3A1215] dark:text-[#F2B8BB]">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#0D2D54] dark:text-[#7FACF2]">
|
||||
{rankOffset + index + 1}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -765,9 +767,9 @@ function UserTable({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
|
||||
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
|
||||
<div
|
||||
className="h-2 rounded-full bg-[#C1121F]"
|
||||
className="h-2 rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
|
||||
style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -801,7 +803,7 @@ function SkillRanking({
|
||||
}) {
|
||||
const max = Math.max(1, ...items.map((item) => item.call_count));
|
||||
return (
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
@@ -810,7 +812,7 @@ function SkillRanking({
|
||||
<Badge variant="outline">{items.length} 项</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>}
|
||||
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
|
||||
{items.slice(0, 10).map((item, index) => (
|
||||
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-3">
|
||||
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
|
||||
@@ -821,7 +823,7 @@ function SkillRanking({
|
||||
{fmtNumber(item.user_count)} 人
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
|
||||
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
|
||||
<div
|
||||
className="h-2 rounded-full"
|
||||
style={{
|
||||
@@ -859,7 +861,7 @@ function DimensionRanking({
|
||||
}) {
|
||||
const max = Math.max(1, ...items.map((item) => item.count));
|
||||
return (
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
@@ -868,7 +870,7 @@ function DimensionRanking({
|
||||
<Badge variant="outline">{items.length} 项</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>}
|
||||
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
|
||||
{items.slice(0, 8).map((item, index) => (
|
||||
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_88px] items-center gap-3">
|
||||
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
|
||||
@@ -879,7 +881,7 @@ function DimensionRanking({
|
||||
{fmtNumber(item.user_count)} 人
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#3A1215]">
|
||||
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
|
||||
<div
|
||||
className="h-2 rounded-full"
|
||||
style={{
|
||||
@@ -913,8 +915,8 @@ function QuestionPanel({
|
||||
showUser?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
|
||||
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
|
||||
@@ -922,8 +924,8 @@ function QuestionPanel({
|
||||
<Badge variant="outline">{items.length}</Badge>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="divide-y divide-[#F1D6D6] dark:divide-[#462123]">
|
||||
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
|
||||
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
|
||||
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
|
||||
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
|
||||
@@ -942,8 +944,8 @@ function QuestionPanel({
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#462123]">
|
||||
<div className="truncate text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">{label}</div>
|
||||
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
|
||||
<div className="truncate text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{label}</div>
|
||||
<div className="mt-1 truncate font-semibold text-[#231815] tabular-nums dark:text-white">{fmtNumber(value)}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -971,7 +973,7 @@ function UserDetailPanel({
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#5F6368]">
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载用户详情
|
||||
</div>
|
||||
@@ -981,13 +983,13 @@ function UserDetailPanel({
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold text-[#231815] dark:text-white">
|
||||
{data.user.email || data.user.user_id}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<div className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
最近活跃 {fmtTime(data.user.last_active_at)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -996,7 +998,7 @@ function UserDetailPanel({
|
||||
<ArrowRight className="ml-1 size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-4 overflow-hidden rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] text-sm dark:border-[#462123] dark:bg-[#19090A]">
|
||||
<div className="mt-4 grid grid-cols-4 overflow-hidden rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] text-sm dark:border-[#213D5E] dark:bg-[#011226]">
|
||||
<Metric label="问答次数" value={data.user.qa_count} />
|
||||
<Metric label="运行次数" value={data.user.run_count} />
|
||||
<Metric label="技能调用" value={data.user.skill_call_count} />
|
||||
@@ -1016,7 +1018,7 @@ function UserDetailPanel({
|
||||
</TabsContent>
|
||||
<TabsContent value="trend" className="min-h-[420px]">
|
||||
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
|
||||
<TrendingUp className="size-4 text-[#C1121F]" />
|
||||
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
个人使用趋势
|
||||
</div>
|
||||
<TrendBars points={data.trends} compact />
|
||||
@@ -1051,6 +1053,7 @@ export default function AdminUserLeaderboardPage() {
|
||||
[range],
|
||||
);
|
||||
const { data, isLoading, error } = useUserLeaderboard(apiRange, 100);
|
||||
const refreshToday = useRefreshTodayLeaderboard();
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const { models: reportModels, isLoading: reportModelsLoading } = useModels({ enabled: reportOpen });
|
||||
const [reportModelName, setReportModelName] = useState(DEFAULT_REPORT_MODEL);
|
||||
@@ -1226,31 +1229,38 @@ export default function AdminUserLeaderboardPage() {
|
||||
}, [apiRange.since, apiRange.until, reportModelName]);
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
|
||||
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#F2B8BB]">
|
||||
<Award className="size-4" />
|
||||
管理员分析
|
||||
{/* 整页统一滚动:标题与操作工具条不再固定置顶,随内容一起滚动,避免可视区域被压缩 */}
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 标题维度:仅承载页面标题与说明 */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
|
||||
<Award className="size-4" />
|
||||
管理员分析
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">用户排行榜</h1>
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
统计用户活跃、问答、Token 消耗、技能热度和近期问题,用于运营和审计。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate("/page/workspace/chats/new")}
|
||||
className="h-9"
|
||||
className="h-9 shrink-0"
|
||||
>
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
返回系统
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 操作维度:与标题区分开的控制工具条(操作按钮 + 时间筛选) */}
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 rounded-xl border border-[#F1D6D6] bg-white/70 px-3 py-3 dark:border-[#213D5E] dark:bg-[#02244C]/70">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1261,6 +1271,30 @@ export default function AdminUserLeaderboardPage() {
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refreshToday.isPending}
|
||||
onClick={() =>
|
||||
refreshToday.mutate(
|
||||
{ range: apiRange, limit: 100 },
|
||||
{
|
||||
onSuccess: () => toast.success("已刷新当天最新统计数据"),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "刷新当天数据失败"),
|
||||
},
|
||||
)
|
||||
}
|
||||
className="h-9"
|
||||
title="强制重算今天的统计,把最新的实时记录纳入榜单"
|
||||
>
|
||||
{refreshToday.isPending ? (
|
||||
<Loader2 className="mr-1 size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 size-4" />
|
||||
)}
|
||||
{refreshToday.isPending ? "刷新中" : "刷新当天"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1271,6 +1305,16 @@ export default function AdminUserLeaderboardPage() {
|
||||
<FileDown className="mr-1 size-4" />
|
||||
导出 Excel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open("#/public/leaderboard", "_blank", "noopener")}
|
||||
className="h-9"
|
||||
title="打开免登录的公开发布页"
|
||||
>
|
||||
<ExternalLink className="mr-1 size-4" />
|
||||
公开页
|
||||
</Button>
|
||||
<Dialog open={reportOpen} onOpenChange={handleReportOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={!data} className="h-9">
|
||||
@@ -1279,16 +1323,16 @@ export default function AdminUserLeaderboardPage() {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex h-[92vh] w-[96vw] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
|
||||
<DialogHeader className="shrink-0 border-b border-[#F1D6D6] px-6 py-5 pr-12 dark:border-[#462123]">
|
||||
<DialogHeader className="shrink-0 border-b border-[#F1D6D6] px-6 py-5 pr-12 dark:border-[#213D5E]">
|
||||
<DialogTitle>AI 榜单分析报告</DialogTitle>
|
||||
<DialogDescription>
|
||||
基于当前时间段的问答、用户、技能、模型和趋势统计生成运营分析。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mx-6 mt-4 flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="mx-6 mt-4 flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Select value={reportModelName} onValueChange={setReportModelName} disabled={reportGenerating || reportModelsLoading}>
|
||||
<SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#462123] dark:bg-[#19090A]">
|
||||
<SelectTrigger className="h-9 w-[260px] border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#011226]">
|
||||
<SelectValue placeholder="选择报告模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1302,7 +1346,7 @@ export default function AdminUserLeaderboardPage() {
|
||||
</Select>
|
||||
{reportStatus === "cached" && <Badge variant="outline">已读取缓存</Badge>}
|
||||
{reportResolvedModel && <Badge variant="outline">模型 {reportResolvedModel}</Badge>}
|
||||
{reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#D8B8B8]">生成于 {fmtTime(reportGeneratedAt)}</span>}
|
||||
{reportGeneratedAt && <span className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">生成于 {fmtTime(reportGeneratedAt)}</span>}
|
||||
</div>
|
||||
<Button size="sm" onClick={startReportGeneration} disabled={!data || reportGenerating}>
|
||||
{reportGenerating ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Sparkles className="mr-1 size-4" />}
|
||||
@@ -1311,24 +1355,24 @@ export default function AdminUserLeaderboardPage() {
|
||||
</div>
|
||||
{data && reportStatus === "idle" && !reportText && (
|
||||
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
|
||||
<MessageSquareText className="size-4 text-[#C1121F]" />
|
||||
<MessageSquareText className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
最近关注
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.recent_questions.slice(0, 3).map((item, index) => (
|
||||
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
|
||||
<span className="font-medium text-[#C1121F]">{fmtTime(item.asked_at)}</span>
|
||||
<span className="font-medium text-[#C1121F] dark:text-[#7FACF2]">{fmtTime(item.asked_at)}</span>
|
||||
<span className="ml-1">{clampText(item.text, 58)}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
|
||||
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
|
||||
<TrendingUp className="size-4 text-[#C1121F]" />
|
||||
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
高频问题
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -1337,34 +1381,34 @@ export default function AdminUserLeaderboardPage() {
|
||||
{index + 1}. {clampText(item.text, 72)}
|
||||
</div>
|
||||
))}
|
||||
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
|
||||
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
|
||||
<Sparkles className="size-4 text-[#C1121F]" />
|
||||
<Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
使用频率
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[...data.skills.slice(0, 2), ...data.tools.slice(0, 1)].map((item, index) => (
|
||||
<div key={`${item.name}-${index}`} className="flex items-center justify-between gap-2 text-xs leading-5 text-[#5F6368] dark:text-slate-300">
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className="shrink-0 font-semibold text-[#C1121F]">{fmtNumber(item.call_count)} 次</span>
|
||||
<span className="shrink-0 font-semibold text-[#C1121F] dark:text-[#7FACF2]">{fmtNumber(item.call_count)} 次</span>
|
||||
</div>
|
||||
))}
|
||||
{data.skills.length + data.tools.length === 0 && <div className="text-xs text-[#8B6B6B]">暂无数据</div>}
|
||||
{data.skills.length + data.tools.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className="mx-6 mt-4 min-h-0 flex-1 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-5 dark:border-[#462123] dark:bg-[#19090A]">
|
||||
<ScrollArea className="mx-6 mt-4 min-h-0 flex-1 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-5 dark:border-[#213D5E] dark:bg-[#011226]">
|
||||
{reportStatus === "idle" && !reportText && (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
选择模型后点击生成;同一时间段、同一模型和同一统计设置会直接读取已保存报告。
|
||||
</div>
|
||||
)}
|
||||
{reportGenerating && !reportText && (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在调用大模型生成报告,内容会流式显示
|
||||
</div>
|
||||
@@ -1375,13 +1419,13 @@ export default function AdminUserLeaderboardPage() {
|
||||
</div>
|
||||
)}
|
||||
{reportText && (
|
||||
<div className="rounded-lg bg-white px-8 py-6 text-base leading-8 text-[#231815] shadow-sm dark:bg-[#230D0F] dark:text-white [&_blockquote]:border-l-4 [&_blockquote]:border-[#C1121F] [&_blockquote]:bg-[#FFF7F7] [&_blockquote]:px-4 [&_blockquote]:py-3 [&_code]:rounded [&_code]:bg-[#FFF1F1] [&_code]:px-1.5 [&_h1]:mb-5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:mb-4 [&_h2]:mt-7 [&_h2]:border-b [&_h2]:border-[#F1D6D6] [&_h2]:pb-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mb-3 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold [&_li]:my-1.5 [&_ol]:pl-6 [&_p]:my-3 [&_strong]:text-[#C1121F] [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#F1D6D6] [&_td]:p-3 [&_th]:border [&_th]:border-[#F1D6D6] [&_th]:bg-[#FFF1F1] [&_th]:p-3 [&_ul]:pl-6 dark:[&_blockquote]:bg-[#19090A] dark:[&_code]:bg-[#19090A] dark:[&_strong]:text-[#F2B8BB] dark:[&_th]:bg-[#19090A]">
|
||||
<div className="rounded-lg bg-white px-8 py-6 text-base leading-8 text-[#231815] shadow-sm dark:bg-[#02244C] dark:text-white [&_blockquote]:border-l-4 [&_blockquote]:border-[#C1121F] dark:border-[#2563EB] [&_blockquote]:bg-[#FFF7F7] [&_blockquote]:px-4 [&_blockquote]:py-3 [&_code]:rounded [&_code]:bg-[#FFF1F1] [&_code]:px-1.5 [&_h1]:mb-5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:mb-4 [&_h2]:mt-7 [&_h2]:border-b [&_h2]:border-[#F1D6D6] [&_h2]:pb-3 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mb-3 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold [&_li]:my-1.5 [&_ol]:pl-6 [&_p]:my-3 [&_strong]:text-[#C1121F] dark:text-[#7FACF2] [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#F1D6D6] [&_td]:p-3 [&_th]:border [&_th]:border-[#F1D6D6] [&_th]:bg-[#FFF1F1] [&_th]:p-3 [&_ul]:pl-6 dark:[&_blockquote]:bg-[#011226] dark:[&_code]:bg-[#011226] dark:[&_strong]:text-[#7FACF2] dark:[&_th]:bg-[#011226]">
|
||||
<MarkdownContent content={reportText} isLoading={reportGenerating} rehypePlugins={[]} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
{reportText && (
|
||||
<div className="mt-4 flex shrink-0 justify-end border-t border-[#F1D6D6] px-6 py-4 dark:border-[#462123]">
|
||||
<div className="mt-4 flex shrink-0 justify-end border-t border-[#F1D6D6] px-6 py-4 dark:border-[#213D5E]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={exportReportAsWord}
|
||||
@@ -1394,6 +1438,8 @@ export default function AdminUserLeaderboardPage() {
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
|
||||
<Input
|
||||
@@ -1402,7 +1448,7 @@ export default function AdminUserLeaderboardPage() {
|
||||
onClick={(event) => event.currentTarget.showPicker?.()}
|
||||
onFocus={(event) => event.currentTarget.showPicker?.()}
|
||||
onChange={(event) => setRange((prev) => ({ ...prev, since: event.target.value }))}
|
||||
className="h-9 w-36 bg-white dark:bg-[#19090A]"
|
||||
className="h-9 w-36 bg-white dark:bg-[#011226]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -1413,7 +1459,7 @@ export default function AdminUserLeaderboardPage() {
|
||||
onClick={(event) => event.currentTarget.showPicker?.()}
|
||||
onFocus={(event) => event.currentTarget.showPicker?.()}
|
||||
onChange={(event) => setRange((prev) => ({ ...prev, until: event.target.value }))}
|
||||
className="h-9 w-36 bg-white dark:bg-[#19090A]"
|
||||
className="h-9 w-36 bg-white dark:bg-[#011226]"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setQuickRange(7)}>
|
||||
@@ -1425,19 +1471,19 @@ export default function AdminUserLeaderboardPage() {
|
||||
<Button variant="outline" size="sm" onClick={() => setQuickRange(90)}>
|
||||
近 90 天
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-5 p-6">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368]">
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368] dark:text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>正在统计排行榜数据</span>
|
||||
</div>
|
||||
<div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<div className="max-w-md text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
线上用户较多或时间范围较大时,统计时间可能较长,请耐心等待。为避免影响其他接口,系统会限制排行榜统计的并发数量。
|
||||
</div>
|
||||
</div>
|
||||
@@ -1450,12 +1496,12 @@ export default function AdminUserLeaderboardPage() {
|
||||
)}
|
||||
|
||||
{!isLoading && !error && leaderboardPending && !leaderboardHasRows && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-[#F1D6D6] bg-white px-6 py-16 text-center text-sm text-[#5F6368] shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-[#F1D6D6] bg-white px-6 py-16 text-center text-sm text-[#5F6368] shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="flex items-center gap-2 font-medium text-[#202124] dark:text-white">
|
||||
<Loader2 className="size-4 animate-spin text-[#C1121F]" />
|
||||
<Loader2 className="size-4 animate-spin text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
<span>排行榜正在后台统计</span>
|
||||
</div>
|
||||
<div className="max-w-lg text-xs leading-5 text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<div className="max-w-lg text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
{data?.message || "统计任务已放到后台执行,页面会自动刷新结果。这样排行榜慢慢算,不会长时间占住当前接口请求。"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1495,7 +1541,7 @@ export default function AdminUserLeaderboardPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
|
||||
@@ -1534,7 +1580,7 @@ export default function AdminUserLeaderboardPage() {
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索用户邮箱或 ID"
|
||||
className="h-9 w-64 bg-white pl-8 dark:bg-[#19090A]"
|
||||
className="h-9 w-64 bg-white pl-8 dark:bg-[#011226]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1558,8 +1604,8 @@ export default function AdminUserLeaderboardPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#462123]">
|
||||
<section className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
|
||||
<UserRound className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">用户详情</h2>
|
||||
</div>
|
||||
@@ -1575,7 +1621,7 @@ export default function AdminUserLeaderboardPage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
|
||||
@@ -61,17 +61,17 @@ export default function AdminUserLeaderboardSettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
|
||||
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<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]">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
管理员设置
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">排行榜设置</h1>
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
这里的规则会影响用户排行榜、趋势、技能、模型、问题与个人详情统计。
|
||||
</p>
|
||||
</div>
|
||||
@@ -86,14 +86,14 @@ export default function AdminUserLeaderboardSettingsPage() {
|
||||
|
||||
<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]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<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]" />
|
||||
<ShieldOff className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
统计排除名单
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。
|
||||
</p>
|
||||
</div>
|
||||
@@ -111,21 +111,21 @@ export default function AdminUserLeaderboardSettingsPage() {
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索账号邮箱或 ID"
|
||||
className="h-10 border-[#F1D6D6] bg-white pl-9"
|
||||
className="h-10 border-[#F1D6D6] bg-white pl-9 dark:border-[#213D5E] dark:bg-[#011226] dark:text-white"
|
||||
/>
|
||||
{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]">
|
||||
<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-[#213D5E] dark:bg-[#02244C]">
|
||||
<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]"
|
||||
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-[#213D5E] 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 className="block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">{user.id}</span>
|
||||
</span>
|
||||
<Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge>
|
||||
</button>
|
||||
@@ -134,15 +134,15 @@ export default function AdminUserLeaderboardSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#19090A]">
|
||||
<div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#011226]">
|
||||
{selectedUsers.length === 0 ? (
|
||||
<span className="text-sm text-[#8B6B6B]">暂无排除账号</span>
|
||||
<span className="text-sm text-[#8B6B6B] dark:text-[#7FACF2]">暂无排除账号</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]"
|
||||
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-[#213D5E] dark:bg-[#02244C] dark:text-[#7FACF2]"
|
||||
title={userLabel(user)}
|
||||
>
|
||||
<span className="truncate">{userLabel(user)}</span>
|
||||
@@ -157,14 +157,14 @@ export default function AdminUserLeaderboardSettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<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]" />
|
||||
<Save className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
统计规则
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。
|
||||
</p>
|
||||
</div>
|
||||
@@ -180,11 +180,11 @@ export default function AdminUserLeaderboardSettingsPage() {
|
||||
].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]"
|
||||
className="flex min-h-28 cursor-pointer items-start justify-between gap-4 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#213D5E] dark:bg-[#011226]"
|
||||
>
|
||||
<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 className="mt-1 block text-sm leading-6 text-[#7A5A5A] dark:text-[#7FACF2]">{desc}</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(settings?.[key as keyof typeof settings])}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
useThreadChat,
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
|
||||
import { KnowledgeRagToggle } from "@/components/workspace/knowledge-rag-toggle";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
@@ -41,9 +43,47 @@ import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BROWSER_CONTEXT_PARAM_KEYS = [
|
||||
"browser_context",
|
||||
"bc_filename",
|
||||
"bc_size",
|
||||
"bc_path",
|
||||
"bc_prompt",
|
||||
] as const;
|
||||
|
||||
function clearBrowserContextParamsFromHash() {
|
||||
if (typeof window === "undefined") return;
|
||||
const hash = window.location.hash;
|
||||
if (!hash.startsWith("#")) return;
|
||||
const hashBody = hash.slice(1);
|
||||
const queryStart = hashBody.indexOf("?");
|
||||
if (queryStart < 0) return;
|
||||
|
||||
const path = hashBody.slice(0, queryStart);
|
||||
const search = hashBody.slice(queryStart + 1);
|
||||
const params = new URLSearchParams(search);
|
||||
let changed = false;
|
||||
for (const key of BROWSER_CONTEXT_PARAM_KEYS) {
|
||||
if (params.has(key)) {
|
||||
params.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
|
||||
const nextSearch = params.toString();
|
||||
const nextHash = nextSearch ? `#${path}?${nextSearch}` : `#${path}`;
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}${nextHash}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
@@ -62,6 +102,7 @@ export default function ChatPage() {
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentHandoffPending, setAgentHandoffPending] = useState(false);
|
||||
const agentHandoffPendingRef = useRef(false);
|
||||
const browserContextAutoSubmitRef = useRef<string | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
@@ -242,6 +283,75 @@ export default function ChatPage() {
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get("browser_context")?.trim();
|
||||
if (!token || browserContextAutoSubmitRef.current === token) {
|
||||
return;
|
||||
}
|
||||
if (!threadId || isNewThread || thread.isLoading || agentHandoffPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumedKey = `deerflow.browser-context.consumed.${token}`;
|
||||
if (window.sessionStorage.getItem(consumedKey)) {
|
||||
browserContextAutoSubmitRef.current = token;
|
||||
clearBrowserContextParamsFromHash();
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = searchParams.get("bc_filename")?.trim();
|
||||
const path = searchParams.get("bc_path")?.trim();
|
||||
const size = Number(searchParams.get("bc_size") ?? "0");
|
||||
const prompt =
|
||||
searchParams.get("bc_prompt")?.trim() ||
|
||||
"请基于刚刚发送的网页内容,先用中文总结要点,并等待我继续提问。";
|
||||
|
||||
if (!filename || !path || !Number.isFinite(size) || size < 0) {
|
||||
browserContextAutoSubmitRef.current = token;
|
||||
clearBrowserContextParamsFromHash();
|
||||
toast.error("网页上下文参数不完整,无法自动发送到 Deer");
|
||||
return;
|
||||
}
|
||||
|
||||
browserContextAutoSubmitRef.current = token;
|
||||
window.sessionStorage.setItem(consumedKey, "1");
|
||||
clearBrowserContextParamsFromHash();
|
||||
setHasStartedConversation(true);
|
||||
|
||||
void sendMessage(
|
||||
threadId,
|
||||
{ text: prompt, files: [] },
|
||||
undefined,
|
||||
{
|
||||
additionalKwargs: {
|
||||
browser_context: {
|
||||
source: "chrome-extension",
|
||||
token,
|
||||
},
|
||||
files: [
|
||||
{
|
||||
filename,
|
||||
size,
|
||||
path,
|
||||
status: "uploaded",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
).catch((err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "网页上下文发送失败,请重试",
|
||||
);
|
||||
});
|
||||
}, [
|
||||
searchParams,
|
||||
threadId,
|
||||
isNewThread,
|
||||
thread.isLoading,
|
||||
agentHandoffPending,
|
||||
sendMessage,
|
||||
]);
|
||||
|
||||
// Persist the picked agent the moment it changes so the next new chat
|
||||
// defaults to it — even if the user never sends a message this session.
|
||||
// Passing null (the ✕ clear) wipes the stored preference.
|
||||
@@ -300,7 +410,9 @@ export default function ChatPage() {
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<KnowledgeRagToggle threadId={threadId} />
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<KnowledgeSaveTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,95 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useThreads } from "@/core/threads/hooks";
|
||||
import { usePathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
export default function ChatsPage() {
|
||||
const pathOfThread = usePathOfThread();
|
||||
const { t } = useI18n();
|
||||
const { data: threads } = useThreads();
|
||||
const [search, setSearch] = useState("");
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
||||
}, [t.pages.chats, t.pages.appName]);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
return threads?.filter((thread) => {
|
||||
return (
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable" &&
|
||||
titleOfThread(thread).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
});
|
||||
}, [threads, search]);
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader></WorkspaceHeader>
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full flex-col">
|
||||
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
|
||||
<div className="flex w-full max-w-(--container-width-md) items-center justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{t.threadShare.importChats}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
type="search"
|
||||
className="h-12 w-full max-w-(--container-width-md) text-xl"
|
||||
placeholder={t.chats.searchChats}
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1">
|
||||
<ScrollArea className="size-full py-4">
|
||||
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
|
||||
{filteredThreads?.map((thread) => (
|
||||
<Link key={thread.thread_id} href={pathOfThread(thread)}>
|
||||
<div className="flex flex-col gap-2 border-b p-4">
|
||||
<div>
|
||||
<div>{desensitize(titleOfThread(thread, t.pages.newChat))}</div>
|
||||
</div>
|
||||
{thread.updated_at && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatTimeAgo(thread.updated_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
<ImportConversationsDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
/>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
DownloadIcon,
|
||||
Pin,
|
||||
PinOff,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useThreads,
|
||||
useToggleThreadFavorite,
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
import {
|
||||
isThreadFavorite,
|
||||
isThreadPinned,
|
||||
usePathOfThread,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function ChatsPage() {
|
||||
const pathOfThread = usePathOfThread();
|
||||
const { t } = useI18n();
|
||||
const { data: threads } = useThreads();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
||||
const [search, setSearch] = useState("");
|
||||
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
||||
}, [t.pages.chats, t.pages.appName]);
|
||||
|
||||
// Reset to the first page whenever the filtered result set changes.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, onlyFavorites]);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
return (threads ?? []).filter((thread) => {
|
||||
if (
|
||||
thread.metadata?.thread_type === "scheduler" ||
|
||||
thread.metadata?.thread_type === "roundtable"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (onlyFavorites && !isThreadFavorite(thread)) {
|
||||
return false;
|
||||
}
|
||||
return titleOfThread(thread)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase());
|
||||
});
|
||||
}, [threads, search, onlyFavorites]);
|
||||
|
||||
// Pinned conversations are surfaced in their own section on the first page
|
||||
// only, and excluded from the paginated list (so they don't repeat on every
|
||||
// page as the user pages through history).
|
||||
const pinnedThreads = useMemo(
|
||||
() => filteredThreads.filter(isThreadPinned),
|
||||
[filteredThreads],
|
||||
);
|
||||
const restThreads = useMemo(
|
||||
() => filteredThreads.filter((t) => !isThreadPinned(t)),
|
||||
[filteredThreads],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(restThreads.length / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pagedThreads = restThreads.slice(
|
||||
(currentPage - 1) * PAGE_SIZE,
|
||||
currentPage * PAGE_SIZE,
|
||||
);
|
||||
|
||||
const renderThread = (thread: AgentThread) => {
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
<div
|
||||
key={thread.thread_id}
|
||||
className="group/chat-row relative flex flex-col gap-2 border-b"
|
||||
>
|
||||
<Link href={pathOfThread(thread)} className="block p-4 pr-24">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{pinned && (
|
||||
<Pin className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
</div>
|
||||
{thread.updated_at && (
|
||||
<div className="text-muted-foreground mt-2 text-sm">
|
||||
{formatTimeAgo(thread.updated_at)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
{/* Row actions — kept out of the Link so clicks don't navigate. */}
|
||||
<div className="absolute right-3 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover/chat-row:opacity-100 focus-within:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
title={favorited ? t.common.unfavorite : t.common.favorite}
|
||||
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"size-4",
|
||||
favorited
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
title={pinned ? t.common.unpin : t.common.pin}
|
||||
onClick={() => togglePin(thread.thread_id, !pinned)}
|
||||
>
|
||||
{pinned ? (
|
||||
<PinOff className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Pin className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isEmpty = filteredThreads.length === 0;
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader></WorkspaceHeader>
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full flex-col">
|
||||
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
|
||||
<div className="flex w-full max-w-(--container-width-md) items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={onlyFavorites ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setOnlyFavorites(false)}
|
||||
>
|
||||
{t.chats.allChats}
|
||||
</Button>
|
||||
<Button
|
||||
variant={onlyFavorites ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setOnlyFavorites(true)}
|
||||
>
|
||||
<Star className="size-4" />
|
||||
{t.chats.favorites}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{t.threadShare.importChats}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
type="search"
|
||||
className="h-12 w-full max-w-(--container-width-md) text-xl"
|
||||
placeholder={t.chats.searchChats}
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1">
|
||||
<ScrollArea className="size-full py-4">
|
||||
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
|
||||
{currentPage === 1 && pinnedThreads.length > 0 && (
|
||||
<>
|
||||
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t.chats.pinnedSection}
|
||||
</div>
|
||||
{pinnedThreads.map(renderThread)}
|
||||
</>
|
||||
)}
|
||||
{pagedThreads.map(renderThread)}
|
||||
{isEmpty && (
|
||||
<div className="text-muted-foreground py-16 text-center text-sm">
|
||||
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 py-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
{t.chats.prevPage}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() =>
|
||||
setPage((p) => Math.min(totalPages, p + 1))
|
||||
}
|
||||
>
|
||||
{t.chats.nextPage}
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
<ImportConversationsDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
/>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Network,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import {
|
||||
useKnowledgeActions,
|
||||
useKnowledgeDuplicates,
|
||||
useKnowledgeNote,
|
||||
useKnowledgeNotes,
|
||||
useNoteWikilinks,
|
||||
useVaultPage,
|
||||
} from "@/core/knowledge/hooks";
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import MarkdownRenderer, {
|
||||
type WikiLinkTarget,
|
||||
} from "@/strategy-components/MarkdownRenderer";
|
||||
|
||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
thread: "对话",
|
||||
search: "检索",
|
||||
tool: "工具",
|
||||
file: "文件",
|
||||
manual: "手动",
|
||||
report: "报告",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
approved: "已批准",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
/** Human label for an audit actor: prefer the resolved name (email), else a short id. */
|
||||
function actorLabel(name?: string | null, id?: string | null): string {
|
||||
if (name && name.trim()) return name.trim();
|
||||
if (id && id.trim()) return `${id.trim().slice(0, 8)}…`;
|
||||
return "—";
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
|
||||
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
|
||||
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
|
||||
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
|
||||
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
|
||||
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
|
||||
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
|
||||
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
|
||||
};
|
||||
|
||||
function entityMeta(type?: string | null) {
|
||||
return (
|
||||
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
|
||||
label: "其他",
|
||||
className: "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, delay = 350): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setDebounced(value), delay);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function NoteListItem({
|
||||
note,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
note: KnowledgeNote;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-1 rounded-md border p-3 text-left transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/70 hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="line-clamp-1 text-sm font-medium">{note.title}</span>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
|
||||
</Badge>
|
||||
</div>
|
||||
{note.summary ? (
|
||||
<span className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{note.summary}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{note.tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-muted-foreground ml-auto text-[10px]">
|
||||
{note.updated_at?.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateNoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { create } = useKnowledgeActions();
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setContent("");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
toast.error("请输入标题");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const note = await create.mutateAsync({
|
||||
title: title.trim(),
|
||||
content_md: content || `# ${title.trim()}\n\n`,
|
||||
tags: tags
|
||||
.split(/[,,\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
status: "approved",
|
||||
});
|
||||
toast.success("知识已创建");
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
navigate(`/page/workspace/knowledge/${note.id}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "创建失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建知识</DialogTitle>
|
||||
<DialogDescription>手动创建一条知识笔记,写入共享知识库 vault。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
placeholder="标题"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="标签,使用逗号分隔"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Markdown 正文"
|
||||
rows={12}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleSubmit()} disabled={create.isPending}>
|
||||
{create.isPending ? "创建中..." : "创建"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { batchIngest } = useKnowledgeActions();
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [skipExisting, setSkipExisting] = useState(true);
|
||||
// Optional model override for extraction (null = configured default).
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
|
||||
const handleRun = async () => {
|
||||
try {
|
||||
const result = await batchIngest.mutateAsync({
|
||||
limit,
|
||||
skip_existing: skipExisting,
|
||||
status: "approved",
|
||||
include_sources: true,
|
||||
model_name: model,
|
||||
});
|
||||
const base = `批量导入完成:新增 ${result.created},跳过 ${result.skipped},失败 ${result.failed}`;
|
||||
if (result.llm_fallback && result.llm_fallback > 0) {
|
||||
// The model failed on some threads — they were saved via rule-based
|
||||
// extraction instead. Say so explicitly per the requirement.
|
||||
toast.warning(
|
||||
`${base};其中 ${result.llm_fallback} 条因大模型异常改用了规则抽取(草稿)`,
|
||||
);
|
||||
} else {
|
||||
toast.success(base);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "批量导入失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入历史线程</DialogTitle>
|
||||
<DialogDescription>
|
||||
扫描你最近的对话线程并沉淀为知识库内容。已沉淀的线程可自动跳过。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
处理数量上限
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(Number(e.target.value) || 1)}
|
||||
className="w-28"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
跳过已沉淀线程
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipExisting}
|
||||
onChange={(e) => setSkipExisting(e.target.checked)}
|
||||
className="size-4"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
提炼大模型
|
||||
<ModelNameSelect
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={batchIngest.isPending}
|
||||
triggerClassName="h-9 w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleRun()} disabled={batchIngest.isPending}>
|
||||
{batchIngest.isPending ? "导入中..." : "开始导入"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DedupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { data: groups = [], isLoading } = useKnowledgeDuplicates(open);
|
||||
const { merge } = useKnowledgeActions();
|
||||
|
||||
const handleMerge = async (primaryId: string, duplicateIds: string[]) => {
|
||||
try {
|
||||
await merge.mutateAsync({ primaryId, duplicateIds });
|
||||
toast.success("已合并重复知识");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "合并失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>查重与合并</DialogTitle>
|
||||
<DialogDescription>
|
||||
基于标题/摘要相似度与内容哈希检测重复知识;合并会把重复项归档并把来源并入主条目。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground p-4 text-sm">检测中...</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
|
||||
未发现明显重复的知识。
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{groups.map((g) => (
|
||||
<li key={g.primary.id} className="rounded-md border p-3">
|
||||
<div className="mb-2 text-sm font-medium">
|
||||
保留:{g.primary.title || g.primary.id}
|
||||
</div>
|
||||
<ul className="mb-2 flex flex-col gap-1">
|
||||
{g.duplicates.map((d) => (
|
||||
<li key={d.id} className="text-muted-foreground text-xs">
|
||||
合并:{d.title || d.id}(相似度 {Math.round(d.score * 100)}%)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void handleMerge(
|
||||
g.primary.id,
|
||||
g.duplicates.map((d) => d.id),
|
||||
)
|
||||
}
|
||||
disabled={merge.isPending}
|
||||
>
|
||||
合并到主条目
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function VaultPageDialog({
|
||||
path,
|
||||
onOpenChange,
|
||||
}: {
|
||||
path: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { data: page, isLoading } = useVaultPage(path ?? undefined);
|
||||
return (
|
||||
<Dialog open={!!path} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{page?.title || "知识库页面"}</DialogTitle>
|
||||
<DialogDescription>{path}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="p-1">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground p-4 text-sm">加载中...</div>
|
||||
) : page ? (
|
||||
<MarkdownRenderer content={page.content_md} />
|
||||
) : (
|
||||
<div className="text-muted-foreground p-4 text-sm">页面不存在</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteDetail({ noteId }: { noteId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: note, isLoading } = useKnowledgeNote(noteId);
|
||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||
const { update, remove } = useKnowledgeActions();
|
||||
const [vaultPath, setVaultPath] = useState<string | null>(null);
|
||||
|
||||
const handleWikiLink = (link: WikiLinkTarget) => {
|
||||
if (link.kind === "note" && link.note_id) {
|
||||
navigate(`/page/workspace/knowledge/${link.note_id}`);
|
||||
} else if (link.kind === "vault" && link.vault_path) {
|
||||
setVaultPath(link.vault_path);
|
||||
}
|
||||
};
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [draftTags, setDraftTags] = useState("");
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [draftStatus, setDraftStatus] = useState("approved");
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setVaultPath(null);
|
||||
}, [noteId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (note) {
|
||||
setDraftTitle(note.title);
|
||||
setDraftTags(note.tags.join(", "));
|
||||
setDraftContent(note.content_md);
|
||||
setDraftStatus(note.status);
|
||||
}
|
||||
}, [note]);
|
||||
|
||||
if (isLoading || !note) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
{isLoading ? "加载中..." : "知识不存在"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
noteId: note.id,
|
||||
data: {
|
||||
title: draftTitle.trim() || note.title,
|
||||
content_md: draftContent,
|
||||
tags: draftTags
|
||||
.split(/[,,\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
status: draftStatus,
|
||||
},
|
||||
});
|
||||
toast.success("已保存");
|
||||
setEditing(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
await remove.mutateAsync({ noteId: note.id });
|
||||
toast.success("已归档");
|
||||
navigate("/page/workspace/knowledge");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "归档失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{STATUS_LABELS[note.status] ?? note.status}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
|
||||
<X className="size-4" /> 取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleSave()} disabled={update.isPending}>
|
||||
<Save className="size-4" /> 保存
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
|
||||
<Pencil className="size-4" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => void handleArchive()}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
<Trash2 className="size-4" /> 归档
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-4">
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
value={draftTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
placeholder="标题"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={draftTags}
|
||||
onChange={(e) => setDraftTags(e.target.value)}
|
||||
placeholder="标签,逗号分隔"
|
||||
/>
|
||||
<Select value={draftStatus} onValueChange={setDraftStatus}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="approved">已批准</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Textarea
|
||||
rows={20}
|
||||
value={draftContent}
|
||||
onChange={(e) => setDraftContent(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="mb-3 text-xl font-semibold">{note.title}</h1>
|
||||
|
||||
{note.tags.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||
{note.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="border-primary/20 bg-primary/5 text-primary/90 hover:bg-primary/10 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{note.entities && note.entities.length > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted-foreground mr-0.5 inline-flex items-center gap-1 text-xs">
|
||||
<Sparkles className="size-3" /> 实体
|
||||
</span>
|
||||
{note.entities.map((ent) => {
|
||||
const meta = entityMeta(ent.entity_type);
|
||||
return (
|
||||
<span
|
||||
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
||||
title={meta.label}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
|
||||
meta.className,
|
||||
)}
|
||||
>
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
|
||||
<span>{ent.name}</span>
|
||||
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
|
||||
{meta.label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MarkdownRenderer
|
||||
content={note.content_md}
|
||||
wikiLinks={wikiLinks}
|
||||
onWikiLinkClick={handleWikiLink}
|
||||
/>
|
||||
|
||||
{note.sources && note.sources.length > 0 ? (
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<h2 className="mb-2 text-sm font-semibold">来源</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{note.sources.map((src) => (
|
||||
<li key={src.id} className="text-sm">
|
||||
{src.url ? (
|
||||
<a
|
||||
href={src.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{src.title || src.url}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
) : src.thread_id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary hover:underline"
|
||||
onClick={() =>
|
||||
navigate(`/page/workspace/chats/${src.thread_id}`)
|
||||
}
|
||||
>
|
||||
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
|
||||
</button>
|
||||
) : (
|
||||
<span>{src.title || src.tool_name || "来源"}</span>
|
||||
)}
|
||||
{src.snippet ? (
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
|
||||
{src.snippet}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="text-muted-foreground mt-6 border-t pt-3 text-xs">
|
||||
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
|
||||
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
|
||||
{note.updated_at?.slice(0, 19).replace("T", " ")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<VaultPageDialog
|
||||
path={vaultPath}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVaultPath(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const navigate = useNavigate();
|
||||
const { noteId } = useParams<{ noteId: string }>();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const debouncedKeyword = useDebounced(keyword);
|
||||
const [sourceType, setSourceType] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [dedupOpen, setDedupOpen] = useState(false);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
keyword: debouncedKeyword || undefined,
|
||||
source_type: sourceType === "all" ? undefined : sourceType,
|
||||
status: status === "all" ? undefined : status,
|
||||
limit: 50,
|
||||
}),
|
||||
[debouncedKeyword, sourceType, status],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useKnowledgeNotes(params);
|
||||
const notes = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<header className="flex flex-wrap items-center gap-2 border-b p-3">
|
||||
<BookOpen className="size-5" />
|
||||
<h1 className="mr-2 text-base font-semibold">知识库</h1>
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索标题 / 摘要 / 正文 / 标签"
|
||||
className="w-64 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={sourceType} onValueChange={setSourceType}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue placeholder="来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部来源</SelectItem>
|
||||
{Object.entries(SOURCE_TYPE_LABELS).map(([v, label]) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="approved">已批准</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
|
||||
<RefreshCw className="size-4" /> 批量导入
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setDedupOpen(true)}>
|
||||
<Copy className="size-4" /> 查重
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate("/page/workspace/knowledge/graph")}
|
||||
>
|
||||
<Network className="size-4" /> 图谱
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" /> 新建
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="flex min-h-0 flex-col border-r">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
|
||||
暂无知识。点击「新建」或在对话页「保存到知识库」。
|
||||
</div>
|
||||
) : (
|
||||
notes.map((note) => (
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
active={note.id === noteId}
|
||||
onClick={() => navigate(`/page/workspace/knowledge/${note.id}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{data ? (
|
||||
<div className="text-muted-foreground border-t p-2 text-center text-xs">
|
||||
共 {data.total} 条知识
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0">
|
||||
{noteId ? (
|
||||
<NoteDetail noteId={noteId} />
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
从左侧选择一条知识查看详情
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
|
||||
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ArrowLeft, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { knowledgeExportFileUrl } from "@/core/knowledge/api";
|
||||
import { useKnowledgeActions } from "@/core/knowledge/hooks";
|
||||
|
||||
export default function KnowledgeGraphPage() {
|
||||
const navigate = useNavigate();
|
||||
const { exportGraph } = useKnowledgeActions();
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null);
|
||||
|
||||
const regenerate = useCallback(async () => {
|
||||
try {
|
||||
const result = await exportGraph.mutateAsync();
|
||||
setStats(result.stats);
|
||||
// Cache-bust the iframe so it reflects the freshly generated graph.
|
||||
setSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成图谱失败");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void regenerate();
|
||||
}, [regenerate]);
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<header className="flex items-center gap-2 border-b p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/page/workspace/knowledge")}
|
||||
>
|
||||
<ArrowLeft className="size-4" /> 返回
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">知识图谱</h1>
|
||||
{stats ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{stats.nodes} 个节点 · {stats.edges} 条关系
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => void regenerate()}
|
||||
disabled={exportGraph.isPending}
|
||||
>
|
||||
{exportGraph.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
重新生成
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1">
|
||||
{src ? (
|
||||
<iframe
|
||||
src={src}
|
||||
title="知识图谱"
|
||||
className="size-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" /> 生成图谱中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -105,6 +105,7 @@ export default function NewAgentPage() {
|
||||
return enabledSkills.filter(
|
||||
(skill) =>
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [enabledSkills, skillSearch]);
|
||||
@@ -590,7 +591,9 @@ export default function NewAgentPage() {
|
||||
!checked && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{skill.name}</span>
|
||||
<span className="truncate">
|
||||
{skill.name_zh?.trim() ? skill.name_zh : skill.name}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -35,7 +35,11 @@ const errorToastDarkOptions = {
|
||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||
} as const;
|
||||
|
||||
export default function PrivateSquareSettingsPage() {
|
||||
export default function PrivateSquareSettingsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { data, isLoading, error } = usePrivateSquareStatus();
|
||||
const update = useUpdatePrivateSquareSettings();
|
||||
const { theme } = useTheme();
|
||||
@@ -95,9 +99,15 @@ export default function PrivateSquareSettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? "flex w-full flex-col gap-6"
|
||||
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">私密广场配置</h1>
|
||||
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>私密广场配置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场;
|
||||
其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。
|
||||
|
||||
@@ -35,7 +35,11 @@ const errorToastDarkOptions = {
|
||||
},
|
||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||
} as const;
|
||||
export default function PromptPrefixSettingsPage() {
|
||||
export default function PromptPrefixSettingsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { data, isLoading, error } = usePromptPrefixSettings();
|
||||
const update = useUpdatePromptPrefixSettings();
|
||||
const { theme } = useTheme();
|
||||
@@ -83,9 +87,15 @@ export default function PromptPrefixSettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? "flex w-full flex-col gap-6"
|
||||
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">问答前缀配置</h1>
|
||||
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>问答前缀配置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
|
||||
会将下方配置的前缀文本和原问题拼接后再发送给模型。
|
||||
|
||||
@@ -0,0 +1,947 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Award,
|
||||
BarChart3,
|
||||
Bot,
|
||||
CalendarDays,
|
||||
Crown,
|
||||
Gauge,
|
||||
Layers3,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Medal,
|
||||
MessageSquareText,
|
||||
MousePointerClick,
|
||||
Search,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type {
|
||||
AdminLeaderboardDimension,
|
||||
AdminLeaderboardOverview,
|
||||
AdminLeaderboardQuestion,
|
||||
AdminLeaderboardResponse,
|
||||
AdminLeaderboardSkill,
|
||||
AdminLeaderboardTrendPoint,
|
||||
AdminLeaderboardUser,
|
||||
} from "@/core/admin/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
|
||||
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
|
||||
|
||||
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
|
||||
const LEADERBOARD_PAGE_SIZE = 20;
|
||||
const RANGE_DAYS = 30;
|
||||
|
||||
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
||||
crimson: {
|
||||
soft: "bg-[#FFF1F1] dark:bg-[#2563EB]/15",
|
||||
text: "text-[#C1121F] dark:text-[#7FACF2]",
|
||||
bar: "bg-[#C1121F] dark:bg-[#2563EB]",
|
||||
border: "border-[#F1D6D6] dark:border-[#213D5E]",
|
||||
},
|
||||
rose: {
|
||||
soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
|
||||
text: "text-[#E11D48] dark:text-rose-200",
|
||||
bar: "bg-[#E11D48]",
|
||||
border: "border-[#FECDD3] dark:border-rose-300/25",
|
||||
},
|
||||
amber: {
|
||||
soft: "bg-[#FFFBEB] dark:bg-amber-300/15",
|
||||
text: "text-[#B45309] dark:text-amber-100",
|
||||
bar: "bg-[#F59E0B]",
|
||||
border: "border-[#FDE68A] dark:border-amber-200/25",
|
||||
},
|
||||
teal: {
|
||||
soft: "bg-[#ECFDF5] dark:bg-teal-300/15",
|
||||
text: "text-[#0F766E] dark:text-teal-100",
|
||||
bar: "bg-[#0F766E]",
|
||||
border: "border-[#A7F3D0] dark:border-teal-200/25",
|
||||
},
|
||||
};
|
||||
|
||||
function fmtNumber(value?: number | null): string {
|
||||
return Number(value ?? 0).toLocaleString();
|
||||
}
|
||||
|
||||
function fmtTime(value?: string | null): string {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function fmtDate(value?: string | null): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function clampText(value: string, max = 120): string {
|
||||
const text = value.trim();
|
||||
return text.length > max ? `${text.slice(0, max)}...` : text;
|
||||
}
|
||||
|
||||
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
|
||||
if (tab === "questions") return user.qa_count;
|
||||
if (tab === "tokens") return user.total_tokens;
|
||||
if (tab === "skills") return user.skill_call_count;
|
||||
if (tab === "efficiency") {
|
||||
return user.run_count > 0 ? Math.round((user.qa_count / user.run_count) * 100) : 0;
|
||||
}
|
||||
return user.run_count + user.tool_call_count;
|
||||
}
|
||||
|
||||
function scoreLabel(user: AdminLeaderboardUser, tab: UserRankTab): string {
|
||||
if (tab === "questions") return `${fmtNumber(user.qa_count)} 次问答`;
|
||||
if (tab === "tokens") return `${fmtNumber(user.total_tokens)} tokens`;
|
||||
if (tab === "skills") return `${fmtNumber(user.skill_call_count)} 次技能`;
|
||||
if (tab === "efficiency") {
|
||||
return `${(user.run_count > 0 ? user.qa_count / user.run_count : 0).toFixed(1)} 问答/运行`;
|
||||
}
|
||||
return `${fmtNumber(userScore(user, tab))} 次使用`;
|
||||
}
|
||||
|
||||
function rankTabDescription(tab: UserRankTab): string {
|
||||
if (tab === "questions") return "问答排行按用户提出有效问题的运行次数排序。";
|
||||
if (tab === "tokens") return "消耗排行按模型输入与输出 Token 总量排序。";
|
||||
if (tab === "skills") return "技能排行按带有技能名称的工具调用次数排序。";
|
||||
if (tab === "efficiency") return "效率排行 = 问答次数 / 运行次数,用来衡量每次运行承载的问答密度。";
|
||||
return "使用排行按运行次数与工具调用次数合计排序。";
|
||||
}
|
||||
|
||||
function initials(user: AdminLeaderboardUser): string {
|
||||
const source = user.email || user.user_id || "?";
|
||||
return source.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
async function fetchPublicLeaderboard(days: number): Promise<AdminLeaderboardResponse> {
|
||||
const res = await fetch(
|
||||
`${getBackendBaseURL()}/api/public/leaderboard?days=${days}&limit=100`,
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `加载排行榜失败 (HTTP ${res.status})`);
|
||||
}
|
||||
return (await res.json()) as AdminLeaderboardResponse;
|
||||
}
|
||||
|
||||
export default function PublicLeaderboardPage() {
|
||||
const [data, setData] = useState<AdminLeaderboardResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<UserRankTab>("activity");
|
||||
const [query, setQuery] = useState("");
|
||||
const [userPage, setUserPage] = useState(1);
|
||||
const days = RANGE_DAYS;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchPublicLeaderboard(days)
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setData(res);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [days]);
|
||||
|
||||
const pending = data?.status === "running" || data?.status === "refreshing";
|
||||
|
||||
// The snapshot may still be computing on first load — poll briefly until ready.
|
||||
useEffect(() => {
|
||||
if (!pending) return;
|
||||
const timer = setInterval(() => {
|
||||
fetchPublicLeaderboard(days)
|
||||
.then((res) => setData(res))
|
||||
.catch(() => undefined);
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [pending, days]);
|
||||
|
||||
const rankedUsers = useMemo(() => {
|
||||
const base = data?.users_by_activity ?? [];
|
||||
const source =
|
||||
tab === "questions"
|
||||
? data?.users_by_questions
|
||||
: tab === "tokens"
|
||||
? data?.users_by_tokens
|
||||
: tab === "skills"
|
||||
? [...base].sort((a, b) => b.skill_call_count - a.skill_call_count)
|
||||
: tab === "efficiency"
|
||||
? [...base].sort((a, b) => userScore(b, "efficiency") - userScore(a, "efficiency"))
|
||||
: data?.users_by_activity;
|
||||
const needle = query.trim().toLowerCase();
|
||||
return (source ?? []).filter((user) =>
|
||||
needle ? `${user.email} ${user.user_id}`.toLowerCase().includes(needle) : true,
|
||||
);
|
||||
}, [data, query, tab]);
|
||||
|
||||
const overview = data?.overview;
|
||||
const hasRows = rankedUsers.length > 0 || (overview?.total_runs ?? 0) > 0 || (overview?.total_users ?? 0) > 0;
|
||||
const topUsers = rankedUsers.slice(0, 3);
|
||||
const userPageCount = Math.max(1, Math.ceil(rankedUsers.length / LEADERBOARD_PAGE_SIZE));
|
||||
const visibleUsers = useMemo(() => {
|
||||
const start = (userPage - 1) * LEADERBOARD_PAGE_SIZE;
|
||||
return rankedUsers.slice(start, start + LEADERBOARD_PAGE_SIZE);
|
||||
}, [rankedUsers, userPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setUserPage(1);
|
||||
}, [query, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
setUserPage((page) => Math.min(page, userPageCount));
|
||||
}, [userPageCount]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-[#FFF7F7] text-[#231815] dark:bg-[#011226] dark:text-white">
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
{/* 标题区:仅展示,不含任何操作按钮 */}
|
||||
<header className="mb-6">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A] dark:text-[#7FACF2]">
|
||||
<Award className="size-4" />
|
||||
公开榜单
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal sm:text-3xl">用户排行榜</h1>
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
展示近 {data?.range_days ?? days} 天用户活跃、问答、Token 消耗、技能与工具热度、模型与 Agent 使用及近期问题。
|
||||
{data?.since && data?.until && (
|
||||
<span className="ml-1">
|
||||
({fmtDate(data.since).slice(0, 10)} ~ {fmtDate(data.until).slice(0, 10)})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{loading && !data && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-sm text-[#5F6368] dark:text-slate-400">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
正在加载排行榜数据
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-5 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
|
||||
加载失败:{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && overview && (
|
||||
<div className="space-y-6">
|
||||
{/* 仅在首次统计、尚无数据时提示;已有数据时后台静默刷新,不再常驻横幅 */}
|
||||
{pending && !hasRows && (
|
||||
<div className="rounded-lg border border-[#F1D6D6] bg-white px-4 py-3 text-sm text-[#7A5A5A] dark:border-[#213D5E] dark:bg-[#02244C] dark:text-[#7FACF2]">
|
||||
<Loader2 className="mr-1 inline size-4 animate-spin" />
|
||||
排行榜正在后台统计,数据会自动刷新。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 概览指标 */}
|
||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard icon={Users} accent="crimson" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
|
||||
<StatCard icon={MousePointerClick} accent="teal" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
|
||||
<StatCard icon={MessageSquareText} accent="amber" label="问答次数" value={fmtNumber(overview.total_questions)} note={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
|
||||
<StatCard icon={Sparkles} accent="rose" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
|
||||
</section>
|
||||
|
||||
{/* 衍生洞察 */}
|
||||
<InsightStrip overview={overview} />
|
||||
|
||||
{/* 榜单前三 */}
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
|
||||
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">{rankTabDescription(tab)}</p>
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="activity">使用排行</TabsTrigger>
|
||||
<TabsTrigger value="questions">问答排行</TabsTrigger>
|
||||
<TabsTrigger value="tokens">消耗排行</TabsTrigger>
|
||||
<TabsTrigger value="skills">技能排行</TabsTrigger>
|
||||
<TabsTrigger value="efficiency">效率排行</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<PodiumCard user={topUsers[0]} rank={1} tab={tab} />
|
||||
<PodiumCard user={topUsers[1]} rank={2} tab={tab} />
|
||||
<PodiumCard user={topUsers[2]} rank={3} tab={tab} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 完整排行榜 */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">完整排行榜</h2>
|
||||
<p className="mt-1 text-xs text-[#6B7280] dark:text-slate-400">{rankTabDescription(tab)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索用户"
|
||||
className="h-9 w-56 bg-white pl-8 dark:bg-[#011226]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UserTable users={visibleUsers} tab={tab} rankOffset={(userPage - 1) * LEADERBOARD_PAGE_SIZE} />
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-[#6B7280] dark:text-slate-400">
|
||||
<span>
|
||||
共 {fmtNumber(rankedUsers.length)} 位用户,当前第 {userPage} / {userPageCount} 页
|
||||
</span>
|
||||
<Pagination
|
||||
page={userPage}
|
||||
pageSize={LEADERBOARD_PAGE_SIZE}
|
||||
total={rankedUsers.length}
|
||||
onPageChange={setUserPage}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 时间趋势 */}
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">时间趋势</h2>
|
||||
</div>
|
||||
{data.since && data.until && (
|
||||
<div className="flex items-center gap-2 text-xs text-[#5F6368] dark:text-slate-400">
|
||||
<CalendarDays className="size-3.5" />
|
||||
{fmtDate(data.since).slice(0, 10)} 至 {fmtDate(data.until).slice(0, 10)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TrendBars points={data.trends} />
|
||||
</section>
|
||||
|
||||
{/* 技能与工具热度 */}
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" />
|
||||
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" />
|
||||
</div>
|
||||
|
||||
{/* 模型 / Agent / 运行状态 */}
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
<DimensionRanking title="模型使用排行" icon={Layers3} items={data.models} empty="当前时间段暂无模型调用" />
|
||||
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
|
||||
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
|
||||
</div>
|
||||
|
||||
{/* 问题 */}
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
|
||||
<QuestionPanel title="最新问题" items={data.recent_questions} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="mt-10 border-t border-[#F1D6D6] pt-4 text-center text-xs text-[#8B6B6B] dark:border-[#213D5E] dark:text-[#7FACF2]">
|
||||
{data?.generated_at ? `数据生成于 ${fmtDate(data.generated_at)}` : "仅供查看"}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
accent,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
note: string;
|
||||
accent: LeaderboardAccent;
|
||||
}) {
|
||||
const color = accentClasses[accent];
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-white p-4 shadow-sm dark:bg-[#02244C]", color.border)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-[#5F6368] dark:text-slate-300">{label}</span>
|
||||
<span className={cn("flex size-8 items-center justify-center rounded-md", color.soft, color.text)}>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-2xl font-semibold tracking-normal text-[#202124] tabular-nums dark:text-white">
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">{note}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InsightStrip({ overview }: { overview: AdminLeaderboardOverview }) {
|
||||
const activeUsers = Math.max(1, overview.active_users);
|
||||
const totalRuns = Math.max(1, overview.total_runs);
|
||||
const items = [
|
||||
{
|
||||
label: "人均使用",
|
||||
value: overview.total_runs / activeUsers,
|
||||
unit: "次/活跃用户",
|
||||
description: "当前时间段内,平均每位活跃用户发起了多少次运行。",
|
||||
},
|
||||
{
|
||||
label: "问答密度",
|
||||
value: overview.total_questions / totalRuns,
|
||||
unit: "问答/运行",
|
||||
description: "问答次数 ÷ 运行次数,用来判断每次使用承载了多少有效提问。",
|
||||
},
|
||||
{
|
||||
label: "平均 Token",
|
||||
value: overview.total_tokens / totalRuns,
|
||||
unit: "Token/运行",
|
||||
description: "总 Token ÷ 运行次数,反映单次使用的模型消耗强度。",
|
||||
},
|
||||
{
|
||||
label: "技能覆盖",
|
||||
value: overview.total_tool_calls ? (overview.total_skill_calls / overview.total_tool_calls) * 100 : 0,
|
||||
unit: "%",
|
||||
description: "技能调用 ÷ 工具总调用,越高说明用户越多依赖已配置技能能力。",
|
||||
},
|
||||
{
|
||||
label: "失败率",
|
||||
value: (overview.error_runs / totalRuns) * 100,
|
||||
unit: "%",
|
||||
description: "失败运行 ÷ 总运行,用来快速发现稳定性或配置问题。",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<section className="grid gap-3 md:grid-cols-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-[#F1D6D6] bg-white p-3 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]" title={item.description}>
|
||||
<div className="text-xs font-medium text-[#7A5A5A] dark:text-[#7FACF2]">{item.label}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums text-[#231815] dark:text-white">
|
||||
{Number(item.value).toFixed(item.unit === "%" || String(item.value).includes(".") ? 1 : 0)}
|
||||
<span className="ml-1 text-xs font-normal text-[#8B6B6B] dark:text-[#7FACF2]">{item.unit}</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PodiumCard({
|
||||
user,
|
||||
rank,
|
||||
tab,
|
||||
}: {
|
||||
user?: AdminLeaderboardUser;
|
||||
rank: 1 | 2 | 3;
|
||||
tab: UserRankTab;
|
||||
}) {
|
||||
const styles = {
|
||||
1: "border-[#FEEFC3] bg-[#FEF7E0] text-[#B06000] dark:border-[#F4B400]/40 dark:bg-[#F4B400]/10 dark:text-[#FDD663]",
|
||||
2: "border-[#E5E7EB] bg-[#F8FAFC] text-[#4B5563] dark:border-[#6B7280]/40 dark:bg-white/5 dark:text-[#E5E7EB]",
|
||||
3: "border-[#FAD2CF] bg-[#FCE8E6] text-[#DB4437] dark:border-[#DB4437]/40 dark:bg-[#DB4437]/10 dark:text-[#F28B82]",
|
||||
}[rank];
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-dashed border-[#DADCE0] bg-white/70 p-4 dark:border-[#213D5E] dark:bg-[#02244C]/70">
|
||||
<div className="text-sm text-[#5F6368] dark:text-slate-400">第 {rank} 名</div>
|
||||
<div className="text-sm text-[#80868B]">暂无数据</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-40 flex-col justify-between rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium", styles)}>
|
||||
{rank === 1 ? <Crown className="size-3.5" /> : <Medal className="size-3.5" />}
|
||||
第 {rank} 名
|
||||
</span>
|
||||
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 flex size-11 items-center justify-center rounded-lg bg-[#C1121F] dark:bg-[#2563EB] text-base font-semibold text-white shadow-sm">
|
||||
{initials(user)}
|
||||
</div>
|
||||
<div className="truncate font-semibold text-[#202124] dark:text-white">{user.email || user.user_id}</div>
|
||||
<div className="mt-1 text-sm font-medium text-[#C1121F] dark:text-[#7FACF2]">{scoreLabel(user, tab)}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||
<span>运行 {fmtNumber(user.run_count)}</span>
|
||||
<span>问答 {fmtNumber(user.qa_count)}</span>
|
||||
<span>技能 {fmtNumber(user.skill_call_count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserTable({
|
||||
users,
|
||||
tab,
|
||||
rankOffset = 0,
|
||||
}: {
|
||||
users: AdminLeaderboardUser[];
|
||||
tab: UserRankTab;
|
||||
rankOffset?: number;
|
||||
}) {
|
||||
const max = Math.max(1, ...users.map((user) => userScore(user, tab)));
|
||||
if (users.length === 0) {
|
||||
return <div className="py-14 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无用户统计</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
{users.map((user, index) => {
|
||||
const score = userScore(user, tab);
|
||||
return (
|
||||
<div
|
||||
key={user.user_id}
|
||||
className="grid w-full grid-cols-[44px_minmax(0,1fr)_minmax(160px,240px)_120px] items-center gap-4 border-b border-[#F1D6D6] px-4 py-3 text-left last:border-b-0 dark:border-[#213D5E]"
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-[#FFF1F1] text-sm font-semibold text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#0D2D54] dark:text-[#7FACF2]">
|
||||
{rankOffset + index + 1}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-[#202124] dark:text-white">{user.email || user.user_id}</span>
|
||||
{user.system_role === "admin" && <Badge variant="outline">管理员</Badge>}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||
最近活跃 {fmtTime(user.last_active_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
|
||||
<div
|
||||
className="h-2 rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
|
||||
style={{ width: `${Math.max(4, (score / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||
会话 {fmtNumber(user.thread_count)} · LLM {fmtNumber(user.llm_call_count)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-[#202124] tabular-nums dark:text-white">{scoreLabel(user, tab)}</div>
|
||||
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||
{fmtNumber(user.total_tokens)} tokens
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRanking({
|
||||
title,
|
||||
icon: Icon,
|
||||
items,
|
||||
empty,
|
||||
}: {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
items: AdminLeaderboardSkill[];
|
||||
empty: string;
|
||||
}) {
|
||||
const max = Math.max(1, ...items.map((item) => item.call_count));
|
||||
return (
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
|
||||
</div>
|
||||
<Badge variant="outline">{items.length} 项</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
|
||||
{items.slice(0, 10).map((item, index) => (
|
||||
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-3">
|
||||
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-[#202124] dark:text-slate-100">{item.name}</span>
|
||||
<span className="text-xs text-[#5F6368] dark:text-slate-400">
|
||||
{fmtNumber(item.user_count)} 人
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
|
||||
<div
|
||||
className="h-2 rounded-full"
|
||||
style={{
|
||||
width: `${Math.max(4, (item.call_count / max) * 100)}%`,
|
||||
backgroundColor: RANK_COLORS[index % RANK_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold tabular-nums text-[#202124] dark:text-white">
|
||||
{fmtNumber(item.call_count)}
|
||||
</div>
|
||||
<div className="text-xs text-[#5F6368] dark:text-slate-400">
|
||||
失败 {fmtNumber(item.error_count)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DimensionRanking({
|
||||
title,
|
||||
icon: Icon,
|
||||
items,
|
||||
empty,
|
||||
}: {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
items: AdminLeaderboardDimension[];
|
||||
empty: string;
|
||||
}) {
|
||||
const max = Math.max(1, ...items.map((item) => item.count));
|
||||
return (
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
|
||||
</div>
|
||||
<Badge variant="outline">{items.length} 项</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
|
||||
{items.slice(0, 8).map((item, index) => (
|
||||
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_88px] items-center gap-3">
|
||||
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-[#202124] dark:text-slate-100">{item.name}</span>
|
||||
<span className="text-xs text-[#5F6368] dark:text-slate-400">
|
||||
{fmtNumber(item.user_count)} 人
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
|
||||
<div
|
||||
className="h-2 rounded-full"
|
||||
style={{
|
||||
width: `${Math.max(4, (item.count / max) * 100)}%`,
|
||||
backgroundColor: RANK_COLORS[index % RANK_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||
成功 {fmtNumber(item.success_count)} · 失败 {fmtNumber(item.error_count)} · {fmtNumber(item.total_tokens)} tokens
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-[#202124] tabular-nums dark:text-white">{fmtNumber(item.count)}</div>
|
||||
<div className="text-xs text-[#5F6368] dark:text-slate-400">次</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionPanel({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: AdminLeaderboardQuestion[];
|
||||
}) {
|
||||
return (
|
||||
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
|
||||
</div>
|
||||
<Badge variant="outline">{items.length}</Badge>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
|
||||
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
|
||||
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||
<span>{item.email || item.user_id || "未知用户"}</span>
|
||||
<span>{fmtTime(item.asked_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
||||
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
|
||||
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
|
||||
const chartId = useId().replace(/:/g, "");
|
||||
if (points.length === 0) {
|
||||
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">暂无趋势数据</div>;
|
||||
}
|
||||
|
||||
const visible = points.slice(-32);
|
||||
const series = [
|
||||
{ key: "runs", label: "运行", color: "#C1121F", soft: "bg-[#FFF1F1] text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]", description: "每天发起的运行次数" },
|
||||
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E] dark:bg-teal-300/15 dark:text-teal-100", description: "每天产生的有效提问次数" },
|
||||
{ key: "users", label: "活跃", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309] dark:bg-amber-300/15 dark:text-amber-100", description: "每天有行为的用户数量" },
|
||||
{ key: "tokens", label: "Token", color: "#7C3AED", soft: "bg-[#F5F3FF] text-[#7C3AED] dark:bg-violet-300/15 dark:text-violet-100", description: "每天模型输入输出 Token 总量" },
|
||||
] as const;
|
||||
const activeMetric: TrendMetric = metric === "all" ? "runs" : metric;
|
||||
const activeSeries = series.find((item) => item.key === activeMetric) ?? series[0];
|
||||
const metricMax = Math.max(1, ...visible.map((point) => Number(point[activeMetric] ?? 0)));
|
||||
const latest = visible[visible.length - 1];
|
||||
const previous = visible[visible.length - 2];
|
||||
const latestValue = Number(latest?.[activeMetric] ?? 0);
|
||||
const previousValue = Number(previous?.[activeMetric] ?? 0);
|
||||
const delta = latestValue - previousValue;
|
||||
const width = 920;
|
||||
const height = 280;
|
||||
const padding = { top: 22, right: 28, bottom: 36, left: 48 };
|
||||
const chartWidth = width - padding.left - padding.right;
|
||||
const chartHeight = height - padding.top - padding.bottom;
|
||||
const xFor = (index: number) =>
|
||||
padding.left + (visible.length <= 1 ? chartWidth / 2 : (index / (visible.length - 1)) * chartWidth);
|
||||
const yFor = (value: number, max: number) => padding.top + chartHeight - (value / max) * chartHeight;
|
||||
const gridLines = [0, 0.25, 0.5, 0.75, 1];
|
||||
const selectedPath = visible
|
||||
.map((point, index) => {
|
||||
const value = Number(point[activeMetric] ?? 0);
|
||||
return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, metricMax).toFixed(2)}`;
|
||||
})
|
||||
.join(" ");
|
||||
const selectedArea = `${selectedPath} L ${xFor(visible.length - 1).toFixed(2)} ${padding.top + chartHeight} L ${xFor(0).toFixed(2)} ${padding.top + chartHeight} Z`;
|
||||
const peak = visible.reduce(
|
||||
(best, point) => (Number(point[activeMetric] ?? 0) > Number(best[activeMetric] ?? 0) ? point : best),
|
||||
visible[0],
|
||||
);
|
||||
const displayedSeries = metric === "all" ? series : [activeSeries];
|
||||
const totalFor = (key: TrendMetric) => visible.reduce((sum, point) => sum + Number(point[key] ?? 0), 0);
|
||||
const metricCards = ({ large = false }: { large?: boolean } = {}) => (
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMetric("all")}
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
|
||||
large && "px-4 py-3",
|
||||
metric === "all"
|
||||
? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
|
||||
: "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex rounded-full bg-[#FFF1F1] px-2 py-0.5 text-xs font-medium text-[#C1121F] dark:text-[#7FACF2] dark:bg-[#2563EB]/15 dark:text-[#7FACF2]">全部趋势</span>
|
||||
<span className={cn("mt-2 block font-semibold tabular-nums text-[#231815] dark:text-white", large ? "text-2xl" : "text-lg")}>
|
||||
{fmtNumber(visible.reduce((sum, point) => sum + point.runs + point.questions + point.users, 0))}
|
||||
</span>
|
||||
{large && <span className="mt-1 block truncate text-xs text-[#8B6B6B] dark:text-[#7FACF2]">同时显示运行、问答、活跃与 Token</span>}
|
||||
</button>
|
||||
{series.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setMetric(item.key)}
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
|
||||
large && "px-4 py-3",
|
||||
metric === item.key
|
||||
? "border-[#C1121F] dark:border-[#2563EB] bg-[#FFF1F1] shadow-sm dark:border-[#7FACF2]/40 dark:bg-[#0D2D54]"
|
||||
: "border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]",
|
||||
)}
|
||||
title={item.description}
|
||||
>
|
||||
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", item.soft)}>{item.label}</span>
|
||||
<span className={cn("mt-2 block truncate font-semibold tabular-nums text-[#111827] dark:text-white", large ? "text-2xl" : "text-lg")}>
|
||||
{fmtNumber(totalFor(item.key))}
|
||||
</span>
|
||||
{large && <span className="mt-1 block truncate text-[11px] text-[#8B6B6B] dark:text-[#7FACF2]">{item.description}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const analysisDialog = (title: string, description: ReactNode) => (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Maximize2 className="mr-1 size-4" />
|
||||
放大分析
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex h-[92vh] w-[96vw] max-w-none flex-col gap-4 overflow-hidden p-5 sm:max-w-none">
|
||||
<DialogHeader className="shrink-0 pr-10">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="shrink-0">{metricCards({ large: true })}</div>
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div className="rounded-lg border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">当前查看</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-[#111827] dark:text-white">{metric === "all" ? "全部指标" : activeSeries.label}</div>
|
||||
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
|
||||
<div className="mt-2 text-4xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
|
||||
<div className={cn("mt-3 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
|
||||
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
|
||||
</div>
|
||||
<div className="mt-5 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
|
||||
</div>
|
||||
</div>
|
||||
{chart(true)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const chart = (large = false) => (
|
||||
<div className={cn("min-w-0 overflow-hidden rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#3F3F46] dark:bg-[#18181B]", large && "flex min-h-0 p-5")}>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-full min-h-[420px]" : "h-72")} role="img" aria-label="使用趋势折线图">
|
||||
<defs>
|
||||
<linearGradient id={`trend-area-${chartId}-${activeMetric}`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={activeSeries.color} stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor={activeSeries.color} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{gridLines.map((ratio) => {
|
||||
const y = padding.top + chartHeight * ratio;
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
x2={width - padding.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="currentColor"
|
||||
className="text-[#E5E7EB] dark:text-[#3F3F46]"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text x={12} y={y + 4} className="fill-[#9CA3AF] text-[11px]">
|
||||
{fmtNumber(Math.round(metricMax * (1 - ratio)))}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{metric !== "all" && <path d={selectedArea} fill={`url(#trend-area-${chartId}-${activeMetric})`} />}
|
||||
{displayedSeries.map((item) => {
|
||||
const max = Math.max(1, ...visible.map((point) => Number(point[item.key] ?? 0)));
|
||||
const path = visible
|
||||
.map((point, index) => {
|
||||
const value = Number(point[item.key] ?? 0);
|
||||
return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, max).toFixed(2)}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<g key={item.key}>
|
||||
<path d={path} fill="none" stroke={item.color} strokeWidth={large ? "4" : "3"} strokeLinecap="round" strokeLinejoin="round" opacity={metric === "all" ? 0.9 : 1} />
|
||||
{metric !== "all" &&
|
||||
visible.map((point, index) => {
|
||||
const value = Number(point[item.key] ?? 0);
|
||||
const isPeak = point.date === peak.date;
|
||||
const isLast = index === visible.length - 1;
|
||||
return (
|
||||
<circle
|
||||
key={`${item.key}-${point.date}`}
|
||||
cx={xFor(index)}
|
||||
cy={yFor(value, max)}
|
||||
r={isPeak || isLast ? 5 : 3}
|
||||
fill="#fff"
|
||||
stroke={item.color}
|
||||
strokeWidth={isPeak || isLast ? "3" : "2"}
|
||||
>
|
||||
<title>{`${point.date} ${item.label}: ${fmtNumber(value)}`}</title>
|
||||
</circle>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{visible.map((point, index) => {
|
||||
if (index !== 0 && index !== visible.length - 1 && index % Math.ceil(visible.length / 6) !== 0) return null;
|
||||
return (
|
||||
<text key={point.date} x={xFor(index)} y={height - 12} textAnchor="middle" className="fill-[#6B7280] text-[11px]">
|
||||
{point.date.slice(5)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs leading-5 text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
每个折线点代表一天;点击指标卡可单独查看运行、问答、活跃或 Token,全部趋势会同时展示四条多色折线。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">{metricCards()}</div>
|
||||
{analysisDialog(
|
||||
"时间趋势分析",
|
||||
<>当前查看 {metric === "all" ? "全部指标" : activeSeries.label},峰值 {peak.date} / {fmtNumber(Number(peak[activeMetric] ?? 0))}。</>,
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="rounded-xl border border-[#F1D6D6] bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">最新 {metric === "all" ? "运行" : activeSeries.label}</div>
|
||||
<div className="mt-2 text-3xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
|
||||
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#7F1D1D] dark:text-[#FCA5A5]")}>
|
||||
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">
|
||||
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[activeMetric] ?? 0))}
|
||||
</div>
|
||||
</div>
|
||||
{chart(false)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,16 +6,18 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
List,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -127,6 +129,10 @@ async function fetchPublicTaskRuns(taskId: string): Promise<PublicRunsResponse>
|
||||
return fetchJson<PublicRunsResponse>(`${publicBaseUrl()}/${encodeURIComponent(taskId)}/runs?limit=200`);
|
||||
}
|
||||
|
||||
async function fetchPublicRun(runId: string): Promise<PublicRun> {
|
||||
return fetchJson<PublicRun>(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}`);
|
||||
}
|
||||
|
||||
async function fetchPublicRunFile(runId: string, index: number): Promise<PublicFileContent> {
|
||||
return fetchJson<PublicFileContent>(
|
||||
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
|
||||
@@ -667,12 +673,61 @@ type ContentTab = {
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
type PublicFullscreenPreview =
|
||||
| { mode: "html"; title: string; html: string }
|
||||
| { mode: "markdown"; title: string; content: string }
|
||||
| { mode: "text"; title: string; content: string }
|
||||
| { mode: "file"; title: string; file: PublicFileContent };
|
||||
|
||||
function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreenPreview }) {
|
||||
if (preview.mode === "html") {
|
||||
return <HtmlPreviewFrame html={preview.html} className="h-full w-full border-0 bg-white" />;
|
||||
}
|
||||
if (preview.mode === "markdown") {
|
||||
return (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={preview.content || "无正文内容"}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (preview.mode === "file") {
|
||||
const content = preview.file.content || "无文件内容";
|
||||
if (isMarkdownFile(preview.file)) {
|
||||
return (
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <pre className="whitespace-pre-wrap break-words text-sm">{content}</pre>;
|
||||
}
|
||||
return <pre className="whitespace-pre-wrap break-words text-sm">{preview.content || "无正文内容"}</pre>;
|
||||
}
|
||||
|
||||
function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("tab", tab.kind);
|
||||
if (tab.kind === "file" && tab.fileIndex != null) {
|
||||
params.set("file", String(tab.fileIndex));
|
||||
}
|
||||
return `#/public/scheduled-tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/fullscreen?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-pane controller for a single run: builds the tab list (body + MDs),
|
||||
* fetches each tab's source text, and hosts the view/edit toggle.
|
||||
*/
|
||||
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [fullscreenPreview, setFullscreenPreview] = useState<PublicFullscreenPreview | null>(null);
|
||||
|
||||
// HTML page run: render a sandboxed iframe preview instead of the MD tabs.
|
||||
const isHtmlRun =
|
||||
@@ -837,9 +892,67 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
const fileIndex = activeTabMeta?.fileIndex;
|
||||
const fileIsLoading = isFileTab && fileIndex != null ? !!fileLoading[fileIndex] : false;
|
||||
const showEmptyHint = !activeSource && !fileIsLoading;
|
||||
const fullscreenHref = activeTabMeta ? publicFullscreenHref(taskId, run.id, activeTabMeta) : "";
|
||||
const fullscreenDisabled =
|
||||
!activeTabMeta ||
|
||||
(activeTabMeta.kind === "html" ? !htmlContent : activeTabMeta.kind === "file" ? fileIsLoading : false);
|
||||
|
||||
const handleOpenFullscreen = useCallback(() => {
|
||||
if (!activeTabMeta) return;
|
||||
if (activeTabMeta.kind === "html") {
|
||||
if (!htmlContent) return;
|
||||
setFullscreenPreview({
|
||||
mode: "html",
|
||||
title: run.result?.title || "HTML 预览",
|
||||
html: htmlContent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (activeTabMeta.kind === "body") {
|
||||
setFullscreenPreview({
|
||||
mode: run.result?.format === "markdown" || run.result?.format === "mixed" ? "markdown" : "text",
|
||||
title: run.result?.title || "正文预览",
|
||||
content: activeSource || "无正文内容",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const sourceFile = markdownFiles.find((file) => file.index === activeTabMeta.fileIndex);
|
||||
setFullscreenPreview({
|
||||
mode: "file",
|
||||
title: activeTabMeta.fileName || sourceFile?.name || "文件预览",
|
||||
file: {
|
||||
index: activeTabMeta.fileIndex ?? 0,
|
||||
name: activeTabMeta.fileName || sourceFile?.name || "result.md",
|
||||
mime_type: sourceFile?.mime_type ?? "text/markdown",
|
||||
kind: sourceFile?.kind ?? "markdown",
|
||||
size: sourceFile?.size ?? activeSource.length,
|
||||
previewable: true,
|
||||
content: activeSource,
|
||||
},
|
||||
});
|
||||
}, [activeTabMeta, activeSource, htmlContent, markdownFiles, run.result?.format, run.result?.title]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={fullscreenPreview !== null} onOpenChange={(open) => !open && setFullscreenPreview(null)}>
|
||||
<DialogContent className="flex h-[96vh] w-[96vw] max-w-none flex-col gap-0 p-0 sm:max-w-none">
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12">
|
||||
<DialogTitle className="truncate text-base">
|
||||
{fullscreenPreview?.title ?? "预览"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{fullscreenPreview && (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1",
|
||||
fullscreenPreview.mode === "html" ? "" : "overflow-auto p-4",
|
||||
)}
|
||||
>
|
||||
<PublicFullscreenPreviewContent preview={fullscreenPreview} />
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CardHeader className="flex flex-wrap items-start justify-between gap-2 space-y-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Tooltip>
|
||||
@@ -859,6 +972,30 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!isEditing && activeTabMeta && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleOpenFullscreen}
|
||||
disabled={fullscreenDisabled}
|
||||
title="全屏查看"
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
全屏
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.open(fullscreenHref, "_blank", "noopener,noreferrer")}
|
||||
disabled={fullscreenDisabled}
|
||||
title="新页面打开全屏预览"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
新页面
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isFileTab && fileIndex != null && (
|
||||
<a
|
||||
className="bg-background hover:bg-accent inline-flex h-9 items-center gap-1 rounded-md border px-3 text-xs"
|
||||
@@ -999,3 +1136,153 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicScheduledTaskFullscreenPage() {
|
||||
const { taskId, runId } = useParams<{ taskId: string; runId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const tabKind: ContentTab["kind"] =
|
||||
requestedTab === "body" || requestedTab === "file" || requestedTab === "html"
|
||||
? requestedTab
|
||||
: "html";
|
||||
const fileIndexValue = Number(searchParams.get("file"));
|
||||
const fileIndexValid = Number.isInteger(fileIndexValue) && fileIndexValue >= 0;
|
||||
|
||||
const { data: run, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["public-scheduled-task", "run", runId],
|
||||
queryFn: () => fetchPublicRun(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const isHtmlRun =
|
||||
run?.result?.format === "html" ||
|
||||
(run?.result?.files ?? []).some(
|
||||
(file) => file.kind === "html" || (file.name ?? "").toLowerCase().endsWith(".html"),
|
||||
);
|
||||
|
||||
const { data: htmlData, isLoading: htmlLoading } = useQuery({
|
||||
queryKey: ["public-scheduled-task", "html", runId],
|
||||
queryFn: () => fetchPublicRunHtml(runId!),
|
||||
enabled: !!runId && tabKind === "html" && isHtmlRun && !run?.result?.html,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const {
|
||||
data: fileData,
|
||||
isLoading: fileLoading,
|
||||
isError: fileIsError,
|
||||
error: fileError,
|
||||
} = useQuery({
|
||||
queryKey: ["public-scheduled-task", "file", runId, fileIndexValue],
|
||||
queryFn: () => fetchPublicRunFile(runId!, fileIndexValue),
|
||||
enabled: !!runId && tabKind === "file" && fileIndexValid,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const htmlContent = run?.result?.html ?? htmlData?.html ?? null;
|
||||
const backHref = taskId ? `#/public/scheduled-tasks/${encodeURIComponent(taskId)}` : "#/public/scheduled-tasks";
|
||||
const pageTitle =
|
||||
tabKind === "file"
|
||||
? fileData?.name || "文件预览"
|
||||
: tabKind === "body"
|
||||
? run?.result?.title || "正文预览"
|
||||
: htmlData?.title || run?.result?.title || "HTML 预览";
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className="bg-background flex h-screen min-h-0 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
||||
<Button asChild variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||
<a href={backHref}>
|
||||
<ChevronLeft className="size-3" />
|
||||
返回列表
|
||||
</a>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-medium" title={pageTitle}>
|
||||
{pageTitle}
|
||||
</div>
|
||||
{tabKind === "file" && runId && fileIndexValid && (
|
||||
<Button asChild variant="outline" size="sm" className="h-8 px-2 text-xs">
|
||||
<a
|
||||
href={publicRunFileDownloadUrl(runId, fileIndexValue)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
下载
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{!runId || !taskId ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
缺少任务或执行记录 ID。
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载预览...
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="text-destructive flex h-full items-center justify-center text-sm">
|
||||
加载失败:{error instanceof Error ? error.message : "未知错误"}
|
||||
</div>
|
||||
) : tabKind === "html" ? (
|
||||
htmlContent ? (
|
||||
<HtmlPreviewFrame html={htmlContent} className="h-full w-full border-0 bg-white" />
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
|
||||
{htmlLoading && <Loader2 className="size-4 animate-spin" />}
|
||||
HTML 页面尚未生成或正在生成中。
|
||||
</div>
|
||||
)
|
||||
) : tabKind === "file" ? (
|
||||
fileLoading ? (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载文件...
|
||||
</div>
|
||||
) : !fileIndexValid || fileIsError || !fileData ? (
|
||||
<div className="text-destructive flex h-full items-center justify-center text-sm">
|
||||
文件加载失败:{fileError instanceof Error ? fileError.message : "文件不存在或不可预览"}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<PublicFullscreenPreviewContent
|
||||
preview={{
|
||||
mode: "file",
|
||||
title: fileData.name,
|
||||
file: fileData,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<PublicFullscreenPreviewContent
|
||||
preview={{
|
||||
mode:
|
||||
run?.result?.format === "markdown" || run?.result?.format === "mixed"
|
||||
? "markdown"
|
||||
: "text",
|
||||
title: run?.result?.title || "正文预览",
|
||||
content: run?.result?.content || "无正文内容",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +59,11 @@ const errorToastDarkOptions = {
|
||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||
} as const;
|
||||
|
||||
export default function RecommendedQuestionsPage() {
|
||||
export default function RecommendedQuestionsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { theme } = useTheme();
|
||||
const { questions, isLoading, error } = useRecommendedQuestions();
|
||||
const createMutation = useCreateRecommendedQuestion();
|
||||
@@ -176,10 +180,16 @@ export default function RecommendedQuestionsPage() {
|
||||
: { success: undefined, error: undefined };
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? "flex w-full flex-col gap-6"
|
||||
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">推荐问题配置</h1>
|
||||
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>推荐问题配置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理首页展示的推荐问题,点击后直接发送到对话。拖拽可调整顺序。
|
||||
</p>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
RefreshCw,
|
||||
TagsIcon,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
@@ -148,8 +149,16 @@ export default function ScheduledTasksPage() {
|
||||
const initialTaskId = searchParams.get("task");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
// Plaza (广场) name search is resolved client-side over the fetched published
|
||||
// list, kept separate from the server-side 我的任务 search above.
|
||||
const [plazaSearch, setPlazaSearch] = useState("");
|
||||
// Publisher (创建者) filter for the 我的任务 tab — click a task's 创建者 to keep
|
||||
// only that creator's tasks. {id} is creator_user_id; {label} the display name.
|
||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||
null,
|
||||
);
|
||||
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
|
||||
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagIds });
|
||||
const { data: tasks = [], isLoading, isFetching } = useScheduledTasks({ search, tagIds });
|
||||
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
|
||||
const { agents } = useAgents();
|
||||
const actions = useScheduledTaskActions();
|
||||
@@ -199,6 +208,34 @@ export default function ScheduledTasksPage() {
|
||||
}),
|
||||
[tasks],
|
||||
);
|
||||
// The rendered 我的任务 list, optionally narrowed to one creator. Selection
|
||||
// logic still runs against the full sortedTasks so the detail panel keeps
|
||||
// working even when the picked task is from a filtered-out creator.
|
||||
const visibleTasks = useMemo(
|
||||
() =>
|
||||
publisher
|
||||
? sortedTasks.filter((task) => task.creator_user_id === publisher.id)
|
||||
: sortedTasks,
|
||||
[sortedTasks, publisher],
|
||||
);
|
||||
// Plaza search: filter the fetched published list client-side. Match against
|
||||
// both the raw and desensitized forms so typing what's shown on screen (the
|
||||
// desensitized name) and the underlying text both hit.
|
||||
const visiblePublished = useMemo(() => {
|
||||
const q = plazaSearch.trim().toLowerCase();
|
||||
if (!q) return published;
|
||||
return published.filter((item) =>
|
||||
[
|
||||
item.name,
|
||||
desensitize(item.name),
|
||||
item.prompt_summary,
|
||||
desensitize(item.prompt_summary ?? ""),
|
||||
item.schedule_text,
|
||||
displayUsername(item.creator_email),
|
||||
].some((field) => (field ?? "").toLowerCase().includes(q)),
|
||||
);
|
||||
}, [published, plazaSearch]);
|
||||
|
||||
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
||||
const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
|
||||
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
|
||||
@@ -449,8 +486,32 @@ export default function ScheduledTasksPage() {
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
searching={isFetching}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "plaza" && (
|
||||
<TagSearchBar
|
||||
targetType="scheduled_task"
|
||||
hideTags
|
||||
onSearchChange={setPlazaSearch}
|
||||
searching={publishedLoading}
|
||||
searchPlaceholder="搜索广场任务(名称 / 摘要 / 创建者)"
|
||||
/>
|
||||
)}
|
||||
{activeTab === "mine" && publisher && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">筛选创建者:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPublisher(null)}
|
||||
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
|
||||
title="点击清除创建者筛选"
|
||||
>
|
||||
{publisher.label}
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 p-0">
|
||||
<ScrollArea className="size-full">
|
||||
@@ -459,12 +520,14 @@ export default function ScheduledTasksPage() {
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground text-sm">正在加载任务...</div>
|
||||
)}
|
||||
{!isLoading && sortedTasks.length === 0 && (
|
||||
{!isLoading && visibleTasks.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||||
还没有定时任务。点击新建创建第一个任务,或去“广场”收藏他人发布的任务。
|
||||
{publisher
|
||||
? `没有「${publisher.label}」创建的任务。`
|
||||
: "还没有定时任务。点击新建创建第一个任务,或去“广场”收藏他人发布的任务。"}
|
||||
</div>
|
||||
)}
|
||||
{sortedTasks.map((task) => {
|
||||
{visibleTasks.map((task) => {
|
||||
const isOwner = task.role !== "subscriber";
|
||||
const isSubscriber = task.role === "subscriber";
|
||||
const notifyOn = !!task._subscription?.notify_element;
|
||||
@@ -539,11 +602,36 @@ export default function ScheduledTasksPage() {
|
||||
)}
|
||||
</div>
|
||||
{(task.creator_email || task.creator_user_id) && (
|
||||
<div className="truncate">
|
||||
// The card itself is a <button>, so this clickable
|
||||
// creator must be a <span> (no nested buttons).
|
||||
<span
|
||||
role={task.creator_user_id ? "button" : undefined}
|
||||
tabIndex={task.creator_user_id ? 0 : undefined}
|
||||
onClick={(event) => {
|
||||
if (!task.creator_user_id) return;
|
||||
event.stopPropagation();
|
||||
setPublisher({
|
||||
id: task.creator_user_id,
|
||||
label:
|
||||
displayUsername(task.creator_email) ||
|
||||
task.creator_user_id,
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"block max-w-full truncate text-sm font-medium",
|
||||
task.creator_user_id &&
|
||||
"hover:text-primary cursor-pointer transition-colors hover:underline",
|
||||
)}
|
||||
title={
|
||||
task.creator_user_id
|
||||
? `点击筛选创建者:${displayUsername(task.creator_email) || task.creator_user_id}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
创建者:
|
||||
{displayUsername(task.creator_email) ||
|
||||
task.creator_user_id}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(task.tags?.length ?? 0) > 0 && (
|
||||
@@ -694,14 +782,19 @@ export default function ScheduledTasksPage() {
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 p-4 pt-0">
|
||||
{publishedLoading && (
|
||||
<div className="text-muted-foreground text-sm">正在加载广场任务...</div>
|
||||
)}
|
||||
{!publishedLoading && published.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||||
广场上还没有发布的任务。
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载广场任务...
|
||||
</div>
|
||||
)}
|
||||
{published.map((item) => {
|
||||
{!publishedLoading && visiblePublished.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||||
{plazaSearch.trim()
|
||||
? `没有匹配「${plazaSearch.trim()}」的任务。`
|
||||
: "广场上还没有发布的任务。"}
|
||||
</div>
|
||||
)}
|
||||
{visiblePublished.map((item) => {
|
||||
const subscribed = tasks.some((t) => t.task_id === item.task_id);
|
||||
return (
|
||||
<div key={item.task_id} className="bg-card rounded-md border p-4">
|
||||
@@ -781,7 +874,7 @@ export default function ScheduledTasksPage() {
|
||||
{selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"}
|
||||
</p>
|
||||
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
创建者:
|
||||
{displayUsername(selectedTask.creator_email) ||
|
||||
selectedTask.creator_user_id}
|
||||
|
||||
@@ -24,6 +24,8 @@ import AdminScheduledTasksPage from './AdminScheduledTasksPage';
|
||||
import AdminSkillsPage from './AdminSkillsPage';
|
||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||
import KnowledgeBasePage from './KnowledgeBasePage';
|
||||
import KnowledgeGraphPage from './KnowledgeGraphPage';
|
||||
import LlmMetricsPage from './LlmMetricsPage';
|
||||
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
@@ -344,6 +346,30 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<KnowledgeBasePage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/graph"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<KnowledgeGraphPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="knowledge/:noteId"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<KnowledgeBasePage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
|
||||
@@ -16,12 +16,33 @@ import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
|
||||
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
export interface WikiLinkTarget {
|
||||
target: string;
|
||||
kind: 'note' | 'vault' | 'missing';
|
||||
exists: boolean;
|
||||
note_id?: string;
|
||||
vault_path?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export type WikiLinkMap = Record<string, WikiLinkTarget>;
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
onCitationClick?: (index: number) => void;
|
||||
/** Resolved `[[wikilink]]` targets; when provided, non-numeric `[[...]]` render as links. */
|
||||
wikiLinks?: WikiLinkMap;
|
||||
onWikiLinkClick?: (link: WikiLinkTarget) => void;
|
||||
}
|
||||
|
||||
const escapeHtmlAttr = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
SyntaxHighlighter.registerLanguage('bash', bash);
|
||||
SyntaxHighlighter.registerLanguage('css', css);
|
||||
SyntaxHighlighter.registerLanguage('html', markup);
|
||||
@@ -126,8 +147,48 @@ const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = (
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '', onCitationClick }) => {
|
||||
const contentWithCitationBadge = content.replace(
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
content,
|
||||
className = '',
|
||||
onCitationClick,
|
||||
wikiLinks,
|
||||
onWikiLinkClick,
|
||||
}) => {
|
||||
// 1) Non-numeric [[wikilinks]] → clickable (or greyed-out) links, driven by the
|
||||
// resolver map. Numeric [[123]] are left untouched here for the citation pass.
|
||||
let processed = content;
|
||||
if (wikiLinks) {
|
||||
processed = processed.replace(
|
||||
/\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]/g,
|
||||
(match, rawTarget: string, alias?: string) => {
|
||||
const target = String(rawTarget).trim();
|
||||
if (/^\d+$/.test(target)) return match; // citation badge, handled below
|
||||
const resolved = wikiLinks[target];
|
||||
const label = (alias?.trim() || resolved?.title || target);
|
||||
const safeLabel = escapeHtmlAttr(label);
|
||||
const safeTarget = escapeHtmlAttr(target);
|
||||
if (resolved && resolved.exists) {
|
||||
return `<button type="button" data-wikilink-target="${safeTarget}" class="inline items-center font-medium text-blue-600 dark:text-blue-400 hover:underline cursor-pointer align-baseline">${safeLabel}</button>`;
|
||||
}
|
||||
return `<span data-wikilink-missing="${safeTarget}" title="页面不存在" class="text-muted-foreground underline decoration-dashed decoration-from-font cursor-not-allowed">${safeLabel}</span>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
// 2) wiki-capture provenance markers ^[inferred] / ^[ambiguous] → small badges
|
||||
// (otherwise they render as raw text). They mark model-inferred / uncertain
|
||||
// statements per the wiki-capture spec.
|
||||
processed = processed
|
||||
.replace(
|
||||
/\s*\^\[inferred\]/g,
|
||||
' <sup title="模型推断,非原文直接得出" class="ml-0.5 rounded bg-amber-50 px-1 text-[10px] font-medium text-amber-600 align-super dark:bg-amber-500/15 dark:text-amber-300">推测</sup>',
|
||||
)
|
||||
.replace(
|
||||
/\s*\^\[ambiguous\]/g,
|
||||
' <sup title="不确定 / 存在冲突" class="ml-0.5 rounded bg-rose-50 px-1 text-[10px] font-medium text-rose-600 align-super dark:bg-rose-500/15 dark:text-rose-300">存疑</sup>',
|
||||
);
|
||||
|
||||
// 3) Numeric [[123]] citation badges.
|
||||
const contentWithCitationBadge = processed.replace(
|
||||
/\[\[(\d+)\]\]/g,
|
||||
'<button type="button" data-citation-index="$1" class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 mx-0.5 rounded-md border text-xs font-semibold align-middle bg-red-50 text-red-600 border-red-200 dark:bg-slate-700 dark:text-slate-100 dark:border-slate-500 cursor-pointer transition-all hover:opacity-85">[$1]</button>',
|
||||
);
|
||||
@@ -136,8 +197,17 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className
|
||||
<div
|
||||
className={`markdown-content ${className}`}
|
||||
onClick={(event) => {
|
||||
if (!onCitationClick) return;
|
||||
const target = event.target as HTMLElement;
|
||||
const wikiButton = target.closest('[data-wikilink-target]') as HTMLElement | null;
|
||||
if (wikiButton && onWikiLinkClick && wikiLinks) {
|
||||
const key = wikiButton.dataset.wikilinkTarget || '';
|
||||
const link = wikiLinks[key];
|
||||
if (link) {
|
||||
onWikiLinkClick(link);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!onCitationClick) return;
|
||||
const button = target.closest('[data-citation-index]') as HTMLElement | null;
|
||||
if (!button) return;
|
||||
const index = Number(button.dataset.citationIndex || '0');
|
||||
|
||||
Reference in new issue
Block a user