Merge branch 'ji' into dhk-1

Resolve column_additions_mysql.sql conflict: keep square_id columns from master and roundtable_drafts.task_id from dhk-1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-11 17:40:08 +08:00
commit bcc3d617a0
142 files changed
+14953 -2383

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>
+184 -114
View File
@@ -1,28 +1,25 @@
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { BotIcon, MessageCircleQuestionIcon, 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,11 @@ export default function AgentChatPage() {
const agentDisplayName = desensitize(
agent?.name ?? (isAgentLoading ? t.common.loading : ""),
);
// 常用问题 shown as a list beside the input box on the landing screen.
const commonQuestions = useMemo(
() => normalizeAgentQuestions(agent?.common_questions),
[agent?.common_questions],
);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
@@ -162,129 +164,197 @@ 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`);
}}
{/* Root: fixed-height horizontal split. The left main column owns the
chat (home / conversation) and the input; the right sidebar is a
separate flex sibling, so nothing in the main column can ever bleed
over it. */}
<div className="flex size-full min-h-0 overflow-hidden">
{/* ── Left: main conversation column ───────────────────────────── */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* Header — in normal flow (not absolute), 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}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
{agentDisplayName}
</span>
</div>
<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="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</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">
<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>
</header>
{isLandingState ? (
/* ── home: centered hero + suggested questions + 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"
/>
{/* 常用问题 sit as a list to the LEFT of the input box (not as
floating pills above it). On narrow screens the list stacks
above the input instead of squeezing it. */}
<div
className={cn(
"flex w-full flex-col gap-3 sm:flex-row sm:items-end sm:gap-4",
commonQuestions.length > 0
? "max-w-(--container-width-lg)"
: "max-w-(--container-width-md)",
)}
>
{commonQuestions.length > 0 && (
<div className="border-border/60 bg-card/60 w-full shrink-0 rounded-xl border p-3 sm:w-64">
<div className="text-muted-foreground mb-2 flex items-center gap-1.5 text-xs font-medium">
<MessageCircleQuestionIcon className="size-3.5" />
常用问题
</div>
<div className="flex max-h-44 flex-col gap-1.5 overflow-y-auto">
{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/60 bg-background/60 hover:bg-accent hover:border-border w-full truncate rounded-lg border px-3 py-2 text-left text-sm transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{desensitize(question.title)}
</button>
))}
</div>
</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>
) : (
/* ── conversation (active / history): 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"
className="bg-background/5 mb-2"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
!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 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>
{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}
</div>
)}
</div>
</div>
)}
</main>
{/* ── Right: agent detail panel (profile + inline editing for
admins / owners; read-only for everyone else) ───────────────── */}
<AgentDetailPanel
agent={agent}
agentName={agentDisplayName}
disabled={thread.isLoading || isDemoMode}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
/>
</div>
</ChatBox>
</ThreadContext.Provider>
+284 -469
View File
@@ -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>
);
}
+678 -62
View File
@@ -1,79 +1,695 @@
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 { useKnowledgeGraph, useKnowledgeNote } from "@/core/knowledge/hooks";
export default function KnowledgeGraphPage() {
const navigate = useNavigate();
const { exportGraph } = useKnowledgeActions();
const [src, setSrc] = useState<string | null>(null);
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null);
const 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();
// ------------------------------------------------------------------ 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 [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>("explore");
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 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: filteredNodes, edges: filteredEdges };
if (!exploreRootId) return { nodes: filteredNodes, edges: [] };
return {
nodes: allNodes.filter((node) => visibleNodeIds.has(node.id)),
edges: allEdges.filter((edge) => visibleEdgeIds.has(getEdgeId(edge))),
};
}, [
allEdges,
allNodes,
exploreRootId,
filteredEdges,
filteredNodes,
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(
() =>
[...allNodes]
.map((node) => ({ node, degree: degrees.get(node.id) ?? 0 }))
.sort((a, b) => b.degree - a.degree)
.slice(0, 8),
[allNodes, 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());
}, []);
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}
<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}
onSearchChange={setSearchKeyword}
onSearchEnter={handleSearchEnter}
onToggleNodeKind={handleToggleNodeKind}
onToggleEdgeLabel={handleToggleEdgeLabel}
onRestoreHidden={handleRestoreHidden}
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}
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}
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>
);
}
@@ -122,7 +122,7 @@ export default function LightAppNoteListPage() {
>
<Plus className="h-5 w-5" />
</span>
<span className="text-base font-medium">新建笔记本</span>
<span className="text-base font-medium">新建空间</span>
</button>
);
}
@@ -146,7 +146,7 @@ export default function LightAppNoteListPage() {
>
<Plus className="h-5 w-5" />
</span>
<span className="text-base font-medium">新建笔记本</span>
<span className="text-base font-medium">新建空间</span>
</button>
);
};
@@ -230,7 +230,7 @@ export default function LightAppNoteListPage() {
isDark ? "text-slate-100" : "text-slate-950",
)}
>
我的笔记本
我的空间
</h1>
<div className="flex items-center gap-2">
@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import { getNotebookApiBaseURL } from '@/core/config';
interface NotebookTokenData {
doc_ids: number[];
search_space_id: number;
user_id: string;
user_token: string;
}
export default function NotebookAIWritingPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const [data, setData] = useState<NotebookTokenData | null>(null);
const [spaceName, setSpaceName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) {
setError('缺少 token 参数');
return;
}
const base = getNotebookApiBaseURL();
fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`)
.then((res) => {
if (!res.ok) throw new Error(`请求失败 ${res.status}`);
return res.json() as Promise<NotebookTokenData>;
})
.then((d) => {
setData(d);
return fetch(`${base}/api/v1/searchspaces/${d.search_space_id}`, {
headers: { Authorization: `Bearer ${d.user_token}` },
});
})
.then((res) => {
if (!res.ok) return null;
return res.json() as Promise<{ name: string }>;
})
.then((space) => {
if (space?.name) setSpaceName(space.name);
})
.catch((e: Error) => setError(e.message));
}, [token]);
// Keep-alive:每 50 秒 ping 一次 token 接口,防止 Redis TTL 过期
useEffect(() => {
if (!token) return;
const base = getNotebookApiBaseURL();
const id = setInterval(() => {
fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`).catch(() => {});
}, 50_000);
return () => clearInterval(id);
}, [token]);
return (
<div className="h-full flex flex-col overflow-hidden">
{/* 索财笔记 Header —— 只承载空间信息(品牌 / 右侧标签已移除) */}
<div className="relative flex items-center gap-3 px-5 h-[52px] shrink-0 bg-gradient-to-r from-[#1e3a5f] to-[#1a3255] border-b border-[#2a4a70]">
{/* 顶部极细品牌高光线 */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#7fb3e8]/55 to-transparent" />
{/* 空间信息:精致玻璃胶囊 */}
{data?.search_space_id && (
<div
className="inline-flex h-7 items-center gap-2 rounded-full border border-[#7fb3e8]/30 bg-gradient-to-b from-[#2d5080]/55 to-[#1a3a60]/55 pl-2 pr-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_2px_rgba(0,0,0,0.18)] backdrop-blur-sm transition-all hover:border-[#7fb3e8]/55 hover:-translate-y-px hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_2px_10px_-2px_rgba(59,130,246,0.45)]"
title={`当前空间:${spaceName ?? data.search_space_id}`}
>
{/* 图标徽标 */}
<span className="flex size-[18px] items-center justify-center rounded-[5px] bg-gradient-to-br from-[#3b82f6] to-[#1d4ed8] shadow-[0_0_0_1px_rgba(127,179,232,0.25),0_2px_6px_-1px_rgba(29,78,216,0.6)]">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="size-[11px] text-[#e0f0ff]"
>
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
</span>
<span className="text-[11px] font-medium tracking-wide text-[#7fb3e8]">空间</span>
<span className="h-3 w-px bg-[#7fb3e8]/25" />
<span className="max-w-[200px] truncate text-[12.5px] font-semibold text-[#d6e8fb]">
{spaceName ?? data.search_space_id}
</span>
{/* 已连接状态点 */}
<span className="size-1.5 shrink-0 rounded-full bg-[#4ade80] shadow-[0_0_0_2px_rgba(74,222,128,0.18)]" />
</div>
)}
{/* 弹性占位 */}
<div className="flex-1" />
</div>
{/* 主体 */}
<div className="flex-1 min-h-0 overflow-hidden">
{error ? (
<div className="flex items-center justify-center h-full text-red-400 text-sm">{error}</div>
) : !data ? (
<div className="flex items-center justify-center h-full text-[#7fb3e8] text-sm">加载中…</div>
) : (
<AIWritingPage
hideAdvancedFields
initialNotebookId={data.search_space_id}
initialDocumentIds={data.doc_ids}
studioToken={data.user_token}
/>
)}
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Route, Routes } from 'react-router-dom';
import NotebookAIWritingPage from './NotebookAIWritingPage';
export default function NotebookRoutes() {
return (
<Routes>
<Route path="ai-writing" element={<NotebookAIWritingPage />} />
</Routes>
);
}
+2
View File
@@ -11,6 +11,7 @@ import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
import RoundtableRoutes from "./RoundtableRoutes";
import NotebookRoutes from "./NotebookRoutes";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
@@ -68,6 +69,7 @@ function PageLayout() {
<Route path="strategy/*" element={<StrategyRoutes />} />
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
<Route path="roundtable/*" element={<RoundtableRoutes />} />
<Route path="notebook/*" element={<NotebookRoutes />} />
</Routes>
</main>
</div>
@@ -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>
);
}
File diff suppressed because it is too large. Load diff
+15 -15
View File
@@ -180,18 +180,18 @@ export default function StudioNotebooksPage() {
<div className="min-h-full bg-background p-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
<h1 className="text-2xl font-semibold text-foreground">空间</h1>
<button
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
className="flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus className="size-4" />
新建笔记本
新建空间
</button>
</div>
{isError && (
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
<p className="text-sm text-muted-foreground">加载空间失败,请稍后重试。</p>
)}
{/* Grid */}
@@ -199,20 +199,20 @@ export default function StudioNotebooksPage() {
{isLoading
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
: notebooks.map((nb) => (
<NotebookCard
key={nb.id}
nb={nb}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
/>
))}
<NotebookCard
key={nb.id}
nb={nb}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
/>
))}
</div>
{/* ── 新建 Dialog ── */}
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建笔记本</DialogTitle>
<DialogTitle>新建空间</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<input
@@ -220,7 +220,7 @@ export default function StudioNotebooksPage() {
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
placeholder="笔记本名称"
placeholder="空间名称"
disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
@@ -239,7 +239,7 @@ export default function StudioNotebooksPage() {
<Dialog open={!!renameTarget} onOpenChange={(v) => !renaming && !v && setRenameTarget(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>重命名笔记本</DialogTitle>
<DialogTitle>重命名空间</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<div className="flex items-center gap-3">
@@ -272,12 +272,12 @@ export default function StudioNotebooksPage() {
<Dialog open={!!deleteTarget} onOpenChange={(v) => !deleting && !v && setDeleteTarget(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>删除笔记本</DialogTitle>
<DialogTitle>删除空间</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-1">
<p className="text-sm text-muted-foreground">
确定要删除{deleteTarget ? <span className="font-medium text-foreground">「{deleteTarget.name}」</span> : ""}吗?
删除后笔记本内所有来源数据将一并移除,且无法恢复。
删除后空间内所有来源数据将一并移除,且无法恢复。
</p>
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
<div className="flex justify-end gap-2">
@@ -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';
@@ -496,6 +497,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={