Merge remote-tracking branch 'origin/master' into xuhd
This commit is contained in:
commit
915233c5b6
171 files changed
+17266
-2105
No files matched your search
@@ -49,7 +49,6 @@ import { MarkdownContent } from "@/components/workspace/messages/markdown-conten
|
||||
import type {
|
||||
AdminLeaderboardDimension,
|
||||
AdminLeaderboardOverview,
|
||||
AdminLeaderboardQuestion,
|
||||
AdminLeaderboardResponse,
|
||||
AdminLeaderboardSkill,
|
||||
AdminLeaderboardTrendPoint,
|
||||
@@ -66,7 +65,7 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
|
||||
type RangeState = { since: string; until: string; days?: number };
|
||||
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
|
||||
const DEFAULT_REPORT_MODEL = "__default__";
|
||||
const LEADERBOARD_PAGE_SIZE = 20;
|
||||
const LEADERBOARD_PAGE_SIZE = 10;
|
||||
|
||||
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
||||
crimson: {
|
||||
@@ -132,11 +131,6 @@ function fmtTime(value?: string | null): string {
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -207,12 +201,6 @@ function markdownList(items: string[]): string {
|
||||
|
||||
function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, range: RangeState): string {
|
||||
if (!data) return "";
|
||||
const recentQuestions = data.recent_questions
|
||||
.slice(0, 8)
|
||||
.map((item) => `${clampText(item.text, 120)}(${fmtTime(item.asked_at)})`);
|
||||
const frequentQuestions = data.frequent_questions
|
||||
.slice(0, 8)
|
||||
.map((item) => clampText(item.text, 120));
|
||||
const frequentUsage = [
|
||||
...data.skills.slice(0, 5).map((item) => `技能:${item.name},${fmtNumber(item.call_count)} 次调用`),
|
||||
...data.tools.slice(0, 5).map((item) => `工具:${item.name},${fmtNumber(item.call_count)} 次调用`),
|
||||
@@ -223,12 +211,6 @@ function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, ra
|
||||
"",
|
||||
`统计周期:${range.since} 至 ${range.until}`,
|
||||
"",
|
||||
"## 最近关注",
|
||||
markdownList(recentQuestions),
|
||||
"",
|
||||
"## 高频问题",
|
||||
markdownList(frequentQuestions),
|
||||
"",
|
||||
"## 使用频率重点",
|
||||
markdownList(frequentUsage),
|
||||
].join("\n");
|
||||
@@ -905,43 +887,6 @@ function DimensionRanking({
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionPanel({
|
||||
title,
|
||||
items,
|
||||
showUser = true,
|
||||
}: {
|
||||
title: string;
|
||||
items: AdminLeaderboardQuestion[];
|
||||
showUser?: boolean;
|
||||
}) {
|
||||
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">
|
||||
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
|
||||
<span>{fmtTime(item.asked_at)}</span>
|
||||
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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-[#213D5E]">
|
||||
@@ -1006,16 +951,12 @@ function UserDetailPanel({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Tabs defaultValue="questions" className="space-y-3">
|
||||
<Tabs defaultValue="trend" className="space-y-3">
|
||||
<TabsList className="mb-3">
|
||||
<TabsTrigger value="questions">提问</TabsTrigger>
|
||||
<TabsTrigger value="trend">趋势</TabsTrigger>
|
||||
<TabsTrigger value="skills">技能</TabsTrigger>
|
||||
<TabsTrigger value="dimensions">维度</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="questions" className="min-h-[420px]">
|
||||
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
|
||||
</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] dark:text-[#7FACF2]" />
|
||||
@@ -1354,36 +1295,7 @@ export default function AdminUserLeaderboardPage() {
|
||||
</Button>
|
||||
</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-[#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] 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] 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] dark:text-[#7FACF2]">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
<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] dark:text-[#7FACF2]" />
|
||||
高频问题
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.frequent_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">
|
||||
{index + 1}. {clampText(item.text, 72)}
|
||||
</div>
|
||||
))}
|
||||
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="mx-6 mt-4 grid shrink-0 gap-3">
|
||||
<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] dark:text-[#7FACF2]" />
|
||||
@@ -1645,12 +1557,6 @@ export default function AdminUserLeaderboardPage() {
|
||||
<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>
|
||||
|
||||
@@ -3,26 +3,23 @@
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
@@ -65,6 +62,10 @@ export default function AgentChatPage() {
|
||||
const agentDisplayName = desensitize(
|
||||
agent?.name ?? (isAgentLoading ? t.common.loading : ""),
|
||||
);
|
||||
const commonQuestions = useMemo(
|
||||
() => normalizeAgentQuestions(agent?.common_questions),
|
||||
[agent?.common_questions],
|
||||
);
|
||||
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||
useThreadChat();
|
||||
@@ -162,131 +163,188 @@ export default function AgentChatPage() {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
|
||||
const status = thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready";
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{/* Agent badge */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span
|
||||
className="max-w-[200px] truncate text-xs font-medium"
|
||||
title={agentDisplayName}
|
||||
>
|
||||
{agentDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="mr-4 flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(`/page/workspace/agents/${agent_id}/chats/new`);
|
||||
}}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-md)"
|
||||
: "max-w-(--container-width-lg)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact
|
||||
preview) lives in the left flex cell; the agent detail panel is a
|
||||
separate flex sibling OUTSIDE ChatBox, so the artifact preview
|
||||
always opens between the chat and the detail panel — the detail
|
||||
panel stays rightmost. */}
|
||||
<div className="flex size-full min-h-0 overflow-hidden">
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<ChatBox threadId={threadId}>
|
||||
{/* Main conversation column */}
|
||||
<main className="flex size-full min-h-0 min-w-0 flex-col">
|
||||
{/* Header in normal flow, fixed height. */}
|
||||
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span
|
||||
className="max-w-[200px] truncate text-xs font-medium"
|
||||
title={agentDisplayName}
|
||||
>
|
||||
{agentDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full -translate-y-4">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && (
|
||||
<AgentWelcome agent={agent} agentName={agentDisplayName} />
|
||||
)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/page/workspace/agents/${agent_id}/chats/new`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</header>
|
||||
|
||||
{isLandingState ? (
|
||||
/* Home: centered hero, suggested questions, and input.
|
||||
Everything lives inside the scrollable main column. No fixed,
|
||||
no absolute, no viewport centering, so the avatar can't touch
|
||||
the header and the input can't cover the sidebar. */
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
|
||||
<AgentWelcome
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
className="mb-7"
|
||||
/>
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
{commonQuestions.length > 0 && (
|
||||
<div className="mb-2 flex max-h-20 flex-wrap items-start justify-start gap-2 overflow-y-auto px-1">
|
||||
{commonQuestions.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
disabled={isDemoMode}
|
||||
onClick={() =>
|
||||
handleSubmit({ text: question.prompt, files: [] })
|
||||
}
|
||||
title={desensitize(question.prompt)}
|
||||
className={cn(
|
||||
"border-border/70 bg-background/85 hover:bg-accent hover:border-border max-w-full rounded-full border px-3 py-1.5 text-left text-sm leading-5 shadow-sm transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="block max-w-[220px] truncate">
|
||||
{desensitize(question.title)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread
|
||||
hideAdminRecommendedQuestions
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
autoFocus
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
/* Conversation: messages scroll, input
|
||||
docked at the bottom of the main column only. */
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<MessageList
|
||||
className="min-h-0 flex-1"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={showFollowups ? 96 : 24}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
|
||||
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
|
||||
<TodoList
|
||||
className="bg-background/5 mb-2"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos ||
|
||||
thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={false}
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</ChatBox>
|
||||
</div>
|
||||
</ChatBox>
|
||||
|
||||
{/* Right: agent detail panel — always the rightmost column. */}
|
||||
<AgentDetailPanel
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useThreads,
|
||||
useThreadsCount,
|
||||
useThreadsPage,
|
||||
useToggleThreadFavorite,
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
@@ -42,10 +43,10 @@ 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 [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
@@ -54,46 +55,58 @@ export default function ChatsPage() {
|
||||
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
||||
}, [t.pages.chats, t.pages.appName]);
|
||||
|
||||
// Debounce the search box so each keystroke doesn't fire a backend query.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Reset to the first page whenever the filtered result set changes.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, onlyFavorites]);
|
||||
}, [debouncedSearch, 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]);
|
||||
// Conversations are paginated on the backend: each page is one bounded
|
||||
// query (title search + favorites filter included), so a long history is
|
||||
// never fetched in one go. Pinned conversations come from their own small
|
||||
// query and are surfaced in a dedicated section on the first page only,
|
||||
// and dropped from the paginated rows so they don't repeat on every page.
|
||||
const favoriteFilter = useMemo(
|
||||
() => (onlyFavorites ? { is_favorite: true } : {}),
|
||||
[onlyFavorites],
|
||||
);
|
||||
const pinnedFilter = useMemo(
|
||||
() => ({ ...favoriteFilter, is_pinned: true }),
|
||||
[favoriteFilter],
|
||||
);
|
||||
const { data: pageData } = useThreadsPage({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
search: debouncedSearch,
|
||||
metadata: favoriteFilter,
|
||||
});
|
||||
const { data: pinnedData } = useThreadsPage({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
search: debouncedSearch,
|
||||
metadata: pinnedFilter,
|
||||
});
|
||||
const { data: totalCount } = useThreadsCount({
|
||||
search: debouncedSearch,
|
||||
metadata: favoriteFilter,
|
||||
});
|
||||
|
||||
// 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 pinnedThreads = pinnedData?.threads ?? [];
|
||||
const pagedThreads = useMemo(
|
||||
() => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)),
|
||||
[pageData?.threads],
|
||||
);
|
||||
const currentPage = page;
|
||||
const hasMore = pageData?.hasMore ?? false;
|
||||
// Page windows are sliced over the raw (pinned-inclusive) row order, so the
|
||||
// page count derives from the raw total as well.
|
||||
const totalPages =
|
||||
totalCount != null ? Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) : null;
|
||||
|
||||
const renderThread = (thread: AgentThread) => {
|
||||
const pinned = isThreadPinned(thread);
|
||||
@@ -154,7 +167,7 @@ export default function ChatsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const isEmpty = filteredThreads.length === 0;
|
||||
const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0;
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
@@ -197,6 +210,11 @@ export default function ChatsPage() {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{totalCount != null && (
|
||||
<div className="text-muted-foreground w-full max-w-(--container-width-md) text-right text-xs tabular-nums">
|
||||
共 {totalCount} 条对话
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main className="min-h-0 flex-1">
|
||||
<ScrollArea className="size-full py-4">
|
||||
@@ -215,7 +233,7 @@ export default function ChatsPage() {
|
||||
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
{(currentPage > 1 || hasMore) && (
|
||||
<div className="flex items-center justify-center gap-3 py-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -227,15 +245,15 @@ export default function ChatsPage() {
|
||||
{t.chats.prevPage}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
{totalPages != null
|
||||
? `${currentPage} / ${totalPages}`
|
||||
: currentPage}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() =>
|
||||
setPage((p) => Math.min(totalPages, p + 1))
|
||||
}
|
||||
disabled={!hasMore}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t.chats.nextPage}
|
||||
<ChevronRight className="size-4" />
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
import { readWujieProps, isWujieEnv } from "@/core/embed/wujie";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
/**
|
||||
* 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**:
|
||||
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
|
||||
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
|
||||
*/
|
||||
function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === "light" || v === "red" || v === "红白") return "light";
|
||||
return "dark";
|
||||
}
|
||||
|
||||
/**
|
||||
* 圆桌规划嵌入页(独立入口,**与现有 wujie ticket 登录无关**)。
|
||||
*
|
||||
* 用于宿主(如 parallel-agents-vue 的 RoundtableDrawer)用无界把圆桌**主体页面**塞进抽屉:
|
||||
* - **登录**:走「用户名免密登录」(`loginByUsername`),username 来自 `window.$wujie.props.username`
|
||||
* (或 URL `?username=`),不经 LoginPage 的 MaxKey/consumer ticket 流程。
|
||||
* - **无侧边栏**:本路由挂在顶层(`/embed/roundtable`),在 `AppProviders` 之下、`PageRoutes` 之外,
|
||||
* 天然不含页面侧边栏 / workspace 导航,只渲染圆桌主体。
|
||||
* - **任务预填**:taskTitle / taskGoal / chainId 由 `RoundtablePlanningPage` 自身的
|
||||
* `readWujiePrefill()` 直接从 `window.$wujie.props` 读取(抽屉打开即自动跑第一步、按链条进第二步)。
|
||||
*/
|
||||
export default function EmbedRoundtablePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
// username / password / theme:wujie props 优先,URL query 兜底。
|
||||
const wp = readWujieProps();
|
||||
const username =
|
||||
(typeof wp.username === "string" && wp.username.trim()) ||
|
||||
(typeof wp.userName === "string" && wp.userName.trim()) ||
|
||||
searchParams.get("username")?.trim() ||
|
||||
searchParams.get("userName")?.trim() ||
|
||||
DEFAULT_USERNAME;
|
||||
// 共享口令(用户名+密码登录模式):仅当后端开启 username_login.require_password 时校验。
|
||||
// 不 trim —— 口令可能含首尾空格;未传则按「免密」走(后端未开门禁时照常)。
|
||||
const password =
|
||||
(typeof wp.password === "string" && wp.password) ||
|
||||
searchParams.get("password") ||
|
||||
undefined;
|
||||
// 主题色:宿主传入优先;默认「深蓝」= dark 主题(其主色为蓝,oklch hue 259)。
|
||||
const themeParam =
|
||||
(typeof wp.theme === "string" && wp.theme.trim()) ||
|
||||
searchParams.get("theme")?.trim() ||
|
||||
"dark";
|
||||
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(resolveEmbedRoundtableTheme(themeParam));
|
||||
}, [themeParam, setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWujieEnv()) return;
|
||||
document.documentElement.classList.add("wujie-embed");
|
||||
return () => document.documentElement.classList.remove("wujie-embed");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function runLogin() {
|
||||
setAuthError(null);
|
||||
try {
|
||||
const auth = await loginByUsername(username, password);
|
||||
if (cancelled) return;
|
||||
setStoredAuth(auth);
|
||||
setAuthReady(true);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setAuthError(error instanceof Error ? error.message : "登录失败");
|
||||
}
|
||||
}
|
||||
void runLogin();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [username, password]);
|
||||
|
||||
if (authError) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-red-600">
|
||||
登录失败:{authError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authReady) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-muted-foreground">
|
||||
正在进入圆桌…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// RoundtablePlanningPage 依赖 SubtasksProvider / ArtifactsProvider / PromptInputProvider
|
||||
// (Step 2 沙箱用 useArtifacts)。ChatRuntime 正是这三者的封装;不套 WorkspaceLayout,
|
||||
// 故无侧边栏,只渲染圆桌主体。
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
</ChatRuntime>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,8 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Network,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, 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,
|
||||
@@ -29,14 +14,16 @@ import {
|
||||
} 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 { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
|
||||
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
|
||||
import { WikiContent } from "@/components/wiki/WikiContent";
|
||||
import { WikiHeader } from "@/components/wiki/WikiHeader";
|
||||
import {
|
||||
buildKnowledgeTree,
|
||||
downloadMarkdown,
|
||||
findAncestorFolderIds,
|
||||
} from "@/components/wiki/wikiUtils";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import {
|
||||
useKnowledgeActions,
|
||||
@@ -52,45 +39,14 @@ import MarkdownRenderer, {
|
||||
type WikiLinkTarget,
|
||||
} from "@/strategy-components/MarkdownRenderer";
|
||||
|
||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
thread: "对话",
|
||||
search: "检索",
|
||||
tool: "工具",
|
||||
file: "文件",
|
||||
manual: "手动",
|
||||
report: "报告",
|
||||
};
|
||||
const SIDEBAR_COLLAPSED_KEY = "wiki.sidebar.collapsed";
|
||||
|
||||
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 readSidebarCollapsed(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, delay = 350): T {
|
||||
@@ -102,54 +58,6 @@ function useDebounced<T>(value: T, delay = 350): T {
|
||||
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,
|
||||
@@ -323,6 +231,119 @@ function BatchImportDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportFileDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { ingestFile } = useKnowledgeActions();
|
||||
const navigate = useNavigate();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setModel(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) {
|
||||
toast.error("请选择文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await ingestFile.mutateAsync({
|
||||
file,
|
||||
opts: {
|
||||
title: title.trim() || undefined,
|
||||
tags: tags.trim() || undefined,
|
||||
status: "approved",
|
||||
model_name: model,
|
||||
},
|
||||
});
|
||||
const count = result.notes?.length ?? 1;
|
||||
if (result.llm_fallback) {
|
||||
// The model produced nothing usable — the file was stored verbatim as a
|
||||
// draft instead of distilled into a wiki note.
|
||||
toast.warning(
|
||||
`已导入 ${count} 条(大模型异常,改用规则抽取并存为草稿)`,
|
||||
);
|
||||
} else {
|
||||
toast.success(`已从文件生成 ${count} 条知识`);
|
||||
}
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
if (result.note?.id) {
|
||||
navigate(`/page/workspace/knowledge/${result.note.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "文件导入失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入文件生成 Wiki</DialogTitle>
|
||||
<DialogDescription>
|
||||
上传文档(Markdown / TXT / PDF / Word / PPT / Excel),自动提炼为知识库 wiki 词条并写入 vault。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
选择文件
|
||||
<Input
|
||||
type="file"
|
||||
accept=".md,.markdown,.txt,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{file ? (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{file.name} · {(file.size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="标题(可选,留空则由模型生成)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="标签,逗号分隔(可选)"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
提炼大模型
|
||||
<ModelNameSelect
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={ingestFile.isPending}
|
||||
triggerClassName="h-9 w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleImport()} disabled={ingestFile.isPending || !file}>
|
||||
{ingestFile.isPending ? "导入中..." : "导入并生成"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DedupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -436,13 +457,92 @@ function VaultPageDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function NoteDetail({ noteId }: { noteId: string }) {
|
||||
export default function KnowledgeBasePage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: note, isLoading } = useKnowledgeNote(noteId);
|
||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||
const { update, remove } = useKnowledgeActions();
|
||||
const { noteId } = useParams<{ noteId: string }>();
|
||||
|
||||
// --- list / tree state ---
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const debouncedKeyword = useDebounced(keyword);
|
||||
const [sourceType, setSourceType] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const treeInitializedRef = useRef(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed);
|
||||
|
||||
// --- dialogs ---
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [dedupOpen, setDedupOpen] = useState(false);
|
||||
const [vaultPath, setVaultPath] = useState<string | null>(null);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
keyword: debouncedKeyword || undefined,
|
||||
source_type: sourceType === "all" ? undefined : sourceType,
|
||||
status: status === "all" ? undefined : status,
|
||||
limit: 500,
|
||||
}),
|
||||
[debouncedKeyword, sourceType, status],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useKnowledgeNotes(params);
|
||||
const notes = data?.items ?? [];
|
||||
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]);
|
||||
|
||||
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||
|
||||
// First load: expand the root-level folders so the tree reads as a directory.
|
||||
useEffect(() => {
|
||||
if (treeInitializedRef.current || tree.length === 0) return;
|
||||
treeInitializedRef.current = true;
|
||||
setExpandedIds(
|
||||
new Set(tree.filter((n) => n.type === "folder").map((n) => n.id)),
|
||||
);
|
||||
}, [tree]);
|
||||
|
||||
// Keep the selected document's ancestor folders open (deep links included).
|
||||
useEffect(() => {
|
||||
if (!noteId || tree.length === 0) return;
|
||||
const ancestors = findAncestorFolderIds(tree, noteId);
|
||||
if (!ancestors || ancestors.length === 0) return;
|
||||
setExpandedIds((prev) => {
|
||||
if (ancestors.every((id) => prev.has(id))) return prev;
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, [noteId, tree]);
|
||||
|
||||
const handleToggleFolder = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectNote = (item: KnowledgeNote) => {
|
||||
setMobileSidebarOpen(false);
|
||||
navigate(`/page/workspace/knowledge/${item.id}`);
|
||||
};
|
||||
|
||||
const handleToggleSidebarCollapse = () => {
|
||||
setSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage unavailable (private mode) — state stays session-only.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleWikiLink = (link: WikiLinkTarget) => {
|
||||
if (link.kind === "note" && link.note_id) {
|
||||
navigate(`/page/workspace/knowledge/${link.note_id}`);
|
||||
@@ -450,235 +550,81 @@ function NoteDetail({ noteId }: { noteId: string }) {
|
||||
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 className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
|
||||
<WikiHeader
|
||||
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
|
||||
onImportFile={() => setImportOpen(true)}
|
||||
onBatchImport={() => setBatchOpen(true)}
|
||||
onDedup={() => setDedupOpen(true)}
|
||||
onGraph={() => navigate("/page/workspace/knowledge/graph")}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-h-0 flex-1 grid-cols-1",
|
||||
sidebarCollapsed && !mobileSidebarOpen
|
||||
? "md:grid-cols-[44px_minmax(0,1fr)]"
|
||||
: "md:grid-cols-[288px_minmax(0,1fr)]",
|
||||
)}
|
||||
>
|
||||
{mobileSidebarOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/30 md:hidden"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<KnowledgeSidebar
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
sourceType={sourceType}
|
||||
onSourceTypeChange={setSourceType}
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
tree={tree}
|
||||
expandedIds={expandedIds}
|
||||
onToggleFolder={handleToggleFolder}
|
||||
selectedId={noteId}
|
||||
onSelect={handleSelectNote}
|
||||
onExportNote={downloadMarkdown}
|
||||
total={data?.total ?? 0}
|
||||
isLoading={isLoading}
|
||||
collapsed={sidebarCollapsed && !mobileSidebarOpen}
|
||||
onToggleCollapse={handleToggleSidebarCollapse}
|
||||
className={cn(
|
||||
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50 max-md:w-[300px] max-md:shadow-xl max-md:transition-transform max-md:duration-200",
|
||||
mobileSidebarOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full",
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
|
||||
{!noteId ? (
|
||||
<EmptyDocumentState />
|
||||
) : noteLoading || !note ? (
|
||||
<div className="flex h-full items-center justify-center bg-white text-sm text-slate-400 dark:bg-slate-900">
|
||||
{noteLoading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中...
|
||||
</span>
|
||||
) : (
|
||||
"知识不存在"
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<WikiContent
|
||||
note={note}
|
||||
wikiLinks={wikiLinks}
|
||||
onWikiLinkClick={handleWikiLink}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
|
||||
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
|
||||
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
|
||||
<VaultPageDialog
|
||||
path={vaultPath}
|
||||
onOpenChange={(open) => {
|
||||
@@ -688,134 +634,3 @@ function NoteDetail({ noteId }: { noteId: string }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +1,863 @@
|
||||
import { ArrowLeft, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Loader2, Network, RotateCcw, Tags, Undo2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, 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";
|
||||
import {
|
||||
GraphCanvas,
|
||||
type GraphCanvasHandle,
|
||||
} from "@/components/knowledge-graph/GraphCanvas";
|
||||
import { GraphDetailPanel } from "@/components/knowledge-graph/GraphDetailPanel";
|
||||
import { GraphFooter } from "@/components/knowledge-graph/GraphFooter";
|
||||
import { GraphHeader } from "@/components/knowledge-graph/GraphHeader";
|
||||
import { GraphSidebar } from "@/components/knowledge-graph/GraphSidebar";
|
||||
import { GraphTooltip } from "@/components/knowledge-graph/GraphTooltip";
|
||||
import type {
|
||||
ExploreStep,
|
||||
GraphMode,
|
||||
LayoutType,
|
||||
NodeKind,
|
||||
TooltipState,
|
||||
} from "@/components/knowledge-graph/graphTypes";
|
||||
import {
|
||||
computeDegrees,
|
||||
computeGraphStats,
|
||||
getEdgeId,
|
||||
getEdgeLabel,
|
||||
getNodeKind,
|
||||
getNodeTitle,
|
||||
} from "@/components/knowledge-graph/graphUtils";
|
||||
import "@/components/knowledge-graph/graphStyles.css";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
useGraphBlacklist,
|
||||
useGraphBlacklistActions,
|
||||
useKnowledgeGraph,
|
||||
useKnowledgeNote,
|
||||
} from "@/core/knowledge/hooks";
|
||||
|
||||
/** 全量图谱默认只渲染度数最高的核心节点,超出可手动切换显示全部。 */
|
||||
const FULL_GRAPH_NODE_LIMIT = 300;
|
||||
|
||||
/** 常见的低价值实体名(抽取噪声),用于"一键清理"候选。 */
|
||||
const GENERIC_ENTITY_LABELS = new Set([
|
||||
"用户",
|
||||
"助手",
|
||||
"系统",
|
||||
"问题",
|
||||
"回答",
|
||||
"内容",
|
||||
"信息",
|
||||
"数据",
|
||||
"功能",
|
||||
"方法",
|
||||
"结果",
|
||||
"示例",
|
||||
"例子",
|
||||
"总结",
|
||||
"文档",
|
||||
"文件",
|
||||
"其他",
|
||||
"未知",
|
||||
"对话",
|
||||
"聊天",
|
||||
"测试",
|
||||
"工具",
|
||||
"平台",
|
||||
"项目",
|
||||
"任务",
|
||||
"报告",
|
||||
"分析",
|
||||
"none",
|
||||
"null",
|
||||
"unknown",
|
||||
"test",
|
||||
"todo",
|
||||
]);
|
||||
|
||||
/** 单字符、纯数字/符号(日期等)或通用泛词视为低价值主题。 */
|
||||
function isLowValueLabel(raw: string): boolean {
|
||||
const label = raw.trim();
|
||||
if (label.length <= 1) return true;
|
||||
if (/^[\d\s\p{P}\p{S}]+$/u.test(label)) return true;
|
||||
return GENERIC_ENTITY_LABELS.has(label.toLowerCase());
|
||||
}
|
||||
|
||||
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 { theme } = useTheme();
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<GraphCanvasHandle>(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
|
||||
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||
useKnowledgeGraph();
|
||||
const { data: blacklistItems = [] } = useGraphBlacklist();
|
||||
const blacklistActions = useGraphBlacklistActions();
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [disabledNodeKinds, setDisabledNodeKinds] = useState<Set<NodeKind>>(
|
||||
new Set(),
|
||||
);
|
||||
const [disabledEdgeLabels, setDisabledEdgeLabels] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
|
||||
const [showAllNodes, setShowAllNodes] = useState(false);
|
||||
const [layoutType, setLayoutType] = useState<LayoutType>("force");
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [graphMode, setGraphMode] = useState<GraphMode>("full");
|
||||
const [exploreRootId, setExploreRootId] = useState<string | null>(null);
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [visibleNodeIds, setVisibleNodeIds] = useState<Set<string>>(new Set());
|
||||
const [visibleEdgeIds, setVisibleEdgeIds] = useState<Set<string>>(new Set());
|
||||
const [nodeDepthMap, setNodeDepthMap] = useState<Map<string, number>>(new Map());
|
||||
const [exploreHistory, setExploreHistory] = useState<ExploreStep[]>([]);
|
||||
|
||||
// Auto-collapse the side panels on narrow viewports.
|
||||
useEffect(() => {
|
||||
const detailQuery = window.matchMedia("(max-width: 1280px)");
|
||||
const sidebarQuery = window.matchMedia("(max-width: 900px)");
|
||||
const apply = () => {
|
||||
if (detailQuery.matches) setShowDetail(false);
|
||||
setShowSidebar(!sidebarQuery.matches);
|
||||
};
|
||||
apply();
|
||||
detailQuery.addEventListener("change", apply);
|
||||
sidebarQuery.addEventListener("change", apply);
|
||||
return () => {
|
||||
detailQuery.removeEventListener("change", apply);
|
||||
sidebarQuery.removeEventListener("change", apply);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------- derived
|
||||
const allNodes = useMemo(() => data?.nodes ?? [], [data]);
|
||||
const allEdges = useMemo(() => data?.edges ?? [], [data]);
|
||||
|
||||
const nodeById = useMemo(
|
||||
() => new Map(allNodes.map((n) => [n.id, n])),
|
||||
[allNodes],
|
||||
);
|
||||
const degrees = useMemo(
|
||||
() => computeDegrees(allNodes, allEdges),
|
||||
[allNodes, allEdges],
|
||||
);
|
||||
const stats = useMemo(
|
||||
() => computeGraphStats(allNodes, allEdges),
|
||||
[allNodes, allEdges],
|
||||
);
|
||||
|
||||
const nodeKindCounts = useMemo(() => {
|
||||
const counts = new Map<NodeKind, number>();
|
||||
for (const n of allNodes) {
|
||||
const kind = getNodeKind(n);
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [allNodes]);
|
||||
|
||||
const edgeLabelCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const e of allEdges) {
|
||||
const label = getEdgeLabel(e);
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [allEdges]);
|
||||
|
||||
const filteredNodes = useMemo(
|
||||
() =>
|
||||
allNodes.filter(
|
||||
(n) => !disabledNodeKinds.has(getNodeKind(n)) && !hiddenNodeIds.has(n.id),
|
||||
),
|
||||
[allNodes, disabledNodeKinds, hiddenNodeIds],
|
||||
);
|
||||
|
||||
const filteredEdges = useMemo(() => {
|
||||
const visible = new Set(filteredNodes.map((n) => n.id));
|
||||
return allEdges.filter(
|
||||
(e) =>
|
||||
visible.has(e.source) &&
|
||||
visible.has(e.target) &&
|
||||
!disabledEdgeLabels.has(getEdgeLabel(e)),
|
||||
);
|
||||
}, [allEdges, filteredNodes, disabledEdgeLabels]);
|
||||
|
||||
// 节点过多时全量模式默认只渲染度数最高的核心节点,避免布局/渲染卡顿。
|
||||
const fullGraphTruncated =
|
||||
!showAllNodes && filteredNodes.length > FULL_GRAPH_NODE_LIMIT;
|
||||
const limitedNodes = useMemo(() => {
|
||||
if (!fullGraphTruncated) return filteredNodes;
|
||||
return [...filteredNodes]
|
||||
.sort((a, b) => (degrees.get(b.id) ?? 0) - (degrees.get(a.id) ?? 0))
|
||||
.slice(0, FULL_GRAPH_NODE_LIMIT);
|
||||
}, [filteredNodes, fullGraphTruncated, degrees]);
|
||||
const limitedEdges = useMemo(() => {
|
||||
if (!fullGraphTruncated) return filteredEdges;
|
||||
const visible = new Set(limitedNodes.map((n) => n.id));
|
||||
return filteredEdges.filter(
|
||||
(e) => visible.has(e.source) && visible.has(e.target),
|
||||
);
|
||||
}, [filteredEdges, fullGraphTruncated, limitedNodes]);
|
||||
|
||||
const resetExplore = useCallback(() => {
|
||||
setExploreRootId(null);
|
||||
setExpandedNodeIds(new Set());
|
||||
setVisibleNodeIds(new Set());
|
||||
setVisibleEdgeIds(new Set());
|
||||
setNodeDepthMap(new Map());
|
||||
setExploreHistory([]);
|
||||
}, []);
|
||||
|
||||
const rebuildExploreState = useCallback((history: ExploreStep[]) => {
|
||||
const nextVisibleNodeIds = new Set<string>();
|
||||
const nextVisibleEdgeIds = new Set<string>();
|
||||
const nextNodeDepthMap = new Map<string, number>();
|
||||
const nextExpandedNodeIds = new Set<string>();
|
||||
|
||||
for (const step of history) {
|
||||
for (const nodeId of step.addedNodeIds) {
|
||||
nextVisibleNodeIds.add(nodeId);
|
||||
if (!nextNodeDepthMap.has(nodeId)) nextNodeDepthMap.set(nodeId, step.depth);
|
||||
}
|
||||
for (const edgeId of step.addedEdgeIds) nextVisibleEdgeIds.add(edgeId);
|
||||
if (step.depth > 0) nextExpandedNodeIds.add(step.nodeId);
|
||||
}
|
||||
|
||||
setVisibleNodeIds(nextVisibleNodeIds);
|
||||
setVisibleEdgeIds(nextVisibleEdgeIds);
|
||||
setNodeDepthMap(nextNodeDepthMap);
|
||||
setExpandedNodeIds(nextExpandedNodeIds);
|
||||
}, []);
|
||||
|
||||
const startExploreFromNode = useCallback((nodeId: string) => {
|
||||
setExploreRootId(nodeId);
|
||||
setVisibleNodeIds(new Set([nodeId]));
|
||||
setVisibleEdgeIds(new Set());
|
||||
setExpandedNodeIds(new Set());
|
||||
setNodeDepthMap(new Map([[nodeId, 0]]));
|
||||
setExploreHistory([
|
||||
{
|
||||
nodeId,
|
||||
depth: 0,
|
||||
addedNodeIds: [nodeId],
|
||||
addedEdgeIds: [],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
window.setTimeout(() => canvasRef.current?.focusNode(nodeId), 0);
|
||||
}, []);
|
||||
|
||||
const expandOneHop = useCallback(
|
||||
(nodeId: string) => {
|
||||
const currentDepth = nodeDepthMap.get(nodeId) ?? 0;
|
||||
const nextDepth = currentDepth + 1;
|
||||
const addedNodeIds: string[] = [];
|
||||
const addedEdgeIds: string[] = [];
|
||||
const nextVisibleNodeIds = new Set(visibleNodeIds);
|
||||
const nextVisibleEdgeIds = new Set(visibleEdgeIds);
|
||||
const nextNodeDepthMap = new Map(nodeDepthMap);
|
||||
|
||||
for (const edge of filteredEdges) {
|
||||
if (edge.source !== nodeId && edge.target !== nodeId) continue;
|
||||
const edgeId = getEdgeId(edge);
|
||||
const neighborId = edge.source === nodeId ? edge.target : edge.source;
|
||||
if (!nodeById.has(neighborId)) continue;
|
||||
|
||||
if (!nextVisibleEdgeIds.has(edgeId)) {
|
||||
nextVisibleEdgeIds.add(edgeId);
|
||||
addedEdgeIds.push(edgeId);
|
||||
}
|
||||
if (!nextVisibleNodeIds.has(neighborId)) {
|
||||
nextVisibleNodeIds.add(neighborId);
|
||||
nextNodeDepthMap.set(neighborId, nextDepth);
|
||||
addedNodeIds.push(neighborId);
|
||||
}
|
||||
}
|
||||
|
||||
nextVisibleNodeIds.add(nodeId);
|
||||
nextNodeDepthMap.set(nodeId, currentDepth);
|
||||
setVisibleNodeIds(nextVisibleNodeIds);
|
||||
setVisibleEdgeIds(nextVisibleEdgeIds);
|
||||
setNodeDepthMap(nextNodeDepthMap);
|
||||
setExpandedNodeIds((prev) => new Set(prev).add(nodeId));
|
||||
setExploreHistory((prev) => [
|
||||
...prev,
|
||||
{ nodeId, depth: nextDepth, addedNodeIds, addedEdgeIds, timestamp: Date.now() },
|
||||
]);
|
||||
},
|
||||
[filteredEdges, nodeById, nodeDepthMap, visibleEdgeIds, visibleNodeIds],
|
||||
);
|
||||
|
||||
const undoExploreStep = useCallback(() => {
|
||||
if (exploreHistory.length <= 1) {
|
||||
resetExplore();
|
||||
return;
|
||||
}
|
||||
const nextHistory = exploreHistory.slice(0, -1);
|
||||
setExploreHistory(nextHistory);
|
||||
rebuildExploreState(nextHistory);
|
||||
}, [exploreHistory, rebuildExploreState, resetExplore]);
|
||||
|
||||
useEffect(() => {
|
||||
void regenerate();
|
||||
}, [regenerate]);
|
||||
if (graphMode === "explore") resetExplore();
|
||||
}, [
|
||||
disabledNodeKinds,
|
||||
disabledEdgeLabels,
|
||||
hiddenNodeIds,
|
||||
searchKeyword,
|
||||
resetExplore,
|
||||
]);
|
||||
|
||||
const displayedGraph = useMemo(() => {
|
||||
if (graphMode === "full") return { nodes: limitedNodes, edges: limitedEdges };
|
||||
if (!exploreRootId) return { nodes: limitedNodes, edges: [] };
|
||||
return {
|
||||
nodes: allNodes.filter((node) => visibleNodeIds.has(node.id)),
|
||||
edges: allEdges.filter((edge) => visibleEdgeIds.has(getEdgeId(edge))),
|
||||
};
|
||||
}, [
|
||||
allEdges,
|
||||
allNodes,
|
||||
exploreRootId,
|
||||
limitedEdges,
|
||||
limitedNodes,
|
||||
graphMode,
|
||||
visibleEdgeIds,
|
||||
visibleNodeIds,
|
||||
]);
|
||||
|
||||
const searchMatchIds = useMemo(() => {
|
||||
const keyword = searchKeyword.trim().toLowerCase();
|
||||
if (!keyword) return null;
|
||||
const matches = new Set<string>();
|
||||
for (const n of filteredNodes) {
|
||||
if (getNodeTitle(n).toLowerCase().includes(keyword)) matches.add(n.id);
|
||||
}
|
||||
return matches;
|
||||
}, [searchKeyword, filteredNodes]);
|
||||
|
||||
const topNodes = useMemo(() => {
|
||||
// 搜索时核心节点榜单只保留命中的节点,与画布高亮保持一致。
|
||||
const pool = searchMatchIds
|
||||
? filteredNodes.filter((node) => searchMatchIds.has(node.id))
|
||||
: allNodes;
|
||||
return pool
|
||||
.map((node) => ({ node, degree: degrees.get(node.id) ?? 0 }))
|
||||
.sort((a, b) => b.degree - a.degree)
|
||||
.slice(0, 8);
|
||||
}, [allNodes, filteredNodes, searchMatchIds, degrees]);
|
||||
|
||||
const selectedNode = selectedNodeId ? (nodeById.get(selectedNodeId) ?? null) : null;
|
||||
const selectedNeighbors = useMemo(() => {
|
||||
if (!selectedNodeId) return [];
|
||||
const items: Array<{ node: (typeof allNodes)[number]; relation: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of displayedGraph.edges) {
|
||||
const otherId =
|
||||
e.source === selectedNodeId
|
||||
? e.target
|
||||
: e.target === selectedNodeId
|
||||
? e.source
|
||||
: null;
|
||||
if (!otherId || seen.has(otherId)) continue;
|
||||
const other = nodeById.get(otherId);
|
||||
if (!other) continue;
|
||||
seen.add(otherId);
|
||||
items.push({ node: other, relation: getEdgeLabel(e) });
|
||||
}
|
||||
return items.sort(
|
||||
(a, b) => (degrees.get(b.node.id) ?? 0) - (degrees.get(a.node.id) ?? 0),
|
||||
);
|
||||
}, [selectedNodeId, displayedGraph.edges, nodeById, degrees, allNodes]);
|
||||
|
||||
// Note detail for note-type nodes (summary / tags / timestamps / status).
|
||||
const noteId =
|
||||
selectedNode && (selectedNode.type || "").toLowerCase() === "note"
|
||||
? selectedNode.id
|
||||
: undefined;
|
||||
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
||||
|
||||
// --------------------------------------------------------------- handlers
|
||||
const handleSelectNode = useCallback(
|
||||
(id: string | null) => {
|
||||
if (!id) {
|
||||
setSelectedNodeId(null);
|
||||
setShowDetail(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedNodeId(id);
|
||||
setShowDetail(true);
|
||||
|
||||
if (graphMode !== "explore") return;
|
||||
if (!exploreRootId) {
|
||||
startExploreFromNode(id);
|
||||
return;
|
||||
}
|
||||
if (!visibleNodeIds.has(id)) return;
|
||||
if (!expandedNodeIds.has(id)) expandOneHop(id);
|
||||
},
|
||||
[
|
||||
expandedNodeIds,
|
||||
expandOneHop,
|
||||
exploreRootId,
|
||||
graphMode,
|
||||
startExploreFromNode,
|
||||
visibleNodeIds,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePickNode = useCallback(
|
||||
(id: string) => {
|
||||
setSelectedNodeId(id);
|
||||
setShowDetail(true);
|
||||
if (graphMode === "explore" && !exploreRootId) startExploreFromNode(id);
|
||||
canvasRef.current?.focusNode(id);
|
||||
},
|
||||
[exploreRootId, graphMode, startExploreFromNode],
|
||||
);
|
||||
|
||||
const handleSearchEnter = useCallback(() => {
|
||||
if (!searchMatchIds || searchMatchIds.size === 0) return;
|
||||
const first = searchMatchIds.values().next().value;
|
||||
if (first) handlePickNode(first);
|
||||
}, [searchMatchIds, handlePickNode]);
|
||||
|
||||
const handleToggleNodeKind = useCallback((kind: NodeKind) => {
|
||||
setDisabledNodeKinds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(kind)) next.delete(kind);
|
||||
else next.add(kind);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleEdgeLabel = useCallback((label: string) => {
|
||||
setDisabledEdgeLabels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleHideNode = useCallback(
|
||||
(id: string) => {
|
||||
setHiddenNodeIds((prev) => new Set(prev).add(id));
|
||||
setSelectedNodeId((current) => (current === id ? null : current));
|
||||
if (selectedNodeId === id) setShowDetail(false);
|
||||
},
|
||||
[selectedNodeId],
|
||||
);
|
||||
|
||||
const handleRestoreHidden = useCallback(() => {
|
||||
setHiddenNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
// ------------------------------------------------------------- blacklist
|
||||
const addToBlacklist = blacklistActions.add;
|
||||
const removeFromBlacklist = blacklistActions.remove;
|
||||
|
||||
const handleBlacklistNode = useCallback(
|
||||
(id: string) => {
|
||||
const node = nodeById.get(id);
|
||||
if (!node) return;
|
||||
const label = getNodeTitle(node);
|
||||
addToBlacklist.mutate([label], {
|
||||
onSuccess: () => toast.success(`已将「${label}」加入黑名单`),
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : "加入黑名单失败"),
|
||||
});
|
||||
setSelectedNodeId((current) => (current === id ? null : current));
|
||||
if (selectedNodeId === id) setShowDetail(false);
|
||||
},
|
||||
[nodeById, addToBlacklist, selectedNodeId],
|
||||
);
|
||||
|
||||
const handleRemoveBlacklist = useCallback(
|
||||
(entryId: string) => {
|
||||
removeFromBlacklist.mutate(entryId, {
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : "移除黑名单失败"),
|
||||
});
|
||||
},
|
||||
[removeFromBlacklist],
|
||||
);
|
||||
|
||||
// 低价值主题候选:实体节点中的单字符 / 纯数字符号 / 通用泛词。
|
||||
const junkCandidates = useMemo(() => {
|
||||
const blocked = new Set(
|
||||
blacklistItems.map((item) => item.label.trim().toLowerCase()),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const n of allNodes) {
|
||||
if ((n.type || "").toLowerCase() !== "entity") continue;
|
||||
const label = getNodeTitle(n).trim();
|
||||
const key = label.toLowerCase();
|
||||
if (!label || seen.has(key) || blocked.has(key)) continue;
|
||||
if (!isLowValueLabel(label)) continue;
|
||||
seen.add(key);
|
||||
out.push(label);
|
||||
}
|
||||
return out;
|
||||
}, [allNodes, blacklistItems]);
|
||||
|
||||
const handleCleanJunk = useCallback(() => {
|
||||
if (junkCandidates.length === 0) {
|
||||
toast.info("没有发现明显的低价值主题");
|
||||
return;
|
||||
}
|
||||
const preview = junkCandidates.slice(0, 10).join("、");
|
||||
const confirmed = window.confirm(
|
||||
`将 ${junkCandidates.length} 个低价值主题加入黑名单?\n例如:${preview}${junkCandidates.length > 10 ? " 等" : ""}`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
addToBlacklist.mutate(junkCandidates, {
|
||||
onSuccess: (res) => toast.success(`已清理 ${res.added} 个低价值主题`),
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : "清理失败"),
|
||||
});
|
||||
}, [junkCandidates, addToBlacklist]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetch().then((result) => {
|
||||
if (result.isError) {
|
||||
toast.error(
|
||||
result.error instanceof Error ? result.error.message : "刷新图谱失败",
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [refetch]);
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
const el = pageRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
else void el.requestFullscreen();
|
||||
}, []);
|
||||
|
||||
const handleViewSource = useCallback(
|
||||
(id: string) => {
|
||||
navigate(`/page/workspace/knowledge/${encodeURIComponent(id)}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleSetExploreRoot = useCallback(
|
||||
(id: string) => {
|
||||
setGraphMode("explore");
|
||||
startExploreFromNode(id);
|
||||
setSelectedNodeId(id);
|
||||
setShowDetail(true);
|
||||
},
|
||||
[startExploreFromNode],
|
||||
);
|
||||
|
||||
const exploreDepth = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const depth of nodeDepthMap.values()) max = Math.max(max, depth);
|
||||
return max;
|
||||
}, [nodeDepthMap]);
|
||||
|
||||
const exploreRootTitle = exploreRootId
|
||||
? getNodeTitle(nodeById.get(exploreRootId) ?? { id: exploreRootId })
|
||||
: null;
|
||||
|
||||
const selectedExploreInfo =
|
||||
graphMode === "explore" && selectedNodeId
|
||||
? {
|
||||
depth: nodeDepthMap.get(selectedNodeId) ?? null,
|
||||
expanded: expandedNodeIds.has(selectedNodeId),
|
||||
isRoot: selectedNodeId === exploreRootId,
|
||||
}
|
||||
: null;
|
||||
|
||||
// ---------------------------------------------------------------- overlay
|
||||
let overlay: React.ReactNode = null;
|
||||
if (isLoading) {
|
||||
overlay = (
|
||||
<div className="kg-canvas-status">
|
||||
<Loader2 size={26} className="animate-spin" />
|
||||
<div className="kg-canvas-status-title">正在加载知识图谱...</div>
|
||||
</div>
|
||||
);
|
||||
} else if (isError) {
|
||||
overlay = (
|
||||
<div className="kg-canvas-status">
|
||||
<Network size={30} />
|
||||
<div className="kg-canvas-status-title">图谱加载失败</div>
|
||||
<div className="kg-canvas-status-desc">
|
||||
{error instanceof Error ? error.message : "请求失败,请稍后重试。"}
|
||||
</div>
|
||||
<button type="button" className="kg-icon-button" onClick={handleRefresh}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (allNodes.length === 0) {
|
||||
overlay = (
|
||||
<div className="kg-canvas-status">
|
||||
<Network size={30} />
|
||||
<div className="kg-canvas-status-title">暂无知识图谱数据</div>
|
||||
<div className="kg-canvas-status-desc">
|
||||
请先导入知识文档,或从知识库中抽取实体、标签与关系后再查看图谱。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (filteredNodes.length === 0) {
|
||||
overlay = (
|
||||
<div className="kg-canvas-status">
|
||||
<Network size={30} />
|
||||
<div className="kg-canvas-status-title">当前筛选没有可显示的节点</div>
|
||||
<div className="kg-canvas-status-desc">
|
||||
请在左侧勾选更多节点类型,或恢复已隐藏的节点。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<div className="kg-floating-pill">
|
||||
<span className="kg-pill-label">模式</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-pill-button${graphMode === "explore" ? " kg-pill-button-active" : ""}`}
|
||||
onClick={() => setGraphMode("explore")}
|
||||
>
|
||||
探索模式
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-pill-button${graphMode === "full" ? " kg-pill-button-active" : ""}`}
|
||||
onClick={() => setGraphMode("full")}
|
||||
>
|
||||
全量图谱
|
||||
</button>
|
||||
</div>
|
||||
{graphMode === "explore" ? (
|
||||
<>
|
||||
<div className="kg-floating-pill">
|
||||
起点:{exploreRootTitle ? exploreRootTitle : "未选择"}
|
||||
</div>
|
||||
<div className="kg-floating-pill">
|
||||
深度 {exploreDepth} · 已显示 {displayedGraph.nodes.length} 节点 /{" "}
|
||||
{displayedGraph.edges.length} 关系
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-explore-button"
|
||||
disabled={exploreHistory.length === 0}
|
||||
onClick={undoExploreStep}
|
||||
>
|
||||
<Undo2 size={13} /> 返回上一层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-explore-button"
|
||||
disabled={!exploreRootId}
|
||||
onClick={resetExplore}
|
||||
>
|
||||
<RotateCcw size={13} /> 重置探索
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{graphMode === "full" && filteredNodes.length > FULL_GRAPH_NODE_LIMIT ? (
|
||||
<div className="kg-floating-pill">
|
||||
{fullGraphTruncated
|
||||
? `节点较多,已显示核心 ${FULL_GRAPH_NODE_LIMIT} / ${filteredNodes.length}`
|
||||
: `已显示全部 ${filteredNodes.length} 个节点`}
|
||||
<button
|
||||
type="button"
|
||||
className="kg-pill-button"
|
||||
title={
|
||||
fullGraphTruncated
|
||||
? "渲染全部节点(节点很多时可能卡顿)"
|
||||
: "只渲染度数最高的核心节点"
|
||||
}
|
||||
onClick={() => setShowAllNodes((v) => !v)}
|
||||
>
|
||||
{fullGraphTruncated ? "显示全部" : "仅核心节点"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="kg-floating-pill">
|
||||
<span className="kg-pill-label">布局</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-pill-button${layoutType === "force" ? " kg-pill-button-active" : ""}`}
|
||||
onClick={() => setLayoutType("force")}
|
||||
>
|
||||
力导向
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-pill-button${layoutType === "circular" ? " kg-pill-button-active" : ""}`}
|
||||
onClick={() => setLayoutType("circular")}
|
||||
>
|
||||
环形
|
||||
</button>
|
||||
</div>
|
||||
<div className="kg-floating-pill">
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-pill-button${showLabels ? " kg-pill-button-active" : ""}`}
|
||||
onClick={() => setShowLabels((v) => !v)}
|
||||
>
|
||||
<Tags size={13} /> 标签 {showLabels ? "开" : "关"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const hint =
|
||||
graphMode === "explore" ? (
|
||||
<div className="kg-canvas-hint">
|
||||
{!exploreRootId
|
||||
? "点击任意节点作为起点,开始逐层探索知识关系。"
|
||||
: "点击已显示节点,可继续展开它的一跳邻居。已展开的节点和路径会保留。"}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
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="kg-page" ref={pageRef}>
|
||||
<GraphHeader
|
||||
nodeCount={displayedGraph.nodes.length}
|
||||
edgeCount={displayedGraph.edges.length}
|
||||
totalNodes={allNodes.length}
|
||||
totalEdges={allEdges.length}
|
||||
refreshing={isFetching}
|
||||
onBack={() => navigate("/page/workspace/knowledge")}
|
||||
onRelayout={() => canvasRef.current?.relayout()}
|
||||
onFitView={() => canvasRef.current?.fitView()}
|
||||
onZoomIn={() => canvasRef.current?.zoomIn()}
|
||||
onZoomOut={() => canvasRef.current?.zoomOut()}
|
||||
onExport={() => canvasRef.current?.exportImage()}
|
||||
onFullscreen={handleFullscreen}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={[
|
||||
"kg-body",
|
||||
showSidebar ? "" : "kg-hide-left",
|
||||
showDetail ? "" : "kg-hide-right",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{showSidebar ? (
|
||||
<GraphSidebar
|
||||
searchKeyword={searchKeyword}
|
||||
searchMatchCount={searchMatchIds ? searchMatchIds.size : null}
|
||||
nodeKindCounts={nodeKindCounts}
|
||||
edgeLabelCounts={edgeLabelCounts}
|
||||
disabledNodeKinds={disabledNodeKinds}
|
||||
disabledEdgeLabels={disabledEdgeLabels}
|
||||
stats={stats}
|
||||
topNodes={topNodes}
|
||||
hiddenCount={hiddenNodeIds.size}
|
||||
blacklistItems={blacklistItems}
|
||||
blacklistPending={addToBlacklist.isPending || removeFromBlacklist.isPending}
|
||||
junkCandidateCount={junkCandidates.length}
|
||||
onSearchChange={setSearchKeyword}
|
||||
onSearchEnter={handleSearchEnter}
|
||||
onToggleNodeKind={handleToggleNodeKind}
|
||||
onToggleEdgeLabel={handleToggleEdgeLabel}
|
||||
onRestoreHidden={handleRestoreHidden}
|
||||
onRemoveBlacklist={handleRemoveBlacklist}
|
||||
onCleanJunk={handleCleanJunk}
|
||||
onPickNode={handlePickNode}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" /> 生成图谱中...
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="kg-canvas-shell">
|
||||
{!showSidebar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="kg-side-rail-toggle kg-side-rail-toggle-left"
|
||||
onClick={() => setShowSidebar(true)}
|
||||
>
|
||||
筛选
|
||||
</button>
|
||||
) : null}
|
||||
<GraphCanvas
|
||||
ref={canvasRef}
|
||||
nodes={displayedGraph.nodes}
|
||||
edges={displayedGraph.edges}
|
||||
degrees={degrees}
|
||||
selectedNodeId={selectedNodeId}
|
||||
searchMatchIds={searchMatchIds}
|
||||
layoutType={layoutType}
|
||||
showLabels={showLabels}
|
||||
theme={theme}
|
||||
onSelectNode={handleSelectNode}
|
||||
onTooltip={setTooltip}
|
||||
onZoomChange={setZoomLevel}
|
||||
explore={
|
||||
graphMode === "explore"
|
||||
? {
|
||||
preview: !exploreRootId,
|
||||
rootId: exploreRootId,
|
||||
nodeDepthMap,
|
||||
expandedNodeIds,
|
||||
}
|
||||
: null
|
||||
}
|
||||
toolbar={toolbar}
|
||||
hint={hint}
|
||||
overlay={overlay}
|
||||
/>
|
||||
{!showDetail ? (
|
||||
<button
|
||||
type="button"
|
||||
className="kg-side-rail-toggle kg-side-rail-toggle-right"
|
||||
onClick={() => setShowDetail(true)}
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showDetail ? (
|
||||
<GraphDetailPanel
|
||||
node={selectedNode}
|
||||
degree={selectedNodeId ? (degrees.get(selectedNodeId) ?? 0) : 0}
|
||||
neighbors={selectedNeighbors}
|
||||
note={noteId ? note : undefined}
|
||||
noteLoading={Boolean(noteId) && noteLoading}
|
||||
explore={selectedExploreInfo}
|
||||
onViewSource={handleViewSource}
|
||||
onLocate={(id) => canvasRef.current?.focusNode(id)}
|
||||
onHide={handleHideNode}
|
||||
onBlacklist={handleBlacklistNode}
|
||||
onPickNeighbor={handlePickNode}
|
||||
onExpandNode={expandOneHop}
|
||||
onSetExploreRoot={handleSetExploreRoot}
|
||||
onClose={() => setShowDetail(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<GraphFooter
|
||||
nodeKindCounts={nodeKindCounts}
|
||||
edgeLabels={[...edgeLabelCounts.keys()]}
|
||||
zoomLevel={zoomLevel}
|
||||
selectedTitle={selectedNode ? getNodeTitle(selectedNode) : null}
|
||||
visibleNodes={displayedGraph.nodes.length}
|
||||
visibleEdges={displayedGraph.edges.length}
|
||||
/>
|
||||
|
||||
<GraphTooltip tooltip={tooltip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -433,14 +433,22 @@ export default function NewAgentPage() {
|
||||
)}
|
||||
</div>
|
||||
<PromptInput
|
||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||
onSubmit={({ text }) => {
|
||||
if (thread.isLoading) {
|
||||
void thread.stop();
|
||||
return;
|
||||
}
|
||||
void handleChatSubmit(text);
|
||||
}}
|
||||
>
|
||||
<PromptInputTextarea
|
||||
placeholder="直接告诉模型你想要什么样的智能体,或回答模型的问题"
|
||||
disabled={thread.isLoading}
|
||||
/>
|
||||
<PromptInputFooter className="justify-end">
|
||||
<PromptInputSubmit disabled={thread.isLoading} />
|
||||
<PromptInputSubmit
|
||||
status={thread.isLoading ? "streaming" : "ready"}
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
@@ -538,7 +546,7 @@ export default function NewAgentPage() {
|
||||
placeholder="写下智能体的人设、边界、工作方式和输出要求"
|
||||
value={soulInput}
|
||||
onChange={(event) => setSoulInput(event.currentTarget.value)}
|
||||
className="min-h-48 max-h-[400px] resize-y overflow-y-auto font-mono text-xs"
|
||||
className="max-h-[50vh] min-h-64 resize-y overflow-y-auto font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { PageSidebar } from "@/components/page-sidebar";
|
||||
import { PageSidebarDark } from "@/components/page-sidebar-dark";
|
||||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
import StrategyRoutes from "./StrategyRoutes";
|
||||
@@ -16,17 +18,20 @@ import NotebookRoutes from "./NotebookRoutes";
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
|
||||
const location = useLocation();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const pageSidebarAllowed =
|
||||
sidebarEnabled && !isChatsIframeWorkspacePath(location.pathname);
|
||||
|
||||
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
|
||||
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
|
||||
// 下容器不占满整屏时,高度才能正确收缩而非锁死整屏。
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-muted/20">
|
||||
{sidebarEnabled && <Header collapsed={collapsed} />}
|
||||
{pageSidebarAllowed && <Header collapsed={collapsed} />}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{sidebarEnabled && (
|
||||
{pageSidebarAllowed && (
|
||||
isDark ? (
|
||||
<PageSidebarDark
|
||||
collapsed={collapsed}
|
||||
@@ -41,7 +46,7 @@ function PageLayout() {
|
||||
)}
|
||||
<main
|
||||
className={`flex-1 overflow-hidden ${
|
||||
!sidebarEnabled
|
||||
!pageSidebarAllowed
|
||||
? ""
|
||||
: isDark
|
||||
// 深色 sidebar:展开 118px(10 + 98 + 10 gap),收起 = 0 sidebar 完全消失,
|
||||
@@ -59,9 +64,9 @@ function PageLayout() {
|
||||
element={
|
||||
<WorkspaceRoutes
|
||||
pageSidebarCollapsed={collapsed}
|
||||
pageSidebarHidden={!sidebarEnabled}
|
||||
headerHidden={!sidebarEnabled}
|
||||
compactWorkspaceSidebar={sidebarEnabled}
|
||||
pageSidebarHidden={!pageSidebarAllowed}
|
||||
headerHidden={!pageSidebarAllowed}
|
||||
compactWorkspaceSidebar={pageSidebarAllowed}
|
||||
pageSidebarDark={isDark}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -34,12 +34,10 @@ import {
|
||||
} 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,
|
||||
@@ -52,8 +50,10 @@ 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 LEADERBOARD_PAGE_SIZE = 10;
|
||||
// 公开页只统计近 7 天:跨日快照在每次请求时合并,天数越多合并越慢(尤其常问问题的
|
||||
// 模糊聚合是 O(n^2))。7 天足够展示近期热度,同时把请求耗时压到最低。
|
||||
const RANGE_DAYS = 7;
|
||||
|
||||
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
||||
crimson: {
|
||||
@@ -98,11 +98,6 @@ function fmtDate(value?: string | null): string {
|
||||
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;
|
||||
@@ -367,12 +362,6 @@ export default function PublicLeaderboardPage() {
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -688,40 +677,6 @@ function DimensionRanking({
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon, PlusIcon, StarIcon, Trash2Icon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
usePublishSquares,
|
||||
useUpdatePublishSquares,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import type { PublishSquare } from "@/core/system-settings/api";
|
||||
|
||||
/** Local draft row — `id` is stable (server- or client-generated) so the
|
||||
* "default" radio can reference it before the first save. */
|
||||
type Draft = PublishSquare;
|
||||
|
||||
function genId(): string {
|
||||
// Frontend-only id; the backend also accepts/normalizes these.
|
||||
return `sq_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export default function PublishSquaresSettingsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { data, isLoading, error } = usePublishSquares();
|
||||
const update = useUpdatePublishSquares();
|
||||
|
||||
const [squares, setSquares] = useState<Draft[]>([]);
|
||||
const [defaultId, setDefaultId] = useState<string>("");
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setSquares(data.squares.map((s) => ({ ...s })));
|
||||
setDefaultId(data.default_square_id);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
function addSquare() {
|
||||
setSquares((prev) => [...prev, { id: genId(), name: "", description: "" }]);
|
||||
}
|
||||
|
||||
function patchSquare(id: string, patch: Partial<Draft>) {
|
||||
setSquares((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, ...patch } : s)),
|
||||
);
|
||||
}
|
||||
|
||||
function removeSquare(id: string) {
|
||||
setSquares((prev) => prev.filter((s) => s.id !== id));
|
||||
if (defaultId === id) setDefaultId("");
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const cleaned = squares
|
||||
.map((s) => ({ ...s, name: s.name.trim(), description: s.description.trim() }))
|
||||
.filter((s) => s.name.length > 0);
|
||||
if (cleaned.length === 0) {
|
||||
toast.error("请至少配置一个广场(名称不能为空)");
|
||||
return;
|
||||
}
|
||||
// Re-anchor the default to a surviving square if the picked one was removed.
|
||||
const nextDefault = cleaned.some((s) => s.id === defaultId)
|
||||
? defaultId
|
||||
: cleaned[0].id;
|
||||
try {
|
||||
const saved = await update.mutateAsync({
|
||||
squares: cleaned,
|
||||
default_square_id: nextDefault,
|
||||
});
|
||||
setSquares(saved.squares.map((s) => ({ ...s })));
|
||||
setDefaultId(saved.default_square_id);
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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={embedded ? "text-base font-semibold" : "text-xl font-semibold"}
|
||||
>
|
||||
发布广场配置
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理员可在此配置多个「发布广场」。发布智能体或技能时,未选择广场则归入默认广场,也可选择发布到其他广场;
|
||||
每个智能体或技能只能归属于一个广场,不会重复出现在多个广场中。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex flex-col gap-3">
|
||||
{squares.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
暂无广场,点击下方「添加广场」创建第一个。
|
||||
</p>
|
||||
) : (
|
||||
squares.map((s) => {
|
||||
const isDefault = defaultId === s.id;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex flex-col gap-2 rounded-lg border p-3 dark:border-[#1B477E]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
title={isDefault ? "默认广场" : "设为默认广场"}
|
||||
className={
|
||||
isDefault ? "text-amber-500 hover:text-amber-600" : ""
|
||||
}
|
||||
onClick={() => setDefaultId(s.id)}
|
||||
>
|
||||
<StarIcon
|
||||
className={isDefault ? "size-4 fill-current" : "size-4"}
|
||||
/>
|
||||
</Button>
|
||||
<Input
|
||||
value={s.name}
|
||||
placeholder="广场名称(如:公开广场 / 合作伙伴 / 内测)"
|
||||
onChange={(e) => patchSquare(s.id, { name: e.target.value })}
|
||||
className="h-9 flex-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
title="删除广场"
|
||||
onClick={() => removeSquare(s.id)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={s.description}
|
||||
placeholder="广场描述(选填)"
|
||||
onChange={(e) =>
|
||||
patchSquare(s.id, { description: e.target.value })
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
{isDefault && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
默认广场:发布时未选择广场的智能体/技能会归入此处。
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button type="button" variant="outline" size="sm" onClick={addSquare}>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
添加广场
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
import SkillsPage from './SkillsPage';
|
||||
import UsersPage from './UsersPage';
|
||||
@@ -66,6 +67,7 @@ export function WorkspaceLayout({
|
||||
children,
|
||||
pageSidebarCollapsed,
|
||||
pageSidebarHidden = false,
|
||||
workspaceSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
compactWorkspaceSidebar = false,
|
||||
wrapperOverflowHidden = false,
|
||||
@@ -75,6 +77,7 @@ export function WorkspaceLayout({
|
||||
children: ReactNode;
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
workspaceSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
compactWorkspaceSidebar?: boolean;
|
||||
wrapperOverflowHidden?: boolean;
|
||||
@@ -108,7 +111,9 @@ export function WorkspaceLayout({
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen
|
||||
>
|
||||
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
|
||||
{!workspaceSidebarHidden && (
|
||||
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
|
||||
)}
|
||||
<SidebarInset
|
||||
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
|
||||
>
|
||||
@@ -204,7 +209,11 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<WorkspaceLayout
|
||||
{...layoutProps}
|
||||
pageSidebarHidden
|
||||
workspaceSidebarHidden
|
||||
>
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
@@ -216,6 +225,8 @@ export default function WorkspaceRoutes({
|
||||
element={
|
||||
<WorkspaceLayout
|
||||
{...layoutProps}
|
||||
pageSidebarHidden
|
||||
workspaceSidebarHidden
|
||||
wrapperOverflowHidden
|
||||
insetOverflowHidden
|
||||
>
|
||||
@@ -496,6 +507,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/publish-squares"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PublishSquaresSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user