返回 presentation-ai
DocumentTools.tsx
根目录 / src / components / ai / generative-ui / DocumentTools.tsx
1 "use client";
2
3 import {
4 BookOpen,
5 ChevronsUpDownIcon,
6 FileSearch,
7 Loader2,
8 } from "lucide-react";
9
10 import { Badge } from "@/components/ui/badge";
11 import { Button } from "@/components/ui/button";
12 import {
13 Collapsible,
14 CollapsibleContent,
15 CollapsibleTrigger,
16 } from "@/components/ui/collapsible";
17 import { ScrollArea } from "@/components/ui/scroll-area";
18
19 type DocumentSearchResult = {
20 chunkIds?: string[];
21 content?: string;
22 fileName?: string;
23 page?: number | null;
24 };
25
26 type DocumentLoadedPage = {
27 chunkIds?: string[];
28 content?: string;
29 page?: number;
30 };
31
32 const EMPTY_DOCUMENT_SEARCH_RESULTS: DocumentSearchResult[] = [];
33 const EMPTY_DOCUMENT_PAGES: DocumentLoadedPage[] = [];
34
35 function stripChunkTags(content: string): string {
36 return content
37 .replace(/<c\b[^>]*>/gi, "")
38 .replace(/<\/c>/gi, "")
39 .replace(/!\[([^\]]*)\]\([^)]+\)/g, (_match, altText: string) =>
40 altText.trim().length > 0 ? altText : "Image",
41 )
42 .replace(/&amp;/g, "&")
43 .trim();
44 }
45
46 function getDocumentSearchResultTitle(
47 result: DocumentSearchResult,
48 index: number,
49 ): string {
50 const titleParts = [
51 result.fileName?.trim() || `Result ${index + 1}`,
52 typeof result.page === "number" ? `Page ${result.page}` : null,
53 ].filter((part): part is string => typeof part === "string");
54
55 return titleParts.join(" - ");
56 }
57
58 function getPageRangeLabel({
59 endPage,
60 page,
61 pageCount,
62 startPage,
63 }: {
64 endPage?: number;
65 page?: number;
66 pageCount?: number | null;
67 startPage?: number;
68 }): string | null {
69 const resolvedStartPage =
70 typeof page === "number"
71 ? page
72 : typeof startPage === "number"
73 ? startPage
74 : null;
75 const resolvedEndPage =
76 typeof page === "number"
77 ? page
78 : typeof endPage === "number"
79 ? endPage
80 : resolvedStartPage;
81
82 if (
83 typeof resolvedStartPage !== "number" ||
84 typeof resolvedEndPage !== "number"
85 ) {
86 return null;
87 }
88
89 const rangeLabel =
90 resolvedStartPage === resolvedEndPage
91 ? `Page ${resolvedStartPage}`
92 : `Pages ${resolvedStartPage}-${resolvedEndPage}`;
93
94 if (
95 typeof pageCount === "number" &&
96 pageCount > 0 &&
97 resolvedStartPage === resolvedEndPage
98 ) {
99 return `${rangeLabel} of ${pageCount}`;
100 }
101
102 return rangeLabel;
103 }
104
105 export function DocumentSearchLoading({ query }: { query?: string }) {
106 return (
107 <div
108 className="mb-2 w-full rounded-lg border border-primary/20 bg-background"
109 aria-label={
110 query ? `Searching attached documents for ${query}` : undefined
111 }
112 >
113 <div className="flex h-12 items-center gap-3 px-4 text-muted-foreground">
114 <Loader2 className="size-5 animate-spin" />
115 <div className="min-w-0 flex-1">
116 <p className="truncate text-sm font-medium">
117 Searching attached documents...
118 </p>
119 </div>
120 </div>
121 </div>
122 );
123 }
124
125 export function DocumentSearchResultCard({
126 message,
127 query,
128 results = EMPTY_DOCUMENT_SEARCH_RESULTS,
129 }: {
130 message?: string;
131 query?: string;
132 results?: DocumentSearchResult[];
133 }) {
134 return (
135 <Collapsible
136 className="mb-2 w-full rounded-lg border border-primary/20 bg-background"
137 aria-label={query ? `Document searched for ${query}` : undefined}
138 >
139 <CollapsibleTrigger asChild>
140 <Button variant="ghost" className="h-12 w-full justify-between px-4">
141 <div className="flex w-[90%] min-w-0 items-center gap-3">
142 <FileSearch className="size-5 shrink-0" />
143 <div className="flex min-w-0 flex-col items-start overflow-hidden">
144 <span className="w-full truncate text-sm font-medium">
145 Document searched
146 </span>
147 <span className="text-xs text-muted-foreground">
148 {results.length} result{results.length === 1 ? "" : "s"} found
149 </span>
150 </div>
151 </div>
152 <ChevronsUpDownIcon className="size-5 shrink-0 transition-transform duration-300 data-[state=open]:rotate-180" />
153 </Button>
154 </CollapsibleTrigger>
155 <CollapsibleContent className="grid max-w-full grid-cols-1 gap-y-2 p-4">
156 {message ? (
157 <p className="text-xs text-muted-foreground">{message}</p>
158 ) : null}
159 {results.map((result, index) => {
160 const content = stripChunkTags(result.content ?? "");
161
162 return (
163 <div
164 key={`${result.fileName ?? "document"}-${result.page ?? "na"}-${index}`}
165 className="flex max-w-full items-start gap-3 rounded-lg border border-primary/20 p-3"
166 >
167 <FileSearch className="mt-1 size-4 shrink-0 text-muted-foreground" />
168 <div className="min-w-0 flex-1 overflow-hidden">
169 <h4 className="truncate text-sm font-medium">
170 {getDocumentSearchResultTitle(result, index)}
171 </h4>
172 <p className="line-clamp-2 text-xs text-muted-foreground">
173 {content || "No excerpt was returned."}
174 </p>
175 </div>
176 </div>
177 );
178 })}
179 </CollapsibleContent>
180 </Collapsible>
181 );
182 }
183
184 export function DocumentPageLoading({
185 endPage,
186 page,
187 startPage,
188 }: {
189 endPage?: number;
190 page?: number;
191 startPage?: number;
192 }) {
193 const pageLabel =
194 getPageRangeLabel({ endPage, page, startPage }) ?? "document page";
195
196 return (
197 <div className="mb-2 w-full rounded-lg border border-primary/20 bg-background">
198 <div className="flex h-12 items-center gap-3 px-4 text-muted-foreground">
199 <Loader2 className="size-5 animate-spin" />
200 <div className="min-w-0 flex-1">
201 <p className="truncate text-sm font-medium">
202 Loading {pageLabel.toLowerCase()}
203 </p>
204 </div>
205 </div>
206 </div>
207 );
208 }
209
210 export function DocumentPageResultCard({
211 content,
212 error,
213 endPage,
214 fileName,
215 message,
216 page,
217 pages = EMPTY_DOCUMENT_PAGES,
218 pageCount,
219 startPage,
220 }: {
221 content?: string;
222 error?: boolean;
223 endPage?: number;
224 fileName?: string;
225 message?: string;
226 page?: number;
227 pages?: DocumentLoadedPage[];
228 pageCount?: number | null;
229 startPage?: number;
230 }) {
231 const pageLabel = getPageRangeLabel({
232 endPage,
233 page,
234 pageCount,
235 startPage,
236 });
237 const resolvedPages = pages.filter(
238 (
239 loadedPage,
240 ): loadedPage is Required<Pick<DocumentLoadedPage, "content" | "page">> &
241 DocumentLoadedPage =>
242 typeof loadedPage.page === "number" &&
243 typeof loadedPage.content === "string" &&
244 loadedPage.content.trim().length > 0,
245 );
246
247 return (
248 <div className="mb-3 w-full rounded-lg border border-primary/20 bg-background p-4">
249 <div className="flex items-start gap-3">
250 <div className="mt-0.5 rounded-md bg-primary/10 p-2 text-primary">
251 <BookOpen className="size-4" />
252 </div>
253 <div className="min-w-0 flex-1">
254 <div className="flex flex-wrap items-center gap-2">
255 <p className="text-sm font-medium text-foreground">
256 {fileName ?? "Document page"}
257 </p>
258 {pageLabel ? <Badge variant="secondary">{pageLabel}</Badge> : null}
259 </div>
260 {message ? (
261 <p
262 className={`mt-1 text-xs ${
263 error ? "text-destructive" : "text-muted-foreground"
264 }`}
265 >
266 {message}
267 </p>
268 ) : null}
269 {resolvedPages.length > 1 ? (
270 <ScrollArea className="mt-3 max-h-80 rounded-lg border border-border/70 bg-muted/20 p-3">
271 <div className="space-y-4">
272 {resolvedPages.map((loadedPage) => (
273 <section
274 key={loadedPage.page}
275 className="rounded-lg border border-border/60 bg-background/70 p-3"
276 >
277 <div className="mb-3 flex flex-wrap items-center gap-2">
278 <Badge variant="outline">Page {loadedPage.page}</Badge>
279 {Array.isArray(loadedPage.chunkIds) &&
280 loadedPage.chunkIds.length > 0 ? (
281 <Badge variant="outline">
282 {loadedPage.chunkIds.join(", ")}
283 </Badge>
284 ) : null}
285 </div>
286 <pre className="whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
287 {loadedPage.content}
288 </pre>
289 </section>
290 ))}
291 </div>
292 </ScrollArea>
293 ) : content?.trim() ? (
294 <ScrollArea className="mt-3 max-h-80 rounded-lg border border-border/70 bg-muted/20 p-3">
295 <pre className="whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
296 {content}
297 </pre>
298 </ScrollArea>
299 ) : null}
300 {!content?.trim() && !message ? (
301 <p className="mt-3 text-xs text-muted-foreground">
302 No page content was returned.
303 </p>
304 ) : null}
305 </div>
306 </div>
307 </div>
308 );
309 }
310
310 lines Plain Text