返回 presentation-ai
NotebookAgentActivity.tsx
根目录 / src / components / notebook / shared / NotebookAgentActivity.tsx
1 "use client";
2
3 import {
4 CheckCircle2,
5 CircleAlert,
6 FileText,
7 Loader2,
8 Search,
9 } from "lucide-react";
10 import { Fragment, useMemo, useState, type ReactNode } from "react";
11
12 import {
13 DocumentPageLoading,
14 DocumentPageResultCard,
15 DocumentSearchLoading,
16 DocumentSearchResultCard,
17 } from "@/components/ai/generative-ui/DocumentTools";
18 import { PresentationImageSearchActivityCard } from "@/components/ai/generative-ui/PresentationImageSearch";
19 import {
20 Searched,
21 Searching,
22 type SearchResult,
23 } from "@/components/ai/generative-ui/Searched";
24 import {
25 Collapsible,
26 CollapsibleContent,
27 CollapsibleTrigger,
28 } from "@/components/ui/collapsible";
29 import {
30 isPresentationImageSearchToolName,
31 isWebSearchToolName,
32 } from "@/lib/ai/tool-names";
33 import {
34 isNotebookAgentActivityEvent,
35 NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES,
36 parseNotebookAgentToolResult,
37 type NotebookAgentSelectedChunk,
38 type NotebookAgentToolCall,
39 } from "@/lib/notebook/agent-activity";
40 import {
41 parsePresentationImageSearchPayload,
42 type PresentationImageSearchResult,
43 } from "@/lib/presentation/image-search";
44
45 const ACTIVITY_LABELS: Record<string, string> = {
46 [NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES.USER_INPUT]: "User Input",
47 [NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES.ASSISTANT_OUTPUT]: "Assistant Output",
48 [NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES.STEP_START]: "Agent Step",
49 [NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES.SOURCE]: "Source",
50 [NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES.REASONING]: "Reasoning",
51 [NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES.DOCUMENT_CONTEXT]: "File Processing",
52 loadDocumentPage: "Document Page",
53 searchDocuments: "Document Search",
54 search_presentation_images: "Image Search",
55 };
56
57 const EMPTY_SELECTED_CHUNKS: NotebookAgentSelectedChunk[] = [];
58
59 function getActivityLabel(toolName: string): string {
60 return (
61 ACTIVITY_LABELS[toolName] ??
62 toolName
63 .replace(/^__agent\./, "")
64 .replace(/[_-]+/g, " ")
65 .replace(/\b\w/g, (char) => char.toUpperCase())
66 );
67 }
68
69 function isTextPreviewResult(
70 value: unknown,
71 ): value is { text: string; truncated?: boolean } {
72 return (
73 typeof value === "object" &&
74 value !== null &&
75 !Array.isArray(value) &&
76 "text" in value &&
77 typeof value.text === "string"
78 );
79 }
80
81 function getString(value: unknown): string | null {
82 if (typeof value !== "string") {
83 return null;
84 }
85
86 const trimmed = value.trim();
87 return trimmed.length > 0 ? trimmed : null;
88 }
89
90 function getNumber(value: unknown): number | null {
91 return typeof value === "number" && Number.isFinite(value) ? value : null;
92 }
93
94 function isRecord(value: unknown): value is Record<string, unknown> {
95 return typeof value === "object" && value !== null && !Array.isArray(value);
96 }
97
98 function isOpaqueFileIdentifier(
99 fileName: string,
100 ragId: string | null,
101 ): boolean {
102 if (ragId && fileName === ragId) {
103 return true;
104 }
105
106 if (fileName.includes(".")) {
107 return false;
108 }
109
110 return fileName.length >= 20;
111 }
112
113 function getFileProcessingDisplay(status: string | null): {
114 icon: ReactNode;
115 label: string;
116 toneClassName: string;
117 } {
118 switch (status) {
119 case "COMPLETE":
120 return {
121 icon: <CheckCircle2 className="size-3.5" />,
122 label: "Processed",
123 toneClassName: "border-primary/25 bg-primary/10 text-primary",
124 };
125 case "ERROR":
126 return {
127 icon: <CircleAlert className="size-3.5" />,
128 label: "Processing failed",
129 toneClassName:
130 "border-destructive/20 bg-destructive/10 text-destructive",
131 };
132 default:
133 return {
134 icon: <Loader2 className="size-3.5 animate-spin" />,
135 label: "Processing",
136 toneClassName: "border-blue-500/20 bg-blue-500/10 text-blue-500",
137 };
138 }
139 }
140
141 function renderActivityResult(
142 toolName: string,
143 rawResult: unknown,
144 onOpenDocumentContext?: (context: {
145 fileAssetId?: string;
146 fileUrl?: string;
147 ragId?: string;
148 }) => void,
149 ): ReactNode | null {
150 if (!isNotebookAgentActivityEvent(toolName)) {
151 return null;
152 }
153
154 if (toolName === NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES.DOCUMENT_CONTEXT) {
155 if (!isRecord(rawResult)) {
156 return null;
157 }
158
159 const rawFileName = getString(rawResult.fileName) ?? "Document";
160 const fileAssetId = getString(rawResult.fileAssetId);
161 const fileUrl = getString(rawResult.fileUrl);
162 const ragId = getString(rawResult.ragId);
163 const processingStatus = getString(rawResult.processingStatus);
164 const statusDisplay = getFileProcessingDisplay(processingStatus);
165 let displayFileName = rawFileName;
166
167 if (fileUrl && isOpaqueFileIdentifier(rawFileName, ragId)) {
168 try {
169 const urlObj = new URL(fileUrl);
170 const pathParts = urlObj.pathname.split("/");
171 const lastPart = pathParts[pathParts.length - 1];
172 if (lastPart && !isOpaqueFileIdentifier(lastPart, ragId)) {
173 displayFileName = decodeURIComponent(lastPart);
174 }
175 } catch {
176 // Ignore invalid URLs.
177 }
178 }
179
180 if (isOpaqueFileIdentifier(displayFileName, ragId)) {
181 displayFileName = "Document";
182 }
183
184 const rowContent = (
185 <>
186 <div className="flex min-w-0 items-center gap-2">
187 <FileText className="size-4 shrink-0 text-primary/70" />
188 <div className="min-w-0">
189 <p className="truncate text-[13px] font-medium text-foreground/90">
190 {displayFileName}
191 </p>
192 </div>
193 </div>
194 <div className="flex shrink-0 items-center gap-2">
195 <span
196 className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[11px] font-medium ${statusDisplay.toneClassName}`}
197 >
198 {statusDisplay.icon}
199 {statusDisplay.label}
200 </span>
201 {onOpenDocumentContext || fileUrl ? (
202 <span className="text-[12px] font-medium text-primary">Open</span>
203 ) : null}
204 </div>
205 </>
206 );
207
208 if (onOpenDocumentContext) {
209 return (
210 <button
211 type="button"
212 onClick={() =>
213 onOpenDocumentContext({
214 fileAssetId: fileAssetId ?? undefined,
215 fileUrl: fileUrl ?? undefined,
216 ragId: ragId ?? undefined,
217 })
218 }
219 className="flex w-full items-center justify-between gap-3 rounded-lg border border-primary/20 bg-background p-3 text-left text-xs text-muted-foreground transition-colors hover:bg-muted/40"
220 >
221 {rowContent}
222 </button>
223 );
224 }
225
226 if (fileUrl) {
227 return (
228 <a
229 href={fileUrl}
230 target="_blank"
231 rel="noreferrer"
232 className="flex w-full items-center justify-between gap-3 rounded-lg border border-primary/20 bg-background p-3 text-left text-xs text-muted-foreground transition-colors hover:bg-muted/40"
233 >
234 {rowContent}
235 </a>
236 );
237 }
238
239 return (
240 <div className="flex w-full items-center justify-between gap-3 rounded-lg border border-primary/20 bg-background p-3 text-xs text-muted-foreground">
241 {rowContent}
242 </div>
243 );
244 }
245
246 if (toolName === NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES.STEP_START) {
247 if (!isRecord(rawResult)) {
248 return null;
249 }
250
251 const step = getNumber(rawResult.step);
252 return step === null ? null : <p className="mt-2 text-sm">Started step {step}.</p>;
253 }
254
255 if (toolName === NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES.SOURCE) {
256 if (!isRecord(rawResult)) {
257 return null;
258 }
259
260 const sourceType =
261 getString(rawResult.sourceType) ?? getString(rawResult.type);
262 const title = getString(rawResult.title);
263 const url = getString(rawResult.url);
264
265 return (
266 <div className="mt-2 space-y-1 text-sm">
267 {title ? <p className="font-medium text-foreground">{title}</p> : null}
268 {sourceType ? (
269 <p className="text-muted-foreground">Type: {sourceType}</p>
270 ) : null}
271 {url ? (
272 <a
273 href={url}
274 target="_blank"
275 rel="noreferrer"
276 className="block break-all text-blue-500 underline underline-offset-2"
277 >
278 Open source
279 </a>
280 ) : null}
281 </div>
282 );
283 }
284
285 return null;
286 }
287
288 function getQueryFromCall(call: NotebookAgentToolCall): string {
289 if (typeof call.args?.query === "string") {
290 return call.args.query;
291 }
292
293 const parsed = parseNotebookAgentToolResult(call.result) as
294 | { query?: string }
295 | undefined;
296 if (typeof parsed?.query === "string") {
297 return parsed.query;
298 }
299
300 return "Search";
301 }
302
303 function getResultsFromCall(call: NotebookAgentToolCall): unknown[] {
304 const parsed = parseNotebookAgentToolResult(call.result) as
305 | { results?: unknown[] }
306 | undefined;
307
308 return Array.isArray(parsed?.results) ? parsed.results : [];
309 }
310
311 function getImageSearchQuery(call: NotebookAgentToolCall): string {
312 if (
313 typeof call.args?.query === "string" &&
314 call.args.query.trim().length > 0
315 ) {
316 return call.args.query.trim();
317 }
318
319 const parsed = parsePresentationImageSearchPayload(call.result);
320 return parsed?.query ?? "Image search";
321 }
322
323 function getSelectedChunkLines(
324 selectedChunks: NotebookAgentSelectedChunk[],
325 ): string[] {
326 return selectedChunks
327 .map((chunk) => chunk.content?.trim())
328 .filter(
329 (content): content is string =>
330 typeof content === "string" &&
331 content.length > 0 &&
332 !/^!\[.*\]\(.*\)$/.test(content),
333 );
334 }
335
336 function getSelectedChunkPreviewItems(
337 selectedChunks: NotebookAgentSelectedChunk[],
338 ): Array<{ id: string; text: string }> {
339 const seenCounts = new Map<string, number>();
340
341 return getSelectedChunkLines(selectedChunks)
342 .slice(0, 4)
343 .map((text) => {
344 const duplicateCount = seenCounts.get(text) ?? 0;
345 seenCounts.set(text, duplicateCount + 1);
346
347 return {
348 id: `${text}:${duplicateCount}`,
349 text,
350 };
351 });
352 }
353
354 function SelectedChunkSummary({
355 selectedChunks,
356 }: {
357 selectedChunks: NotebookAgentSelectedChunk[];
358 }) {
359 const previewItems = getSelectedChunkPreviewItems(selectedChunks);
360 if (previewItems.length === 0) {
361 return null;
362 }
363
364 return (
365 <div className="rounded-lg border border-primary/20 bg-background p-3">
366 <p className="text-[13px] font-medium text-foreground/90">
367 Selected document emphasis
368 </p>
369 <p className="mt-1.5 text-[12px] text-muted-foreground">
370 The agent should prioritize these selected excerpts.
371 </p>
372 <div className="mt-2 space-y-2">
373 {previewItems.map((item) => (
374 <p
375 key={item.id}
376 className="rounded-md bg-muted/40 px-2.5 py-2 text-[12px] leading-relaxed text-muted-foreground"
377 >
378 {item.text}
379 </p>
380 ))}
381 </div>
382 </div>
383 );
384 }
385
386 export function NotebookAgentActivityList({
387 toolCalls,
388 selectedChunks = EMPTY_SELECTED_CHUNKS,
389 showEmptyState = false,
390 emptyLabel = "No activity yet.",
391 onOpenDocumentContext,
392 }: {
393 toolCalls: NotebookAgentToolCall[];
394 selectedChunks?: NotebookAgentSelectedChunk[];
395 showEmptyState?: boolean;
396 emptyLabel?: string;
397 onOpenDocumentContext?: (context: {
398 fileAssetId?: string;
399 fileUrl?: string;
400 ragId?: string;
401 }) => void;
402 }) {
403 const { pendingCalls, resultCalls, imageSearchResults, pendingImageQueries } =
404 useMemo(() => {
405 const pending: NotebookAgentToolCall[] = [];
406 const done: NotebookAgentToolCall[] = [];
407 const mergedImageSearchResults: PresentationImageSearchResult[] = [];
408 const mergedPendingImageQueries: string[] = [];
409
410 for (const call of toolCalls) {
411 if (isPresentationImageSearchToolName(call.toolName)) {
412 if (call.state === "result") {
413 const parsed = parsePresentationImageSearchPayload(call.result);
414
415 if (parsed) {
416 mergedImageSearchResults.push(parsed);
417 }
418 } else {
419 mergedPendingImageQueries.push(getImageSearchQuery(call));
420 }
421
422 continue;
423 }
424
425 if (call.state === "result") {
426 done.push(call);
427 } else {
428 pending.push(call);
429 }
430 }
431
432 return {
433 pendingCalls: pending,
434 resultCalls: done,
435 imageSearchResults: mergedImageSearchResults,
436 pendingImageQueries: mergedPendingImageQueries,
437 };
438 }, [toolCalls]);
439 const hasSelectedChunkContext =
440 getSelectedChunkLines(selectedChunks).length > 0;
441 const hasImageSearchActivity =
442 imageSearchResults.length > 0 || pendingImageQueries.length > 0;
443
444 if (toolCalls.length === 0 && !hasSelectedChunkContext) {
445 if (!showEmptyState) {
446 return null;
447 }
448
449 return (
450 <div className="rounded-lg border border-primary/20 bg-background p-3 text-xs text-muted-foreground">
451 {emptyLabel}
452 </div>
453 );
454 }
455
456 return (
457 <div className="space-y-2">
458 {hasSelectedChunkContext ? (
459 <SelectedChunkSummary selectedChunks={selectedChunks} />
460 ) : null}
461
462 {hasImageSearchActivity ? (
463 <PresentationImageSearchActivityCard
464 searches={imageSearchResults}
465 pendingQueries={pendingImageQueries}
466 />
467 ) : null}
468
469 {pendingCalls.map((call) => {
470 const query = getQueryFromCall(call);
471
472 if (isWebSearchToolName(call.toolName)) {
473 return <Searching key={call.id} query={query} />;
474 }
475
476 if (call.toolName === "searchDocuments") {
477 return <DocumentSearchLoading key={call.id} query={query} />;
478 }
479
480 if (call.toolName === "loadDocumentPage") {
481 return (
482 <DocumentPageLoading
483 key={call.id}
484 page={
485 typeof call.args?.page === "number" ? call.args.page : undefined
486 }
487 startPage={
488 typeof call.args?.startPage === "number"
489 ? call.args.startPage
490 : undefined
491 }
492 endPage={
493 typeof call.args?.endPage === "number"
494 ? call.args.endPage
495 : undefined
496 }
497 />
498 );
499 }
500
501 return (
502 <div
503 key={call.id}
504 className="rounded-lg border border-primary/20 bg-background p-3 text-xs text-muted-foreground"
505 >
506 <p className="text-[13px] font-medium text-foreground/90">
507 {getActivityLabel(call.toolName)}
508 </p>
509 <p className="mt-1.5 text-[12px] text-muted-foreground">
510 Processing...
511 </p>
512 </div>
513 );
514 })}
515
516 {resultCalls.map((call) => {
517 const query = getQueryFromCall(call);
518
519 if (isWebSearchToolName(call.toolName)) {
520 const formattedResults: SearchResult[] = getResultsFromCall(call).map(
521 (result: unknown) => {
522 const searchResult = result as Record<string, unknown>;
523 return {
524 url: (searchResult.url as string) || "",
525 title: (searchResult.title as string) || "No title",
526 published_date: "",
527 content: (searchResult.content as string) || "No content",
528 };
529 },
530 );
531
532 return (
533 <Searched key={call.id} query={query} results={formattedResults} />
534 );
535 }
536
537 if (call.toolName === "searchDocuments") {
538 return (
539 <DocumentSearchResultCard
540 key={call.id}
541 query={query}
542 results={
543 getResultsFromCall(call) as Array<Record<string, unknown>>
544 }
545 message={(() => {
546 const parsed = parseNotebookAgentToolResult(call.result) as
547 | { message?: string }
548 | undefined;
549 return typeof parsed?.message === "string"
550 ? parsed.message
551 : undefined;
552 })()}
553 />
554 );
555 }
556
557 if (call.toolName === "loadDocumentPage") {
558 const parsed = parseNotebookAgentToolResult(call.result) as
559 | {
560 content?: string;
561 endPage?: number;
562 error?: boolean;
563 fileName?: string;
564 message?: string;
565 page?: number;
566 pages?: Array<{
567 chunkIds?: string[];
568 content?: string;
569 page?: number;
570 }>;
571 pageCount?: number | null;
572 startPage?: number;
573 }
574 | undefined;
575
576 return (
577 <DocumentPageResultCard
578 key={call.id}
579 content={
580 typeof parsed?.content === "string" ? parsed.content : undefined
581 }
582 endPage={
583 typeof parsed?.endPage === "number" ? parsed.endPage : undefined
584 }
585 error={parsed?.error === true}
586 fileName={
587 typeof parsed?.fileName === "string"
588 ? parsed.fileName
589 : undefined
590 }
591 message={
592 typeof parsed?.message === "string" ? parsed.message : undefined
593 }
594 page={typeof parsed?.page === "number" ? parsed.page : undefined}
595 pages={Array.isArray(parsed?.pages) ? parsed.pages : undefined}
596 pageCount={
597 typeof parsed?.pageCount === "number" ? parsed.pageCount : null
598 }
599 startPage={
600 typeof parsed?.startPage === "number"
601 ? parsed.startPage
602 : undefined
603 }
604 />
605 );
606 }
607
608 const rawResult = parseNotebookAgentToolResult(call.result);
609 const customActivityResult = renderActivityResult(
610 call.toolName,
611 rawResult,
612 onOpenDocumentContext,
613 );
614 const previewText = isTextPreviewResult(rawResult)
615 ? rawResult.text
616 : null;
617 const showPlainText =
618 previewText !== null &&
619 isNotebookAgentActivityEvent(call.toolName) &&
620 previewText.trim().length > 0;
621
622 if (
623 call.toolName ===
624 NOTEBOOK_AGENT_ACTIVITY_TOOL_NAMES.DOCUMENT_CONTEXT &&
625 customActivityResult
626 ) {
627 return <Fragment key={call.id}>{customActivityResult}</Fragment>;
628 }
629
630 return (
631 <div
632 key={call.id}
633 className="rounded-lg border border-primary/20 bg-background p-3 text-xs text-muted-foreground"
634 >
635 <p className="text-[13px] font-medium text-foreground/90">
636 {getActivityLabel(call.toolName)}
637 </p>
638 {customActivityResult ? (
639 customActivityResult
640 ) : showPlainText ? (
641 <p className="mt-1.5 max-h-40 overflow-auto text-[12px] leading-relaxed whitespace-pre-wrap text-muted-foreground">
642 {previewText}
643 </p>
644 ) : null}
645 </div>
646 );
647 })}
648 </div>
649 );
650 }
651
652 export function NotebookAgentActivityInline({
653 toolCalls,
654 isRunning = false,
655 selectedChunks = EMPTY_SELECTED_CHUNKS,
656 defaultExpanded = false,
657 onOpenDocumentContext,
658 }: {
659 toolCalls: NotebookAgentToolCall[];
660 isRunning?: boolean;
661 selectedChunks?: NotebookAgentSelectedChunk[];
662 defaultExpanded?: boolean;
663 onOpenDocumentContext?: (context: {
664 fileAssetId?: string;
665 fileUrl?: string;
666 ragId?: string;
667 }) => void;
668 }) {
669 const [isExpanded, setIsExpanded] = useState(defaultExpanded);
670 const hasSelectedChunkContext =
671 getSelectedChunkLines(selectedChunks).length > 0;
672 const hasDisplayContent = toolCalls.length > 0 || hasSelectedChunkContext;
673
674 if (!hasDisplayContent && !isRunning) {
675 return null;
676 }
677
678 return (
679 <div className="space-y-2">
680 <Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
681 <CollapsibleTrigger asChild>
682 <button
683 type="button"
684 className="flex w-full items-center justify-between rounded-lg border bg-muted/30 p-3 text-left transition-colors hover:bg-muted/50"
685 >
686 <div className="flex min-w-0 items-center gap-2">
687 <Search className="size-4 shrink-0 text-blue-500" />
688 <span className="truncate text-sm font-medium">
689 Agent Activity
690 </span>
691 </div>
692 <div className="flex shrink-0 items-center gap-2">
693 {isRunning ? (
694 <span className="flex size-4 shrink-0 items-center justify-center">
695 <Loader2 className="size-4 animate-spin text-blue-500" />
696 </span>
697 ) : null}
698 <span className="text-xs text-muted-foreground">
699 {isExpanded ? "Hide" : "Show"}
700 </span>
701 </div>
702 </button>
703 </CollapsibleTrigger>
704
705 <CollapsibleContent className="space-y-2 px-4 pt-2">
706 <NotebookAgentActivityList
707 toolCalls={toolCalls}
708 selectedChunks={selectedChunks}
709 showEmptyState
710 onOpenDocumentContext={onOpenDocumentContext}
711 />
712 </CollapsibleContent>
713 </Collapsible>
714 </div>
715 );
716 }
717
717 lines Plain Text