refactor(ai-chat): split ai message or user message component
- Introduced UserChatMessage and UserMessageParts components to handle user messages distinctly, allowing for better editing and interaction. - Updated AIChatMessage to streamline message rendering and improve markdown formatting. - Enhanced ToolInvocationComponent with accordion functionality for better organization of tool call details. - Adjusted layout properties across various components to ensure responsiveness and visual consistency. These changes collectively enhance the usability and clarity of the AI chat interface, providing a more intuitive experience for users. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
c2ea3828fe
commit
1f340595b6
|
|
@ -51,7 +51,7 @@ const FeedAnalytics = ({ data }: { data: AnalyticsData["feedData"] }) => {
|
|||
</div>
|
||||
|
||||
{analytics && (
|
||||
<div className="@[400px]:grid-cols-2 grid grid-cols-1 gap-4">
|
||||
<div className="@[600px]:grid-cols-2 @[400px]:grid-cols-2 grid grid-cols-1 gap-4">
|
||||
<StatCard title="Views" value={analytics.view || 0} emoji="👀" />
|
||||
<StatCard
|
||||
title="Last Checked"
|
||||
|
|
@ -258,7 +258,7 @@ const AIDisplayAnalyticsPartBase = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<Card className="mx-auto mb-2 w-full max-w-4xl">
|
||||
<Card className="w-[calc(var(--ai-chat-layout-width,65ch)] mx-auto mb-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-text flex items-center gap-2 text-xl font-semibold">
|
||||
<span className="text-lg">📊</span>
|
||||
|
|
@ -279,5 +279,5 @@ export const AIDisplayAnalyticsPart = withDisplayStateHandler<AIDisplayAnalytics
|
|||
title: "Analytics",
|
||||
loadingDescription: "Fetching analytics data...",
|
||||
errorTitle: "Analytics Error",
|
||||
maxWidth: "max-w-4xl",
|
||||
maxWidth: "max-w-full",
|
||||
})(AIDisplayAnalyticsPartBase)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export const AIDisplayFlowPart = toolMemo(
|
|||
}, [nodes, edges, colorMode, present])
|
||||
|
||||
return (
|
||||
<div className="group relative my-2 aspect-[4/3] w-[calc(var(--ai-chat-layout-width,65ch)-120px)] max-w-prose overflow-hidden rounded-md">
|
||||
<div className="group relative my-2 aspect-[4/3] w-[calc(var(--ai-chat-layout-width,65ch))] max-w-full overflow-hidden rounded-md">
|
||||
<ReactFlow
|
||||
colorMode={colorMode}
|
||||
nodes={nodes}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
AIChatMessage,
|
||||
AIChatWaitingIndicator,
|
||||
} from "~/modules/ai-chat/components/message/AIChatMessage"
|
||||
import { UserChatMessage } from "~/modules/ai-chat/components/message/UserChatMessage"
|
||||
import { useAutoScroll } from "~/modules/ai-chat/hooks/useAutoScroll"
|
||||
import { useLoadMessages } from "~/modules/ai-chat/hooks/useLoadMessages"
|
||||
import { useMainEntryId } from "~/modules/ai-chat/hooks/useMainEntryId"
|
||||
|
|
@ -249,7 +250,11 @@ const Messages: FC = () => {
|
|||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
{messages.map((message) => (
|
||||
<Suspense key={message.id}>
|
||||
<AIChatMessage message={message} />
|
||||
{message.role === "user" ? (
|
||||
<UserChatMessage message={message} />
|
||||
) : (
|
||||
<AIChatMessage message={message} />
|
||||
)}
|
||||
</Suspense>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<BizUIMetadata, UIDataTypes, BizUITools>
|
||||
onEdit?: (messageId: string) => void
|
||||
streaming?: boolean
|
||||
}
|
||||
|
||||
export const AIChatMessage: React.FC<AIChatMessageProps> = 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<BizUIMetadata, UIDataTypes, BizUITools>) => {
|
||||
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<AIChatMessageProps> = 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<AIChatMessageProps> = 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<AIChatMessageProps> = React.memo(({ message
|
|||
}
|
||||
}, [getMessageMarkdownFormat])
|
||||
|
||||
const handleRetry = React.useCallback(() => {
|
||||
chatActions.regenerate({ messageId })
|
||||
}, [chatActions, messageId])
|
||||
|
||||
return (
|
||||
<div
|
||||
onContextMenu={stopPropagation}
|
||||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} group`}
|
||||
className="group flex justify-start"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="text-text relative flex max-w-[calc(100%-1rem)] flex-col gap-2">
|
||||
{/* Show editable message if editing */}
|
||||
{isEditing && isUserMessage ? (
|
||||
<EditableMessage
|
||||
messageId={messageId}
|
||||
parts={message.parts}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Normal message display */}
|
||||
<div
|
||||
className={cn(
|
||||
`text-text backdrop-blur-sm`,
|
||||
message.role === "user" && "rounded-2xl bg-gray-100 px-4 py-2.5 dark:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<div className={`flex select-text flex-col gap-2 text-sm`}>
|
||||
<AIMessageParts message={message} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-text relative flex max-w-full flex-col gap-2">
|
||||
{/* Normal message display */}
|
||||
<div className="text-text">
|
||||
<div className="flex select-text flex-col gap-2 text-sm">
|
||||
<AIMessageParts message={message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<m.div
|
||||
className={`absolute bottom-1 flex ${message.role === "user" ? "right-0" : "left-0"} gap-1`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="text-text hover:bg-fill-secondary flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
|
||||
title="Edit message"
|
||||
>
|
||||
<i className="i-mgc-edit-cute-re size-3" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="text-text hover:bg-fill-secondary flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
|
||||
title="Retry"
|
||||
>
|
||||
<i className="i-mgc-refresh-2-cute-re size-3" />
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="text-text hover:bg-fill-tertiary flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
|
||||
title="Copy message"
|
||||
>
|
||||
<i className="i-mgc-copy-2-cute-re size-3" />
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
)}
|
||||
</m.div>
|
||||
{/* Action buttons */}
|
||||
<m.div
|
||||
className="absolute bottom-1 left-0 flex gap-1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="text-text hover:bg-fill-tertiary flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
|
||||
title="Copy message"
|
||||
>
|
||||
<i className="i-mgc-copy-2-cute-re size-3" />
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
</m.div>
|
||||
|
||||
<div className="h-6" />
|
||||
</>
|
||||
)}
|
||||
<div className="h-6" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MessagePartsProps> = React.memo(({ message }) => {
|
||||
export const AIMessageParts: React.FC<AIMessagePartsProps> = React.memo(({ message }) => {
|
||||
return message.parts.map((part, index) => {
|
||||
const partKey = `${message.id}-${index}`
|
||||
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
if (message.role === "assistant")
|
||||
return (
|
||||
<AIMarkdownStreamingMessage
|
||||
isProcessing={message.metadata?.totalTokens === undefined}
|
||||
key={partKey}
|
||||
text={part.text}
|
||||
className={"text-text"}
|
||||
/>
|
||||
)
|
||||
return <AIMarkdownMessage key={partKey} text={part.text} />
|
||||
}
|
||||
|
||||
case "data-block": {
|
||||
return <AIDataBlockPart key={partKey} blocks={part.data as AIChatContextBlock[]} />
|
||||
}
|
||||
|
||||
case "data-rich-text": {
|
||||
return (
|
||||
<AIRichTextMessage
|
||||
<AIMarkdownStreamingMessage
|
||||
isProcessing={message.metadata?.totalTokens === undefined}
|
||||
key={partKey}
|
||||
data={part.data as { state: string; text: string }}
|
||||
className="text-text"
|
||||
text={part.text}
|
||||
className={"text-text"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ToolInvocationComponentProps> = 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 (
|
||||
<div
|
||||
className={`min-w-0 max-w-prose text-left ${hasError ? "border-red/30" : "border-border"}`}
|
||||
className={`min-w-0 max-w-full text-left ${hasError ? "border-red/30" : "border-border"}`}
|
||||
>
|
||||
<div className="w-[9999px] max-w-[calc(var(--ai-chat-layout-width,65ch)_-120px)]" />
|
||||
<div className="flex h-6 min-w-0 flex-1 items-center">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<i className={hasError ? "i-mgc-close-cute-re text-red" : "i-mingcute-tool-line"} />
|
||||
<span className="text-text-secondary">
|
||||
{hasError ? "Tool Failed:" : "Tool Calling:"}
|
||||
</span>
|
||||
<h4 className={`truncate font-medium ${hasError ? "text-red" : "text-text"}`}>
|
||||
{toolName}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-[calc(var(--ai-chat-layout-width,65ch)] w-[9999px]" />
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value={accordionValue} className="group border-none">
|
||||
<AccordionTrigger
|
||||
className="group flex h-6 min-w-0 flex-1 items-center justify-between py-0 hover:no-underline"
|
||||
chevron={false}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<i className={hasError ? "i-mgc-close-cute-re text-red" : "i-mgc-tool-cute-re"} />
|
||||
<span className="text-text-secondary">
|
||||
{hasError ? "Tool Failed:" : "Tool Called:"}
|
||||
</span>
|
||||
<h4 className={`truncate font-medium ${hasError ? "text-red" : "text-text"}`}>
|
||||
{toolName}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Custom arrow that only shows on hover */}
|
||||
<div className="ml-auto flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<i className="i-mgc-right-cute-re size-3 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90" />
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="pb-0 pt-2">
|
||||
<div className="space-y-2 text-xs">
|
||||
{/* Show tool arguments if available */}
|
||||
{hasArgs ? (
|
||||
<div>
|
||||
<div className="text-text-secondary mb-1 font-medium">Arguments:</div>
|
||||
<JsonHighlighter
|
||||
className="text-text-tertiary bg-fill-secondary overflow-x-auto rounded p-2 text-[11px]"
|
||||
json={JSON.stringify(part.input, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Show tool result if available */}
|
||||
{hasResult ? (
|
||||
<div>
|
||||
<div className="text-text-secondary mb-1 font-medium">Result:</div>
|
||||
<JsonHighlighter
|
||||
className="text-text-tertiary bg-fill-secondary overflow-x-auto rounded p-2 text-[11px]"
|
||||
json={JSON.stringify(part.output, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Show error if available */}
|
||||
{hasError && "errorText" in part ? (
|
||||
<div>
|
||||
<div className="text-red mb-1 font-medium">Error:</div>
|
||||
<pre className="text-red bg-red/10 overflow-x-auto rounded p-2 text-[11px]">
|
||||
{String(part.errorText)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<BizUIMetadata, UIDataTypes, BizUITools>
|
||||
}
|
||||
|
||||
export const UserChatMessage: React.FC<UserChatMessageProps> = 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 (
|
||||
<div
|
||||
onContextMenu={stopPropagation}
|
||||
className="group flex justify-end"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="text-text relative flex max-w-[calc(100%-1rem)] flex-col gap-2">
|
||||
{/* Show editable message if editing */}
|
||||
{isEditing ? (
|
||||
<EditableMessage
|
||||
messageId={messageId}
|
||||
parts={message.parts}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Normal message display */}
|
||||
<div className="text-text rounded-2xl bg-gray-100 px-4 py-2.5 backdrop-blur-sm dark:bg-gray-800">
|
||||
<div className="flex select-text flex-col gap-2 text-sm">
|
||||
<UserMessageParts message={message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<m.div
|
||||
className="absolute bottom-1 right-0 flex gap-1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="text-text hover:bg-fill-secondary flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
|
||||
title="Edit message"
|
||||
>
|
||||
<i className="i-mgc-edit-cute-re size-3" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="text-text hover:bg-fill-secondary flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
|
||||
title="Retry"
|
||||
>
|
||||
<i className="i-mgc-refresh-2-cute-re size-3" />
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</m.div>
|
||||
|
||||
<div className="h-6" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
@ -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<UserMessagePartsProps> = React.memo(({ message }) => {
|
||||
return message.parts.map((part, index) => {
|
||||
const partKey = `${message.id}-${index}`
|
||||
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
return <AIMarkdownMessage key={partKey} text={part.text} />
|
||||
}
|
||||
|
||||
case "data-block": {
|
||||
return <AIDataBlockPart key={partKey} blocks={part.data as AIChatContextBlock[]} />
|
||||
}
|
||||
|
||||
case "data-rich-text": {
|
||||
return (
|
||||
<AIRichTextMessage
|
||||
key={partKey}
|
||||
data={part.data as { state: string; text: string }}
|
||||
className="text-text"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
UserMessageParts.displayName = "UserMessageParts"
|
||||
|
|
@ -67,7 +67,12 @@ const AIChatFloatingPanelInner: FC<AIChatFloatingPanelProps> = ({ 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`,
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<Resizable
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ const AIEntryLayoutImpl = () => {
|
|||
}}
|
||||
/>
|
||||
<AIChatLayout
|
||||
key="ai-chat-layout"
|
||||
style={
|
||||
{ width: position, "--ai-chat-layout-width": `${position}px` } as React.CSSProperties
|
||||
}
|
||||
|
|
@ -194,7 +195,7 @@ const AIEntryLayoutImpl = () => {
|
|||
)}
|
||||
|
||||
{/* Floating panel - renders outside layout flow */}
|
||||
{panelStyle === AIChatPanelStyle.Floating && <AIChatLayout />}
|
||||
{panelStyle === AIChatPanelStyle.Floating && <AIChatLayout key="ai-chat-layout" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="m8.64 2.425-.454.078c-.448.074-.746.214-1.031.486-.648.616-.717 1.624-.167 2.422.057.082.713.71 1.457 1.394 1.076.989 1.362 1.273 1.391 1.381.029.102.014.254-.061.624a4.643 4.643 0 0 1-.147.585c-.087.178-.233.243-.792.353-.467.092-.561.1-.666.056-.075-.031-.599-.569-1.352-1.388C5.969 7.493 5.515 7.03 5.35 6.92c-1.057-.706-2.476-.197-2.784 1-.205.799-.207 2.004-.006 2.929a7.033 7.033 0 0 0 3.683 4.775 6.929 6.929 0 0 0 3.654.733c.478-.036 1.193-.157 1.406-.238a.586.586 0 0 1 .137-.039c.019 0 .92 1.045 2.001 2.323 1.518 1.792 2.036 2.377 2.269 2.562.77.612 1.791.911 2.73.798.932-.112 1.668-.463 2.287-1.09.741-.751 1.1-1.682 1.061-2.753-.033-.937-.303-1.643-.898-2.351-.098-.116-1.213-1.087-2.478-2.157-2.25-1.905-2.3-1.949-2.277-2.069.012-.068.069-.339.125-.603.375-1.753-.016-3.708-1.05-5.26-.43-.645-1.253-1.468-1.89-1.89a7.287 7.287 0 0 0-2.56-1.072c-.331-.069-.59-.09-1.22-.1-.44-.006-.845-.003-.9.007m1.64 2.041c1.764.308 3.288 1.613 3.85 3.294.21.629.25.893.249 1.64-.002.65-.011.738-.13 1.225-.22.898-.149 1.421.279 2.046.071.103 1.035.948 2.427 2.126 1.27 1.075 2.367 2.02 2.437 2.1.271.309.417.715.413 1.151-.008.829-.527 1.477-1.355 1.693a1.719 1.719 0 0 1-1.505-.347c-.084-.07-1.032-1.165-2.105-2.433-1.073-1.267-2.012-2.351-2.086-2.406-.589-.445-1.217-.547-2.074-.336-.648.159-1.162.204-1.696.148-1.055-.11-1.894-.446-2.706-1.086-1.128-.888-1.796-2.216-1.864-3.701-.013-.286-.008-.577.011-.647l.035-.127 1.036 1.127c.57.62 1.135 1.212 1.256 1.315.255.218.691.441 1.008.516.533.126 1.639-.009 2.36-.288.532-.205.968-.597 1.262-1.132.164-.298.215-.47.358-1.194.108-.549.127-1.017.056-1.358-.063-.299-.317-.815-.522-1.059-.096-.114-.678-.67-1.294-1.235a41.618 41.618 0 0 1-1.133-1.061c-.032-.084.896-.065 1.433.029" fill="#10161F" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -46,6 +46,7 @@ function AccordionItem({ className, children, ...props }: AccordionItemProps) {
|
|||
)}
|
||||
>
|
||||
<AccordionPrimitive.Item
|
||||
data-state={isOpen ? "open" : "closed"}
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
|
|
|
|||
Loading…
Reference in New Issue