Sidebar.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import { useEffect, useMemo, useState } from "react";
  2. import { useApi } from "../hooks/use-api";
  3. import type { SSEMessage } from "../hooks/use-sse";
  4. import { shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity";
  5. import type { TFunction } from "../hooks/use-i18n";
  6. import { useChatStore } from "../store/chat";
  7. import { ConfirmDialog } from "./ConfirmDialog";
  8. import {
  9. Dialog,
  10. DialogContent,
  11. DialogDescription,
  12. DialogFooter,
  13. DialogHeader,
  14. DialogTitle,
  15. } from "./ui/dialog";
  16. import {
  17. DropdownMenu,
  18. DropdownMenuContent,
  19. DropdownMenuItem,
  20. DropdownMenuSeparator,
  21. DropdownMenuTrigger,
  22. } from "./ui/dropdown-menu";
  23. import {
  24. Book,
  25. Settings,
  26. Terminal,
  27. Plus,
  28. ScrollText,
  29. Boxes,
  30. Zap,
  31. Wand2,
  32. FileInput,
  33. TrendingUp,
  34. Stethoscope,
  35. ChevronRight,
  36. Loader2,
  37. MoreHorizontal,
  38. Pencil,
  39. Trash2,
  40. } from "lucide-react";
  41. interface BookSummary {
  42. readonly id: string;
  43. readonly title: string;
  44. readonly genre: string;
  45. readonly status: string;
  46. readonly chaptersWritten: number;
  47. }
  48. interface Nav {
  49. toDashboard: () => void;
  50. toBook: (id: string) => void;
  51. toBookCreate: () => void;
  52. toServices: () => void;
  53. toDaemon: () => void;
  54. toLogs: () => void;
  55. toGenres: () => void;
  56. toStyle: () => void;
  57. toImport: () => void;
  58. toRadar: () => void;
  59. toDoctor: () => void;
  60. }
  61. export function Sidebar({ nav, activePage, sse, t }: {
  62. nav: Nav;
  63. activePage: string;
  64. sse: { messages: ReadonlyArray<SSEMessage> };
  65. t: TFunction;
  66. }) {
  67. const { data, refetch: refetchBooks } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
  68. const { data: daemon, refetch: refetchDaemon } = useApi<{ running: boolean }>("/daemon");
  69. const sessions = useChatStore((s) => s.sessions);
  70. const sessionIdsByBook = useChatStore((s) => s.sessionIdsByBook);
  71. const activeSessionId = useChatStore((s) => s.activeSessionId);
  72. const bookDataVersion = useChatStore((s) => s.bookDataVersion);
  73. const loadSessionList = useChatStore((s) => s.loadSessionList);
  74. const loadSessionDetail = useChatStore((s) => s.loadSessionDetail);
  75. const activateSession = useChatStore((s) => s.activateSession);
  76. const createSession = useChatStore((s) => s.createSession);
  77. const renameSession = useChatStore((s) => s.renameSession);
  78. const deleteSession = useChatStore((s) => s.deleteSession);
  79. const [expandedBooks, setExpandedBooks] = useState<Record<string, boolean>>({});
  80. const [renameTarget, setRenameTarget] = useState<{ sessionId: string; currentTitle: string } | null>(null);
  81. const [renameValue, setRenameValue] = useState("");
  82. const [deleteTarget, setDeleteTarget] = useState<{ sessionId: string; title: string } | null>(null);
  83. const books = data?.books ?? [];
  84. useEffect(() => {
  85. const recent = sse.messages.at(-1);
  86. if (!recent) return;
  87. if (shouldRefetchBookCollections(recent)) {
  88. refetchBooks();
  89. }
  90. if (shouldRefetchDaemonStatus(recent)) {
  91. refetchDaemon();
  92. }
  93. }, [refetchBooks, refetchDaemon, sse.messages]);
  94. useEffect(() => {
  95. for (const book of books) {
  96. void loadSessionList(book.id);
  97. }
  98. }, [bookDataVersion, books, loadSessionList]);
  99. useEffect(() => {
  100. if (!activePage.startsWith("book:")) return;
  101. const activeBookId = activePage.slice("book:".length);
  102. setExpandedBooks((prev) => ({ ...prev, [activeBookId]: true }));
  103. }, [activePage]);
  104. const sessionsByBook = useMemo(
  105. () =>
  106. Object.fromEntries(
  107. books.map((book) => [
  108. book.id,
  109. (sessionIdsByBook[book.id] ?? [])
  110. .map((sessionId) => sessions[sessionId])
  111. .filter(Boolean),
  112. ]),
  113. ) as Record<string, Array<(typeof sessions)[string]>>,
  114. [books, sessionIdsByBook, sessions],
  115. );
  116. const openSession = (bookId: string, sessionId: string) => {
  117. activateSession(sessionId);
  118. nav.toBook(bookId);
  119. void loadSessionDetail(sessionId);
  120. };
  121. const handleCreateSession = async (bookId: string) => {
  122. const sessionId = await createSession(bookId);
  123. setExpandedBooks((prev) => ({ ...prev, [bookId]: true }));
  124. nav.toBook(bookId);
  125. await loadSessionDetail(sessionId);
  126. };
  127. const handleRenameConfirm = async () => {
  128. if (!renameTarget) return;
  129. const nextTitle = renameValue.trim();
  130. if (!nextTitle) return;
  131. await renameSession(renameTarget.sessionId, nextTitle);
  132. setRenameTarget(null);
  133. setRenameValue("");
  134. };
  135. const handleDeleteConfirm = async () => {
  136. if (!deleteTarget) return;
  137. await deleteSession(deleteTarget.sessionId);
  138. setDeleteTarget(null);
  139. };
  140. return (
  141. <aside className="w-[260px] shrink-0 border-r border-border bg-background/80 backdrop-blur-md flex flex-col h-full overflow-hidden select-none">
  142. {/* Logo Area */}
  143. <div className="px-6 py-8">
  144. <button
  145. onClick={nav.toDashboard}
  146. className="group flex items-center gap-2 hover:opacity-80 transition-all duration-300"
  147. >
  148. <div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground shadow-lg shadow-primary/20 group-hover:scale-105 transition-transform">
  149. <ScrollText size={18} />
  150. </div>
  151. <div className="flex flex-col">
  152. <span className="font-serif text-xl leading-none italic font-medium">InkOS</span>
  153. <span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold mt-1">Studio</span>
  154. </div>
  155. </button>
  156. </div>
  157. {/* Main Navigation */}
  158. <div className="flex-1 overflow-y-auto px-4 py-2 space-y-6">
  159. {/* Books Section */}
  160. <div>
  161. <div className="px-3 mb-3 flex items-center justify-between">
  162. <span className="text-[11px] uppercase tracking-widest text-muted-foreground font-bold">
  163. {t("nav.books")}
  164. </span>
  165. <button
  166. onClick={nav.toBookCreate}
  167. className="flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all"
  168. >
  169. <Plus size={12} />
  170. <span>{t("nav.newBook")}</span>
  171. </button>
  172. </div>
  173. <div className="space-y-1">
  174. {books.map((book) => {
  175. const bookSessions = sessionsByBook[book.id] ?? [];
  176. const isActiveBook = activePage === `book:${book.id}`;
  177. const isExpanded = expandedBooks[book.id] ?? isActiveBook;
  178. return (
  179. <div key={book.id} className="space-y-1">
  180. <div
  181. className={`group flex items-center gap-2 rounded-lg px-2 py-1 ${
  182. isActiveBook ? "bg-primary/8" : ""
  183. }`}
  184. >
  185. <button
  186. type="button"
  187. onClick={() => setExpandedBooks((prev) => ({ ...prev, [book.id]: !isExpanded }))}
  188. className="h-7 w-7 shrink-0 rounded-md text-muted-foreground hover:bg-secondary/70 hover:text-foreground transition-colors"
  189. >
  190. <ChevronRight
  191. size={14}
  192. className={`mx-auto transition-transform ${isExpanded ? "rotate-90" : ""}`}
  193. />
  194. </button>
  195. <button
  196. onClick={() => nav.toBook(book.id)}
  197. className={`flex min-w-0 flex-1 items-center gap-3 rounded-lg px-2 py-2 text-sm transition-all duration-200 ${
  198. isActiveBook
  199. ? "text-primary font-medium"
  200. : "text-foreground font-medium hover:text-foreground"
  201. }`}
  202. >
  203. <Book size={16} className={isActiveBook ? "text-primary" : "text-muted-foreground group-hover:text-foreground"} />
  204. <span className="truncate flex-1 text-left">{book.title}</span>
  205. {book.chaptersWritten > 0 && (
  206. <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary transition-colors">
  207. {book.chaptersWritten}
  208. </span>
  209. )}
  210. </button>
  211. </div>
  212. {isExpanded && (
  213. <div className="ml-8 space-y-1 border-l border-border/40 pl-3">
  214. {bookSessions.map((session) => {
  215. const isActiveSession = isActiveBook && activeSessionId === session.sessionId;
  216. const label = getSessionLabel(session.sessionId, session.title);
  217. return (
  218. <div
  219. key={session.sessionId}
  220. className={`flex items-center gap-1 rounded-md px-1 py-0.5 ${
  221. isActiveSession ? "bg-primary/10" : "hover:bg-secondary/50"
  222. }`}
  223. >
  224. <button
  225. type="button"
  226. onClick={() => openSession(book.id, session.sessionId)}
  227. className={`flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${
  228. isActiveSession
  229. ? "text-primary"
  230. : "text-muted-foreground hover:text-foreground"
  231. }`}
  232. >
  233. <span
  234. className={`h-1.5 w-1.5 rounded-full ${
  235. isActiveSession
  236. ? "bg-primary"
  237. : session.isStreaming
  238. ? "bg-emerald-500"
  239. : "bg-border"
  240. }`}
  241. />
  242. <span className="truncate flex-1">{label}</span>
  243. {session.isStreaming ? (
  244. <Loader2 size={12} className="animate-spin text-emerald-500" />
  245. ) : isActiveSession ? (
  246. <span className="text-primary">●</span>
  247. ) : null}
  248. </button>
  249. <DropdownMenu>
  250. <DropdownMenuTrigger className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-secondary/70 hover:text-foreground transition-colors">
  251. <MoreHorizontal size={14} />
  252. </DropdownMenuTrigger>
  253. <DropdownMenuContent side="right" align="start" className="w-36">
  254. <DropdownMenuItem
  255. onClick={() => {
  256. setRenameTarget({ sessionId: session.sessionId, currentTitle: label });
  257. setRenameValue(session.title ?? "");
  258. }}
  259. >
  260. <Pencil size={14} />
  261. <span>改名</span>
  262. </DropdownMenuItem>
  263. <DropdownMenuSeparator />
  264. <DropdownMenuItem
  265. variant="destructive"
  266. onClick={() => setDeleteTarget({ sessionId: session.sessionId, title: label })}
  267. >
  268. <Trash2 size={14} />
  269. <span>删除</span>
  270. </DropdownMenuItem>
  271. </DropdownMenuContent>
  272. </DropdownMenu>
  273. </div>
  274. );
  275. })}
  276. <button
  277. type="button"
  278. onClick={() => void handleCreateSession(book.id)}
  279. className="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs text-muted-foreground/60 hover:text-primary hover:bg-primary/5 transition-all"
  280. >
  281. <Plus size={12} />
  282. <span>新建会话</span>
  283. </button>
  284. </div>
  285. )}
  286. </div>
  287. );
  288. })}
  289. {books.length === 0 && (
  290. <div className="px-3 py-6 text-xs text-muted-foreground/70 italic text-center border border-dashed border-border rounded-lg">
  291. {t("dash.noBooks")}
  292. </div>
  293. )}
  294. </div>
  295. </div>
  296. {/* System Section */}
  297. <div>
  298. <div className="px-3 mb-3">
  299. <span className="text-[11px] uppercase tracking-widest text-muted-foreground font-bold">
  300. {t("nav.system")}
  301. </span>
  302. </div>
  303. <div className="space-y-1">
  304. <SidebarItem
  305. label={t("create.genre")}
  306. icon={<Boxes size={16} />}
  307. active={activePage === "genres"}
  308. onClick={nav.toGenres}
  309. />
  310. <SidebarItem
  311. label={t("nav.config")}
  312. icon={<Settings size={16} />}
  313. active={activePage === "services"}
  314. onClick={nav.toServices}
  315. />
  316. {/* <SidebarItem
  317. label={t("nav.daemon")}
  318. icon={<Zap size={16} />}
  319. active={activePage === "daemon"}
  320. onClick={nav.toDaemon}
  321. badge={daemon?.running ? t("nav.running") : undefined}
  322. badgeColor={daemon?.running ? "bg-emerald-500/10 text-emerald-500" : "bg-muted text-muted-foreground"}
  323. />*/}
  324. <SidebarItem
  325. label={t("nav.logs")}
  326. icon={<Terminal size={16} />}
  327. active={activePage === "logs"}
  328. onClick={nav.toLogs}
  329. />
  330. </div>
  331. </div>
  332. {/* Tools Section */}
  333. <div>
  334. <div className="px-3 mb-3">
  335. <span className="text-[11px] uppercase tracking-widest text-muted-foreground font-bold">
  336. {t("nav.tools")}
  337. </span>
  338. </div>
  339. <div className="space-y-1">
  340. <SidebarItem
  341. label={t("nav.style")}
  342. icon={<Wand2 size={16} />}
  343. active={activePage === "style"}
  344. onClick={nav.toStyle}
  345. />
  346. <SidebarItem
  347. label={t("nav.import")}
  348. icon={<FileInput size={16} />}
  349. active={activePage === "import"}
  350. onClick={nav.toImport}
  351. />
  352. <SidebarItem
  353. label={t("nav.radar")}
  354. icon={<TrendingUp size={16} />}
  355. active={activePage === "radar"}
  356. onClick={nav.toRadar}
  357. />
  358. <SidebarItem
  359. label={t("nav.doctor")}
  360. icon={<Stethoscope size={16} />}
  361. active={activePage === "doctor"}
  362. onClick={nav.toDoctor}
  363. />
  364. </div>
  365. </div>
  366. </div>
  367. {/* Footer / Status Area — only show when agent is online */}
  368. {daemon?.running && (
  369. <div className="p-4 border-t border-border bg-secondary/40">
  370. <div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-card border border-border shadow-sm">
  371. <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
  372. <span className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider">
  373. {t("nav.agentOnline")}
  374. </span>
  375. </div>
  376. </div>
  377. )}
  378. <Dialog
  379. open={renameTarget !== null}
  380. onOpenChange={(open) => {
  381. if (!open) {
  382. setRenameTarget(null);
  383. setRenameValue("");
  384. }
  385. }}
  386. >
  387. <DialogContent showCloseButton={false}>
  388. <DialogHeader>
  389. <DialogTitle>重命名会话</DialogTitle>
  390. <DialogDescription>
  391. 手动标题会覆盖自动生成标题,后续不再被 AI 改写。
  392. </DialogDescription>
  393. </DialogHeader>
  394. <div className="space-y-2">
  395. <label className="text-xs font-medium text-muted-foreground" htmlFor="session-rename-input">
  396. 会话标题
  397. </label>
  398. <input
  399. id="session-rename-input"
  400. autoFocus
  401. value={renameValue}
  402. onChange={(event) => setRenameValue(event.target.value)}
  403. onKeyDown={(event) => {
  404. if (event.key === "Enter") {
  405. event.preventDefault();
  406. void handleRenameConfirm();
  407. }
  408. }}
  409. placeholder="输入新标题"
  410. className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
  411. />
  412. </div>
  413. <DialogFooter>
  414. <button
  415. type="button"
  416. onClick={() => {
  417. setRenameTarget(null);
  418. setRenameValue("");
  419. }}
  420. className="px-4 py-2 text-sm font-medium rounded-xl bg-secondary text-foreground hover:bg-secondary/80 transition-all border border-border/50"
  421. >
  422. 取消
  423. </button>
  424. <button
  425. type="button"
  426. onClick={() => void handleRenameConfirm()}
  427. disabled={!renameValue.trim()}
  428. className="px-4 py-2 text-sm font-bold rounded-xl bg-primary text-primary-foreground transition-all disabled:opacity-40"
  429. >
  430. 保存
  431. </button>
  432. </DialogFooter>
  433. </DialogContent>
  434. </Dialog>
  435. <ConfirmDialog
  436. open={deleteTarget !== null}
  437. title="删除会话"
  438. message={`确认删除“${deleteTarget?.title ?? ""}”吗?该操作只删除这条会话,不影响书籍内容。`}
  439. confirmLabel="删除"
  440. cancelLabel="取消"
  441. variant="danger"
  442. onConfirm={() => void handleDeleteConfirm()}
  443. onCancel={() => setDeleteTarget(null)}
  444. />
  445. </aside>
  446. );
  447. }
  448. function getSessionLabel(sessionId: string, title: string | null): string {
  449. if (title) return title;
  450. const rawTs = Number(sessionId.split("-")[0]);
  451. if (!Number.isFinite(rawTs)) return "新会话";
  452. const formatted = new Date(rawTs).toLocaleTimeString("zh-CN", {
  453. hour: "2-digit",
  454. minute: "2-digit",
  455. });
  456. return `新会话 · ${formatted}`;
  457. }
  458. function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: {
  459. label: string;
  460. icon: React.ReactNode;
  461. active: boolean;
  462. onClick: () => void;
  463. badge?: string;
  464. badgeColor?: string;
  465. }) {
  466. return (
  467. <button
  468. onClick={onClick}
  469. className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
  470. active
  471. ? "bg-secondary text-foreground font-medium shadow-sm border border-border"
  472. : "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
  473. }`}
  474. >
  475. <span className={`transition-colors ${active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`}>
  476. {icon}
  477. </span>
  478. <span className="flex-1 text-left">{label}</span>
  479. {badge && (
  480. <span className={`text-[9px] px-1.5 py-0.5 rounded-full font-bold uppercase tracking-tight ${badgeColor}`}>
  481. {badge}
  482. </span>
  483. )}
  484. </button>
  485. );
  486. }