diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIDisplayAnalyticsPart.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIDisplayAnalyticsPart.tsx index 5271a5f12..d03bf4799 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIDisplayAnalyticsPart.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIDisplayAnalyticsPart.tsx @@ -51,7 +51,7 @@ const FeedAnalytics = ({ data }: { data: AnalyticsData["feedData"] }) => { {analytics && ( -
+
+ 📊 @@ -279,5 +279,5 @@ export const AIDisplayAnalyticsPart = withDisplayStateHandler +
{
{messages.map((message) => ( - + {message.role === "user" ? ( + + ) : ( + + )} ))}
diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIChatMessage.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIChatMessage.tsx index 741b13b99..482ab8167 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIChatMessage.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIChatMessage.tsx @@ -1,21 +1,18 @@ import { createDefaultLexicalEditor } from "@follow/components/ui/lexical-rich-editor/editor.js" -import { cn, stopPropagation, thenable } from "@follow/utils" +import { stopPropagation, thenable } from "@follow/utils" import type { UIDataTypes, UIMessage } from "ai" -import type { LexicalEditor, SerializedEditorState } from "lexical" +import type { LexicalEditor } from "lexical" import { m } from "motion/react" import * as React from "react" import { toast } from "sonner" import { copyToClipboard } from "~/lib/clipboard" -import { useEditingMessageId, useSetEditingMessageId } from "~/modules/ai-chat/atoms/session" -import { useChatActions } from "~/modules/ai-chat/store/hooks" import type { BizUIMetadata, BizUITools } from "~/modules/ai-chat/store/types" import { MentionPlugin } from "../../editor" import type { RichTextPart } from "../../types/ChatSession" import { convertLexicalToMarkdown } from "../../utils/lexical-markdown" import { AIMessageParts } from "./AIMessageParts" -import { EditableMessage } from "./EditableMessage" export interface ChatMessage { id: string @@ -26,31 +23,11 @@ export interface ChatMessage { interface AIChatMessageProps { message: UIMessage - onEdit?: (messageId: string) => void - streaming?: boolean } -export const AIChatMessage: React.FC = React.memo(({ message }) => { - if (message.parts.length === 0) { - throw thenable - } - const chatActions = useChatActions() - - const messageId = message.id - const [isHovered, setIsHovered] = React.useState(false) - const editingMessageId = useEditingMessageId() - const setEditingMessageId = useSetEditingMessageId() - - const isEditing = editingMessageId === messageId - const isUserMessage = message.role === "user" - - const handleEdit = React.useCallback(() => { - if (isUserMessage) { - setEditingMessageId(messageId) - } - }, [isUserMessage, messageId, setEditingMessageId]) - - const getMessageMarkdownFormat = React.useCallback(() => { +// Utility function for converting message to markdown +const useMessageMarkdownFormat = (message: UIMessage) => { + return React.useCallback(() => { let content = "" for (const part of message.parts) { let lexicalEditor: LexicalEditor | null = null @@ -78,38 +55,16 @@ export const AIChatMessage: React.FC = React.memo(({ message } return content }, [message.parts]) +} - const handleSaveEdit = React.useCallback( - (newState: SerializedEditorState, editor: LexicalEditor) => { - const messageContent = convertLexicalToMarkdown(editor) - const messages = chatActions.getMessages() - const messageIndex = messages.findIndex((msg) => msg.id === messageId) - if (messageIndex !== -1) { - const messagesToKeep = messages.slice(0, messageIndex) - const nextMessage = messages[messageIndex]! - chatActions.setMessages(messagesToKeep) +// AI message component +export const AIChatMessage: React.FC = React.memo(({ message }) => { + if (message.parts.length === 0) { + throw thenable + } - const richTextPart = nextMessage.parts.find( - (part) => part.type === "data-rich-text", - ) as RichTextPart - if (richTextPart) { - richTextPart.data = { - state: newState, - text: messageContent, - } - } - - // Send the edited message - chatActions.sendMessage(nextMessage) - } - setEditingMessageId(null) - }, - [chatActions, messageId, setEditingMessageId], - ) - - const handleCancelEdit = React.useCallback(() => { - setEditingMessageId(null) - }, [setEditingMessageId]) + const [isHovered, setIsHovered] = React.useState(false) + const getMessageMarkdownFormat = useMessageMarkdownFormat(message) const handleCopy = React.useCallback(async () => { const messageContent = getMessageMarkdownFormat() @@ -121,86 +76,42 @@ export const AIChatMessage: React.FC = React.memo(({ message } }, [getMessageMarkdownFormat]) - const handleRetry = React.useCallback(() => { - chatActions.regenerate({ messageId }) - }, [chatActions, messageId]) - return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > -
- {/* Show editable message if editing */} - {isEditing && isUserMessage ? ( - - ) : ( - <> - {/* Normal message display */} -
-
- -
-
+
+ {/* Normal message display */} +
+
+ +
+
- {/* Action buttons */} - - {message.role === "user" ? ( - <> - - - - ) : ( - - )} - + {/* Action buttons */} + + + -
- - )} +
) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMarkdownMessage.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMarkdownMessage.tsx index d23f8d566..f80250d9d 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMarkdownMessage.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMarkdownMessage.tsx @@ -342,7 +342,7 @@ export const AIMarkdownStreamingMessage = memo( className?: string isProcessing?: boolean }) => { - const className = `prose dark:prose-invert text-sm + const className = `prose max-w-full dark:prose-invert text-sm prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-h4:text-base prose-h5:text-base prose-h6:text-sm prose-li:list-disc prose-li:marker:text-accent prose-hr:border-border prose-hr:mx-8` diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx index 325c0d8bb..381b4d347 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx @@ -4,7 +4,6 @@ import type { ToolUIPart } from "ai" import * as React from "react" import type { - AIChatContextBlock, AIDisplayAnalyticsTool, AIDisplayEntriesTool, AIDisplayFeedsTool, @@ -19,47 +18,29 @@ import { AIDisplayFeedsPart, AIDisplaySubscriptionsPart, } from "../displays" -import { AIDataBlockPart } from "./AIDataBlockPart" -import { AIMarkdownMessage, AIMarkdownStreamingMessage } from "./AIMarkdownMessage" -import { AIRichTextMessage } from "./AIRichTextMessage" +import { AIMarkdownStreamingMessage } from "./AIMarkdownMessage" import { ToolInvocationComponent } from "./ToolInvocationComponent" const LazyAIDisplayFlowPart = React.lazy(() => import("../displays/AIDisplayFlowPart").then((mod) => ({ default: mod.AIDisplayFlowPart })), ) -interface MessagePartsProps { +interface AIMessagePartsProps { message: BizUIMessage } -export const AIMessageParts: React.FC = React.memo(({ message }) => { +export const AIMessageParts: React.FC = React.memo(({ message }) => { return message.parts.map((part, index) => { const partKey = `${message.id}-${index}` switch (part.type) { case "text": { - if (message.role === "assistant") - return ( - - ) - return - } - - case "data-block": { - return - } - - case "data-rich-text": { return ( - ) } diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ToolInvocationComponent.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ToolInvocationComponent.tsx index 7964ebc91..d387e591e 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ToolInvocationComponent.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ToolInvocationComponent.tsx @@ -1,3 +1,10 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@follow/components/ui/accordion/index.js" +import { JsonHighlighter } from "@follow/components/ui/json-highlighter/index.js" import type { ToolUIPart } from "ai" import { getToolName } from "ai" import * as React from "react" @@ -10,23 +17,77 @@ export const ToolInvocationComponent: React.FC = R ({ part }) => { const toolName = getToolName(part) const hasError = "errorText" in part && part.errorText + const hasResult = "output" in part && part.output + const hasArgs = "input" in part && part.input + + // Generate a unique value for this accordion item + const accordionValue = `tool-${"toolCallId" in part ? part.toolCallId : Math.random()}` return (
-
-
-
- - - {hasError ? "Tool Failed:" : "Tool Calling:"} - -

- {toolName} -

-
-
+
+ + + + +
+ + + {hasError ? "Tool Failed:" : "Tool Called:"} + +

+ {toolName} +

+
+ + {/* Custom arrow that only shows on hover */} +
+ +
+
+ + +
+ {/* Show tool arguments if available */} + {hasArgs ? ( +
+
Arguments:
+ +
+ ) : null} + + {/* Show tool result if available */} + {hasResult ? ( +
+
Result:
+ +
+ ) : null} + + {/* Show error if available */} + {hasError && "errorText" in part ? ( +
+
Error:
+
+                      {String(part.errorText)}
+                    
+
+ ) : null} +
+
+
+
) }, diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx new file mode 100644 index 000000000..fbb7ab845 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx @@ -0,0 +1,133 @@ +import { stopPropagation, thenable } from "@follow/utils" +import type { UIDataTypes, UIMessage } from "ai" +import type { LexicalEditor, SerializedEditorState } from "lexical" +import { m } from "motion/react" +import * as React from "react" + +import { useEditingMessageId, useSetEditingMessageId } from "~/modules/ai-chat/atoms/session" +import { useChatActions } from "~/modules/ai-chat/store/hooks" +import type { BizUIMetadata, BizUITools } from "~/modules/ai-chat/store/types" + +import type { RichTextPart } from "../../types/ChatSession" +import { convertLexicalToMarkdown } from "../../utils/lexical-markdown" +import { EditableMessage } from "./EditableMessage" +import { UserMessageParts } from "./UserMessageParts" + +interface UserChatMessageProps { + message: UIMessage +} + +export const UserChatMessage: React.FC = React.memo(({ message }) => { + if (message.parts.length === 0) { + throw thenable + } + + const chatActions = useChatActions() + const messageId = message.id + const [isHovered, setIsHovered] = React.useState(false) + const editingMessageId = useEditingMessageId() + const setEditingMessageId = useSetEditingMessageId() + + const isEditing = editingMessageId === messageId + + const handleEdit = React.useCallback(() => { + setEditingMessageId(messageId) + }, [messageId, setEditingMessageId]) + + const handleSaveEdit = React.useCallback( + (newState: SerializedEditorState, editor: LexicalEditor) => { + const messageContent = convertLexicalToMarkdown(editor) + const messages = chatActions.getMessages() + const messageIndex = messages.findIndex((msg) => msg.id === messageId) + if (messageIndex !== -1) { + const messagesToKeep = messages.slice(0, messageIndex) + const nextMessage = messages[messageIndex]! + chatActions.setMessages(messagesToKeep) + + const richTextPart = nextMessage.parts.find( + (part) => part.type === "data-rich-text", + ) as RichTextPart + if (richTextPart) { + richTextPart.data = { + state: newState, + text: messageContent, + } + } + + // Send the edited message + chatActions.sendMessage(nextMessage) + } + setEditingMessageId(null) + }, + [chatActions, messageId, setEditingMessageId], + ) + + const handleCancelEdit = React.useCallback(() => { + setEditingMessageId(null) + }, [setEditingMessageId]) + + const handleRetry = React.useCallback(() => { + chatActions.regenerate({ messageId }) + }, [chatActions, messageId]) + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+ {/* Show editable message if editing */} + {isEditing ? ( + + ) : ( + <> + {/* Normal message display */} +
+
+ +
+
+ + {/* Action buttons */} + + + + + +
+ + )} +
+
+ ) +}) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserMessageParts.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserMessageParts.tsx new file mode 100644 index 000000000..4ffab2766 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserMessageParts.tsx @@ -0,0 +1,43 @@ +import * as React from "react" + +import type { AIChatContextBlock, BizUIMessage } from "~/modules/ai-chat/store/types" + +import { AIDataBlockPart } from "./AIDataBlockPart" +import { AIMarkdownMessage } from "./AIMarkdownMessage" +import { AIRichTextMessage } from "./AIRichTextMessage" + +interface UserMessagePartsProps { + message: BizUIMessage +} + +export const UserMessageParts: React.FC = React.memo(({ message }) => { + return message.parts.map((part, index) => { + const partKey = `${message.id}-${index}` + + switch (part.type) { + case "text": { + return + } + + case "data-block": { + return + } + + case "data-rich-text": { + return ( + + ) + } + + default: { + return null + } + } + }) +}) + +UserMessageParts.displayName = "UserMessageParts" diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/ai/AIChatFloatingPanel.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/ai/AIChatFloatingPanel.tsx index b47e9aefc..dea204b12 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/ai/AIChatFloatingPanel.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/ai/AIChatFloatingPanel.tsx @@ -67,7 +67,12 @@ const AIChatFloatingPanelInner: FC = ({ className, ... exit={{ scale: 0.92, y: 100, opacity: 0 }} transition={Spring.presets.smooth} className="fixed z-50" - style={{ left: floatingState.x, top: floatingState.y }} + style={{ + left: floatingState.x, + top: floatingState.y, + // @ts-expect-error + "--ai-chat-layout-width": `${floatingState.width}px`, + }} >
{ }} /> { )} {/* Floating panel - renders outside layout flow */} - {panelStyle === AIChatPanelStyle.Floating && } + {panelStyle === AIChatPanelStyle.Floating && }
) } diff --git a/icons/mgc/tool_cute_re.svg b/icons/mgc/tool_cute_re.svg new file mode 100644 index 000000000..8f2e65f06 --- /dev/null +++ b/icons/mgc/tool_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/internal/components/src/ui/accordion/index.tsx b/packages/internal/components/src/ui/accordion/index.tsx index 0108a7756..47cf6f9e8 100644 --- a/packages/internal/components/src/ui/accordion/index.tsx +++ b/packages/internal/components/src/ui/accordion/index.tsx @@ -46,6 +46,7 @@ function AccordionItem({ className, children, ...props }: AccordionItemProps) { )} >