返回 presentation-ai
AIMessage.tsx
根目录 / src / components / presentation / agent / AIMessage.tsx
1 import { cn } from "@/lib/utils";
2 import {
3 getMessageText,
4 getToolInputArgs,
5 getToolName,
6 getToolOutput,
7 isToolPart,
8 } from "@/lib/ai/uiMessageParts";
9 import { type UIMessage } from "ai";
10 import { Loader2, Search } from "lucide-react";
11 import { type ComponentType } from "react";
12 import {
13 PresentationChangeThemeCall,
14 PresentationChangeThemeResult,
15 } from "./tools/ChangeTheme";
16 import { PresentationDeleteSlideCall } from "./tools/DeleteSlide";
17 import {
18 PresentationEditSlidePropertiesCall,
19 PresentationEditSlidePropertiesResult,
20 } from "./tools/EditSlideProperties";
21 import EditingSlide from "./tools/EditingSlide";
22 import {
23 PresentationRegenerateSlideResult,
24 } from "./tools/RegenerateSlide";
25 import {
26 PresentationReplaceImageCall,
27 PresentationReplaceImageResult,
28 } from "./tools/ReplaceImage";
29
30 const hiddenTools = [
31 "regenerate_slide",
32 "delete_slide",
33 "create_slide",
34 "webSearch",
35 "respond_to_user",
36 ];
37
38 const ToolCallComponentMap = {
39 edit_slide_properties: PresentationEditSlidePropertiesCall,
40 replace_image: PresentationReplaceImageCall,
41 change_theme: PresentationChangeThemeCall,
42 regenerate_slide: EditingSlide,
43 create_slide: EditingSlide,
44 delete_slide: PresentationDeleteSlideCall,
45 } as const;
46
47 const ToolResultComponentMap = {
48 edit_slide_properties: PresentationEditSlidePropertiesResult,
49 replace_image: PresentationReplaceImageResult,
50 change_theme: PresentationChangeThemeResult,
51 regenerate_slide: PresentationRegenerateSlideResult,
52 } as const;
53
54 function SearchCall() {
55 return (
56 <div className="w-full rounded-lg border bg-card p-3 shadow-2xs">
57 <div className="flex items-center gap-2 text-sm text-muted-foreground">
58 <Loader2 className="h-4 w-4 animate-spin" />
59 Searching the web...
60 </div>
61 </div>
62 );
63 }
64
65 function SearchResult({ message }: { message?: string }) {
66 return (
67 <div className="rounded-lg border bg-card px-3 py-2 shadow-2xs">
68 <div className="flex items-center gap-2 text-sm">
69 <Search className="h-4 w-4 text-muted-foreground" />
70 <span className="line-clamp-2 text-muted-foreground">
71 {message ?? "Web search completed."}
72 </span>
73 </div>
74 </div>
75 );
76 }
77
78 function PresentationAIToolMessage({
79 message,
80 isStreaming,
81 }: {
82 message: UIMessage;
83 isStreaming: boolean;
84 }) {
85 const toolParts = message.parts.filter(isToolPart);
86
87 return toolParts.map((part, index) => {
88 const name = getToolName(part);
89 const key = part.toolCallId ?? `${name}-${index}`;
90
91 if (part.state === "output-available") {
92 const output = getToolOutput(part);
93
94 if (name === "webSearch") {
95 return (
96 <SearchResult
97 key={key}
98 message={typeof output === "string" ? output : undefined}
99 />
100 );
101 }
102
103 const ResultComponent = ToolResultComponentMap[
104 name as keyof typeof ToolResultComponentMap
105 ] as ComponentType<Record<string, unknown>> | undefined;
106
107 if (!ResultComponent) {
108 return null;
109 }
110
111 if (typeof output === "object" && output !== null) {
112 return <ResultComponent key={key} {...output} />;
113 }
114
115 return (
116 <ResultComponent
117 key={key}
118 message={typeof output === "string" ? output : undefined}
119 />
120 );
121 }
122
123 if (name === "webSearch") {
124 return <SearchCall key={key} />;
125 }
126
127 const CallComponent = ToolCallComponentMap[
128 name as keyof typeof ToolCallComponentMap
129 ] as ComponentType<Record<string, unknown>> | undefined;
130
131 if (!CallComponent) {
132 return null;
133 }
134
135 return (
136 <CallComponent
137 key={key}
138 {...getToolInputArgs(part)}
139 loading={isStreaming || part.state === "input-streaming"}
140 />
141 );
142 });
143 }
144
145 export default function AIMessageComponent({
146 message,
147 isStreaming,
148 isLastMessage = true,
149 }: {
150 message: UIMessage;
151 isStreaming: boolean;
152 isLastMessage?: boolean;
153 }) {
154 const toolParts = message.parts.filter(isToolPart);
155 const hasToolCalls = toolParts.length > 0;
156 const text = getMessageText(message).trim();
157
158 return (
159 <div className={cn((text || hasToolCalls) && "space-y-2 py-2")}>
160 {hasToolCalls &&
161 (isLastMessage ||
162 !hiddenTools.includes(getToolName(toolParts[0]!))) && (
163 <PresentationAIToolMessage
164 message={message}
165 isStreaming={isStreaming}
166 />
167 )}
168 {text ? (
169 <div className="whitespace-pre-wrap rounded-2xl bg-muted px-3 py-2 text-sm text-foreground shadow-2xs">
170 {text}
171 </div>
172 ) : null}
173 </div>
174 );
175 }
176
176 lines Plain Text