refactor: optimize chat components and remove unused GridContainer
- Refactored ToolInvocationComponent to use React.memo for performance optimization. - Updated AIDisplayAnalyticsPart, AIDisplayEntriesPart, AIDisplayFeedsPart, and AIDisplaySubscriptionsPart to utilize memoization. - Replaced GridContainer with standard div elements for layout consistency. - Removed unused GridContainer and StatisticsOverview components to streamline the codebase. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
73a1e6eb6c
commit
c5ff53aa20
|
|
@ -12,51 +12,54 @@ interface ToolInvocationComponentProps {
|
|||
part: ToolUIPart
|
||||
}
|
||||
|
||||
export const ToolInvocationComponent: React.FC<ToolInvocationComponentProps> = ({ part }) => {
|
||||
const toolName = getToolName(part)
|
||||
return (
|
||||
<div className="bg-material-medium border-border size-full min-w-0 max-w-prose rounded-lg border text-left">
|
||||
<div className="w-[9999px] max-w-[calc(var(--ai-chat-layout-width,65ch)_-120px)]" />
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="tool-invocation">
|
||||
<AccordionTrigger className="flex w-full cursor-pointer items-center gap-3 py-1 pl-4 pr-2 hover:no-underline">
|
||||
{/* Tool Info */}
|
||||
<div className="flex h-6 min-w-0 flex-1 items-center">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<i className="i-mingcute-tool-line" />
|
||||
<span className="text-text-secondary">Tool Calling:</span>
|
||||
<h4 className="text-text truncate font-medium">{toolName}</h4>
|
||||
export const ToolInvocationComponent: React.FC<ToolInvocationComponentProps> = React.memo(
|
||||
({ part }) => {
|
||||
const toolName = getToolName(part)
|
||||
|
||||
return (
|
||||
<div className="bg-material-medium border-border size-full min-w-0 max-w-prose rounded-lg border text-left">
|
||||
<div className="w-[9999px] max-w-[calc(var(--ai-chat-layout-width,65ch)_-120px)]" />
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="tool-invocation">
|
||||
<AccordionTrigger className="flex w-full cursor-pointer items-center gap-3 py-1 pl-4 pr-2 hover:no-underline">
|
||||
{/* Tool Info */}
|
||||
<div className="flex h-6 min-w-0 flex-1 items-center">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<i className="i-mingcute-tool-line" />
|
||||
<span className="text-text-secondary">Tool Calling:</span>
|
||||
<h4 className="text-text truncate font-medium">{toolName}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="min-w-0 border-t border-zinc-200/50 bg-zinc-50/50 p-4 dark:border-zinc-700/50 dark:bg-zinc-800/50">
|
||||
<div className="space-y-3">
|
||||
{"input" in part && (
|
||||
<div>
|
||||
<div className="text-text-tertiary mb-2 text-xs font-semibold uppercase tracking-wide">
|
||||
Arguments
|
||||
<AccordionContent className="min-w-0 border-t border-zinc-200/50 bg-zinc-50/50 p-4 dark:border-zinc-700/50 dark:bg-zinc-800/50">
|
||||
<div className="space-y-3">
|
||||
{"input" in part && (
|
||||
<div>
|
||||
<div className="text-text-tertiary mb-2 text-xs font-semibold uppercase tracking-wide">
|
||||
Arguments
|
||||
</div>
|
||||
<pre className="text-text-secondary max-h-32 overflow-auto rounded-lg bg-zinc-100/80 p-3 text-xs leading-relaxed dark:bg-zinc-900/80">
|
||||
{JSON.stringify(part.input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<pre className="text-text-secondary max-h-32 overflow-auto rounded-lg bg-zinc-100/80 p-3 text-xs leading-relaxed dark:bg-zinc-900/80">
|
||||
{JSON.stringify(part.input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{"output" in part && (
|
||||
<div>
|
||||
<div className="text-text-tertiary mb-2 text-xs font-semibold uppercase tracking-wide">
|
||||
Result
|
||||
{"output" in part && (
|
||||
<div>
|
||||
<div className="text-text-tertiary mb-2 text-xs font-semibold uppercase tracking-wide">
|
||||
Result
|
||||
</div>
|
||||
<pre className="text-text max-h-32 overflow-auto rounded-lg bg-zinc-100/80 p-3 text-xs leading-relaxed dark:bg-zinc-900/80">
|
||||
{JSON.stringify(part.output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<pre className="text-text max-h-32 overflow-auto rounded-lg bg-zinc-100/80 p-3 text-xs leading-relaxed dark:bg-zinc-900/80">
|
||||
{JSON.stringify(part.output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
TableRow,
|
||||
} from "@follow/components/ui/table/index.js"
|
||||
import dayjs from "dayjs"
|
||||
import { memo } from "react"
|
||||
|
||||
import type { AIDisplayAnalyticsTool } from "../../__internal__/types"
|
||||
import { ErrorState, LoadingState } from "../common-states"
|
||||
|
|
@ -229,18 +230,7 @@ const OverviewAnalytics = ({ data }: { data: AnalyticsData["overviewStats"] }) =
|
|||
)
|
||||
}
|
||||
|
||||
export const AIDisplayAnalyticsPart = ({ part }: { part: AIDisplayAnalyticsTool }) => {
|
||||
// Handle loading state
|
||||
if (part.state === "input-streaming" || part.state === "input-available") {
|
||||
return (
|
||||
<LoadingState
|
||||
title="Loading Analytics..."
|
||||
description="Fetching analytics data..."
|
||||
maxWidth="max-w-4xl"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const AIDisplayAnalyticsPart = memo(({ part }: { part: AIDisplayAnalyticsTool }) => {
|
||||
// Handle error state
|
||||
if (part.state === "output-error") {
|
||||
return (
|
||||
|
|
@ -256,9 +246,8 @@ export const AIDisplayAnalyticsPart = ({ part }: { part: AIDisplayAnalyticsTool
|
|||
)
|
||||
}
|
||||
|
||||
// Handle no output
|
||||
const { output } = part
|
||||
if (!output) {
|
||||
// Handle no output or invalid state
|
||||
if (part.state !== "output-available" || !part.output) {
|
||||
return (
|
||||
<LoadingState
|
||||
title="Loading Analytics..."
|
||||
|
|
@ -268,7 +257,7 @@ export const AIDisplayAnalyticsPart = ({ part }: { part: AIDisplayAnalyticsTool
|
|||
)
|
||||
}
|
||||
|
||||
const { analyticsData, analyticsType, timeRange, displayType, title } = output
|
||||
const { analyticsData, analyticsType, timeRange, displayType, title } = part.output
|
||||
|
||||
const renderAnalytics = () => {
|
||||
switch (analyticsType) {
|
||||
|
|
@ -309,4 +298,4 @@ export const AIDisplayAnalyticsPart = ({ part }: { part: AIDisplayAnalyticsTool
|
|||
<CardContent>{renderAnalytics()}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { FeedIcon } from "~/modules/feed/feed-icon"
|
|||
|
||||
import type { AIDisplayEntriesTool } from "../../__internal__/types"
|
||||
import { ErrorState, LoadingState } from "../common-states"
|
||||
import { CategoryTag, EmptyState, GridContainer, StatCard } from "./shared"
|
||||
import { CategoryTag, EmptyState, StatCard } from "./shared"
|
||||
|
||||
type EntryData = AIDisplayEntriesTool["output"]["entries"]
|
||||
type EntryItem = EntryData[number]
|
||||
|
|
@ -59,10 +59,7 @@ const EntriesGrid = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<GridContainer
|
||||
columns={{ base: 1, md: 2, lg: 3 }}
|
||||
className="@[600px]:grid-cols-2 @[900px]:grid-cols-3"
|
||||
>
|
||||
<div className="@[600px]:grid-cols-2 @[900px]:grid-cols-3 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.map((item) => (
|
||||
<EntryCard
|
||||
key={item.entry.id}
|
||||
|
|
@ -71,7 +68,7 @@ const EntriesGrid = ({
|
|||
showMetadata={showMetadata}
|
||||
/>
|
||||
))}
|
||||
</GridContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -266,18 +263,7 @@ const GroupedEntries = ({
|
|||
)
|
||||
}
|
||||
|
||||
export const AIDisplayEntriesPart = ({ part }: { part: AIDisplayEntriesTool }) => {
|
||||
// Handle loading state
|
||||
if (part.state === "input-streaming" || part.state === "input-available") {
|
||||
return (
|
||||
<LoadingState
|
||||
title="Loading Entries..."
|
||||
description="Fetching entry data..."
|
||||
maxWidth="max-w-6xl"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const AIDisplayEntriesPart = memo(({ part }: { part: AIDisplayEntriesTool }) => {
|
||||
// Handle error state
|
||||
if (part.state === "output-error") {
|
||||
return (
|
||||
|
|
@ -355,16 +341,16 @@ export const AIDisplayEntriesPart = ({ part }: { part: AIDisplayEntriesTool }) =
|
|||
</CardHeader>
|
||||
<CardContent className="@container space-y-6">
|
||||
{/* Statistics Overview */}
|
||||
<GridContainer columns={{ base: 2, md: 4 }} className="@[600px]:grid-cols-4">
|
||||
<div className="@[600px]:grid-cols-4 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatCard title="Total Entries" value={totalEntries} emoji="📄" />
|
||||
<StatCard title="Feeds" value={feedsCount} emoji="📡" />
|
||||
<StatCard title="Authors" value={authorsCount} emoji="✍️" />
|
||||
<StatCard title="Categories" value={categoriesCount} emoji="🏷️" />
|
||||
</GridContainer>
|
||||
</div>
|
||||
|
||||
{/* Entries Display */}
|
||||
{renderEntries()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import {
|
|||
CardTitle,
|
||||
} from "@follow/components/ui/card/index.js"
|
||||
import dayjs from "dayjs"
|
||||
import { memo } from "react"
|
||||
|
||||
import { FeedIcon } from "~/modules/feed/feed-icon"
|
||||
|
||||
import type { AIDisplayFeedsTool } from "../../__internal__/types"
|
||||
import { ErrorState, LoadingState } from "../common-states"
|
||||
import { AnalyticsMetrics, EmptyState, GridContainer, StatCard } from "./shared"
|
||||
import { AnalyticsMetrics, EmptyState, StatCard } from "./shared"
|
||||
|
||||
type FeedData = AIDisplayFeedsTool["output"]["feeds"]
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ const FeedsGrid = ({ data, showAnalytics }: { data: FeedData; showAnalytics: boo
|
|||
}
|
||||
|
||||
return (
|
||||
<GridContainer columns={{ base: 2, md: 3 }} className="@[600px]:grid-cols-3">
|
||||
<div className="@[600px]:grid-cols-3 grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
{data.map((item) => (
|
||||
<Card key={item.feed.id} className="p-4">
|
||||
<CardHeader className="h-24 px-2 py-3">
|
||||
|
|
@ -72,22 +73,11 @@ const FeedsGrid = ({ data, showAnalytics }: { data: FeedData; showAnalytics: boo
|
|||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</GridContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AIDisplayFeedsPart = ({ part }: { part: AIDisplayFeedsTool }) => {
|
||||
// Handle loading state
|
||||
if (part.state === "input-streaming" || part.state === "input-available") {
|
||||
return (
|
||||
<LoadingState
|
||||
title="Loading Feeds..."
|
||||
description="Fetching feed data..."
|
||||
maxWidth="max-w-6xl"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const AIDisplayFeedsPart = memo(({ part }: { part: AIDisplayFeedsTool }) => {
|
||||
// Handle error state
|
||||
if (part.state === "output-error") {
|
||||
return (
|
||||
|
|
@ -145,7 +135,7 @@ export const AIDisplayFeedsPart = ({ part }: { part: AIDisplayFeedsTool }) => {
|
|||
</CardHeader>
|
||||
<CardContent className="@container space-y-6">
|
||||
{/* Statistics Overview */}
|
||||
<GridContainer columns={{ base: 2, md: 4 }} className="@[600px]:grid-cols-4">
|
||||
<div className="@[600px]:grid-cols-4 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatCard title="Total Feeds" value={totalFeeds} emoji="📊" />
|
||||
<StatCard
|
||||
title="Active Feeds"
|
||||
|
|
@ -163,11 +153,11 @@ export const AIDisplayFeedsPart = ({ part }: { part: AIDisplayFeedsTool }) => {
|
|||
<StatCard title="Total Views" value={totalViews.toLocaleString()} emoji="👀" />
|
||||
</>
|
||||
)}
|
||||
</GridContainer>
|
||||
</div>
|
||||
|
||||
{/* Feeds Display */}
|
||||
{renderFeeds()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import {
|
|||
CardTitle,
|
||||
} from "@follow/components/ui/card/index.js"
|
||||
import dayjs from "dayjs"
|
||||
import { memo } from "react"
|
||||
|
||||
import { FeedIcon } from "~/modules/feed/feed-icon"
|
||||
|
||||
import type { AIDisplaySubscriptionsTool } from "../../__internal__/types"
|
||||
import { ErrorState, LoadingState } from "../common-states"
|
||||
import { AnalyticsMetrics, CategoryTag, EmptyState, GridContainer, StatCard } from "./shared"
|
||||
import { AnalyticsMetrics, CategoryTag, EmptyState, StatCard } from "./shared"
|
||||
|
||||
type SubscriptionData = AIDisplaySubscriptionsTool["output"]["subscriptions"]
|
||||
|
||||
|
|
@ -58,7 +59,7 @@ const SubscriptionsGrid = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<GridContainer columns={{ base: 2, md: 3 }} className="@[600px]:grid-cols-3">
|
||||
<div className="@[600px]:grid-cols-3 @[400px]:grid-cols-2 grid grid-cols-1 gap-4">
|
||||
{data.map((sub) => (
|
||||
<Card key={`${sub.subscription?.userId}-${sub.subscription?.feedId}`} className="p-4">
|
||||
<CardHeader className="h-24 px-2 py-3">
|
||||
|
|
@ -105,7 +106,7 @@ const SubscriptionsGrid = ({
|
|||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</GridContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -171,18 +172,8 @@ const GroupedSubscriptions = ({
|
|||
)
|
||||
}
|
||||
|
||||
export const AIDisplaySubscriptionsPart = ({ part }: { part: AIDisplaySubscriptionsTool }) => {
|
||||
// Handle loading state
|
||||
if (part.state === "input-streaming" || part.state === "input-available") {
|
||||
return (
|
||||
<LoadingState
|
||||
title="Loading Subscriptions..."
|
||||
description="Fetching subscription data..."
|
||||
maxWidth="max-w-6xl"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const AIDisplaySubscriptionsPart = memo(({ part }: { part: AIDisplaySubscriptionsTool }) => {
|
||||
return null
|
||||
// Handle error state
|
||||
if (part.state === "output-error") {
|
||||
return (
|
||||
|
|
@ -194,7 +185,7 @@ export const AIDisplaySubscriptionsPart = ({ part }: { part: AIDisplaySubscripti
|
|||
)
|
||||
}
|
||||
|
||||
// Handle no output or invalid state
|
||||
// Handle loading state
|
||||
if (part.state !== "output-available" || !part.output) {
|
||||
return (
|
||||
<LoadingState
|
||||
|
|
@ -266,7 +257,7 @@ export const AIDisplaySubscriptionsPart = ({ part }: { part: AIDisplaySubscripti
|
|||
</CardHeader>
|
||||
<CardContent className="@container space-y-6">
|
||||
{/* Statistics Overview */}
|
||||
<GridContainer columns={{ base: 2, md: 4 }} className="@[600px]:grid-cols-4">
|
||||
<div className="@[700px]:grid-cols-4 grid grid-cols-2 gap-4">
|
||||
<StatCard title="Total Subscriptions" value={totalSubscriptions} emoji="📊" />
|
||||
<StatCard
|
||||
title="Active Feeds"
|
||||
|
|
@ -276,11 +267,11 @@ export const AIDisplaySubscriptionsPart = ({ part }: { part: AIDisplaySubscripti
|
|||
/>
|
||||
{showCategories && <StatCard title="Categories" value={categoriesCount} emoji="🏷️" />}
|
||||
<StatCard title="Total Views" value={totalViews.toLocaleString()} emoji="👀" />
|
||||
</GridContainer>
|
||||
</div>
|
||||
|
||||
{/* Subscriptions Display */}
|
||||
{renderSubscriptions()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export interface GridContainerProps {
|
||||
columns?: {
|
||||
base: number
|
||||
md: number
|
||||
lg?: number
|
||||
}
|
||||
gap?: number
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const GridContainer = ({
|
||||
columns = { base: 1, md: 2 },
|
||||
gap = 4,
|
||||
children,
|
||||
className,
|
||||
}: GridContainerProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
`grid-cols-${columns.base}`,
|
||||
`md:grid-cols-${columns.md}`,
|
||||
columns.lg && `lg:grid-cols-${columns.lg}`,
|
||||
`gap-${gap}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
|
||||
import type { StatCardProps } from "./StatCard"
|
||||
import { StatCard } from "./StatCard"
|
||||
|
||||
export interface StatisticsOverviewProps {
|
||||
stats: StatCardProps[]
|
||||
columns?: {
|
||||
base: number
|
||||
md: number
|
||||
lg?: number
|
||||
}
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const StatisticsOverview = ({
|
||||
stats,
|
||||
columns = { base: 2, md: 4 },
|
||||
className,
|
||||
}: StatisticsOverviewProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-4",
|
||||
`grid-cols-${columns.base}`,
|
||||
`md:grid-cols-${columns.md}`,
|
||||
columns.lg && `lg:grid-cols-${columns.lg}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{stats.map((stat, index) => (
|
||||
<StatCard key={index} {...stat} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -4,7 +4,5 @@ export * from "./ChartPlaceholder"
|
|||
export * from "./DisplayHeader"
|
||||
export * from "./EmptyState"
|
||||
export * from "./FeedItemCard"
|
||||
export * from "./GridContainer"
|
||||
export * from "./GroupedContent"
|
||||
export * from "./StatCard"
|
||||
export * from "./StatisticsOverview"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export const AIChatSendButton: FC<AIChatSendButtonProps> = ({
|
|||
? "bg-red-500/90 hover:bg-red-500 shadow-lg shadow-red-500/25 backdrop-blur-sm"
|
||||
: disabled
|
||||
? "bg-gray-200/80 cursor-not-allowed backdrop-blur-sm"
|
||||
: "bg-gradient-to-r from-blue-500/90 to-blue-600/90 hover:from-blue-500 hover:to-blue-600 shadow-lg shadow-blue-500/25 backdrop-blur-sm hover:shadow-blue-500/35",
|
||||
: "bg-gradient-to-r from-accent to-accent/90 hover:from-accent hover:to-accent/90 shadow-lg shadow-accent/25 backdrop-blur-sm hover:shadow-accent/35",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { createDefaultLexicalEditor } from "@follow/components/ui/lexical-rich-editor/editor.js"
|
||||
import { stopPropagation } 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 { toast } from "sonner"
|
||||
|
|
@ -9,6 +11,8 @@ import { useChatActions } from "~/modules/ai/chat/__internal__/hooks"
|
|||
import type { BizUIMetadata, BizUITools } from "~/modules/ai/chat/__internal__/types"
|
||||
import { useEditingMessageId, useSetEditingMessageId } from "~/modules/ai/chat/atoms/session"
|
||||
|
||||
import type { RichTextPart } from "../../types/ChatSession"
|
||||
import { convertLexicalToMarkdown } from "../../utils/lexical-markdown"
|
||||
import { AIMessageParts } from "./AIMessageParts"
|
||||
import { EditableMessage } from "./EditableMessage"
|
||||
|
||||
|
|
@ -36,39 +40,67 @@ export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message
|
|||
const isEditing = editingMessageId === messageId
|
||||
const isUserMessage = message.role === "user"
|
||||
|
||||
// Get message content for editing
|
||||
const messageContent = React.useMemo(() => {
|
||||
return (
|
||||
message.parts
|
||||
?.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ") || ""
|
||||
)
|
||||
}, [message.parts])
|
||||
|
||||
const handleEdit = React.useCallback(() => {
|
||||
if (isUserMessage) {
|
||||
setEditingMessageId(messageId)
|
||||
}
|
||||
}, [isUserMessage, messageId, setEditingMessageId])
|
||||
|
||||
const handleSaveEdit = React.useCallback(
|
||||
(newContent: string) => {
|
||||
const messages = chatActions.getMessages()
|
||||
if (newContent.trim() !== messageContent.trim()) {
|
||||
// Find the message index and remove all messages after it (including AI responses)
|
||||
const messageIndex = messages.findIndex((msg) => msg.id === messageId)
|
||||
if (messageIndex !== -1) {
|
||||
const messagesToKeep = messages.slice(0, messageIndex)
|
||||
chatActions.setMessages(messagesToKeep)
|
||||
|
||||
// Send the edited message
|
||||
chatActions.sendMessage(newContent)
|
||||
const getMessageMarkdownFormat = React.useCallback(() => {
|
||||
let content = ""
|
||||
for (const part of message.parts) {
|
||||
let lexicalEditor: LexicalEditor | null = null
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
content += part.text
|
||||
break
|
||||
}
|
||||
case "data-rich-text": {
|
||||
lexicalEditor ||= createDefaultLexicalEditor()
|
||||
lexicalEditor.setEditorState(
|
||||
lexicalEditor.parseEditorState((part as RichTextPart).data.state),
|
||||
)
|
||||
content += convertLexicalToMarkdown(lexicalEditor)
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
if (part.type.startsWith("tool-")) {
|
||||
content += `\n\n[TOOL CALL: ${part.type.replace("tool-", "")}]\n\n`
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
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)
|
||||
},
|
||||
[messageContent, messageId, chatActions, setEditingMessageId],
|
||||
[chatActions, messageId, setEditingMessageId],
|
||||
)
|
||||
|
||||
const handleCancelEdit = React.useCallback(() => {
|
||||
|
|
@ -76,13 +108,14 @@ export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message
|
|||
}, [setEditingMessageId])
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
const messageContent = getMessageMarkdownFormat()
|
||||
try {
|
||||
await copyToClipboard(messageContent)
|
||||
toast.success("Message copied to clipboard")
|
||||
} catch {
|
||||
toast.error("Failed to copy message")
|
||||
}
|
||||
}, [messageContent])
|
||||
}, [getMessageMarkdownFormat])
|
||||
|
||||
const handleRetry = React.useCallback(() => {
|
||||
chatActions.regenerate({ messageId })
|
||||
|
|
@ -115,7 +148,7 @@ export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message
|
|||
{isEditing && isUserMessage ? (
|
||||
<EditableMessage
|
||||
messageId={messageId}
|
||||
initialContent={messageContent}
|
||||
parts={message.parts}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
import { defaultLexicalTheme } from "@follow/components/ui/lexical-rich-editor/index.js"
|
||||
import { LexicalRichEditorNodes } from "@follow/components/ui/lexical-rich-editor/nodes.js"
|
||||
import { defaultLexicalTheme } from "@follow/components/ui/lexical-rich-editor/theme.js"
|
||||
import { cn } from "@follow/utils"
|
||||
import { CodeHighlightNode, CodeNode } from "@lexical/code"
|
||||
import { LinkNode } from "@lexical/link"
|
||||
import { ListItemNode, ListNode } from "@lexical/list"
|
||||
import { MarkNode } from "@lexical/mark"
|
||||
import type { InitialConfigType } from "@lexical/react/LexicalComposer"
|
||||
import { LexicalComposer } from "@lexical/react/LexicalComposer"
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
|
||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
|
||||
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
|
||||
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
|
||||
import type { SerializedEditorState } from "lexical"
|
||||
import { ParagraphNode, TextNode } from "lexical"
|
||||
import * as React from "react"
|
||||
|
||||
function onError(error: Error) {
|
||||
|
|
@ -26,49 +21,29 @@ interface AIRichTextMessageProps {
|
|||
className?: string
|
||||
}
|
||||
|
||||
export const AIRichTextMessage: React.FC<AIRichTextMessageProps> = ({ data, className }) => {
|
||||
const initialConfig: InitialConfigType = {
|
||||
namespace: "AIRichTextDisplay",
|
||||
theme: defaultLexicalTheme,
|
||||
onError,
|
||||
editable: false, // Read-only mode
|
||||
editorState: JSON.stringify(data.state),
|
||||
nodes: [
|
||||
// Core nodes
|
||||
ParagraphNode,
|
||||
TextNode,
|
||||
export const AIRichTextMessage: React.FC<AIRichTextMessageProps> = React.memo(
|
||||
({ data, className }) => {
|
||||
const initialConfig: InitialConfigType = {
|
||||
namespace: "AIRichTextDisplay",
|
||||
theme: defaultLexicalTheme,
|
||||
onError,
|
||||
editable: false, // Read-only mode
|
||||
editorState: JSON.stringify(data.state),
|
||||
nodes: LexicalRichEditorNodes,
|
||||
}
|
||||
|
||||
// Rich text nodes
|
||||
HeadingNode,
|
||||
QuoteNode,
|
||||
|
||||
// List nodes
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
|
||||
// Code nodes
|
||||
CodeNode,
|
||||
CodeHighlightNode,
|
||||
|
||||
// Link nodes
|
||||
LinkNode,
|
||||
|
||||
// Text format nodes
|
||||
MarkNode,
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("text-text relative text-sm", className)}>
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable className="focus:outline-none" style={{ outline: "none" }} />
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
placeholder={null}
|
||||
/>
|
||||
</LexicalComposer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn("text-text relative text-sm", className)}>
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable className="focus:outline-none" style={{ outline: "none" }} />
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
placeholder={null}
|
||||
/>
|
||||
</LexicalComposer>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,29 @@
|
|||
import { useInputComposition } from "@follow/hooks"
|
||||
import type { LexicalRichEditorRef } from "@follow/components/ui/lexical-rich-editor/index.js"
|
||||
import {
|
||||
createDefaultLexicalEditor,
|
||||
LexicalRichEditor,
|
||||
} from "@follow/components/ui/lexical-rich-editor/index.js"
|
||||
import { cn } from "@follow/utils"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { BizUIMessage } from "@folo-services/ai-tools"
|
||||
import { isEqual } from "es-toolkit"
|
||||
import type { EditorState, LexicalEditor, SerializedEditorState } from "lexical"
|
||||
import { $getRoot } from "lexical"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { useChatStatus } from "~/modules/ai/chat/__internal__/hooks"
|
||||
import { useEditingMessageId, useSetEditingMessageId } from "~/modules/ai/chat/atoms/session"
|
||||
|
||||
interface EditableMessageProps {
|
||||
messageId: string
|
||||
initialContent: string
|
||||
onSave: (content: string) => void
|
||||
parts: BizUIMessage["parts"]
|
||||
onSave: (content: SerializedEditorState, editor: LexicalEditor) => void
|
||||
onCancel: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const EditableMessage = ({
|
||||
messageId,
|
||||
initialContent,
|
||||
parts,
|
||||
onSave,
|
||||
onCancel,
|
||||
className,
|
||||
|
|
@ -23,72 +31,69 @@ export const EditableMessage = ({
|
|||
const status = useChatStatus()
|
||||
const editingMessageId = useEditingMessageId()
|
||||
const setEditingMessageId = useSetEditingMessageId()
|
||||
const [content, setContent] = useState(initialContent)
|
||||
const [isEmpty, setIsEmpty] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const editorRef = useRef<LexicalRichEditorRef>(null)
|
||||
const [currentEditor, setCurrentEditor] = useState<LexicalEditor | null>(null)
|
||||
|
||||
const initialEditorState = useMemo(() => {
|
||||
const serializedEditorState = (parts.find((part) => part.type === "data-rich-text") as any)
|
||||
?.data.state as SerializedEditorState
|
||||
return createDefaultLexicalEditor().parseEditorState(serializedEditorState)
|
||||
}, [parts])
|
||||
const isEditing = editingMessageId === messageId
|
||||
const isProcessing = status === "submitted" || status === "streaming"
|
||||
|
||||
// Auto-resize textarea and maintain minimum height to prevent CLS
|
||||
// Initialize editor with initial content
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && isEditing) {
|
||||
const textarea = textareaRef.current
|
||||
textarea.style.height = "auto"
|
||||
const newHeight = Math.max(56, textarea.scrollHeight) // Minimum height of 56px
|
||||
textarea.style.height = `${newHeight}px`
|
||||
if (isEditing && editorRef.current && currentEditor) {
|
||||
// Focus the editor
|
||||
editorRef.current.focus()
|
||||
}
|
||||
}, [content, isEditing])
|
||||
|
||||
// Focus on edit start
|
||||
useEffect(() => {
|
||||
if (isEditing && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length,
|
||||
)
|
||||
}
|
||||
}, [isEditing])
|
||||
}, [isEditing, initialEditorState, currentEditor])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (content.trim() && content.trim() !== initialContent) {
|
||||
onSave(content.trim())
|
||||
if (currentEditor && editorRef.current && !editorRef.current.isEmpty()) {
|
||||
const serializedEditorState = currentEditor.getEditorState().toJSON()
|
||||
|
||||
if (!isEqual(serializedEditorState, initialEditorState.toJSON())) {
|
||||
onSave(serializedEditorState, currentEditor)
|
||||
}
|
||||
}
|
||||
setEditingMessageId(null)
|
||||
}, [content, initialContent, onSave, setEditingMessageId])
|
||||
}, [currentEditor, initialEditorState, onSave])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setContent(initialContent)
|
||||
setEditingMessageId(null)
|
||||
onCancel()
|
||||
}, [initialContent, onCancel, setEditingMessageId])
|
||||
}, [onCancel, setEditingMessageId])
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
if (!isProcessing) {
|
||||
handleSave()
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
return true
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
handleCancel()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[handleSave, handleCancel, isProcessing],
|
||||
)
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newContent = e.target.value
|
||||
setContent(newContent)
|
||||
setIsEmpty(newContent.trim() === "")
|
||||
const handleEditorChange = useCallback((editorState: EditorState, editor: LexicalEditor) => {
|
||||
setCurrentEditor(editor)
|
||||
// Update isEmpty state based on editor content
|
||||
editorState.read(() => {
|
||||
const root = $getRoot()
|
||||
const textContent = root.getTextContent().trim()
|
||||
setIsEmpty(textContent === "")
|
||||
})
|
||||
}, [])
|
||||
|
||||
const inputProps = useInputComposition<HTMLTextAreaElement>({
|
||||
onKeyDown: handleKeyPress,
|
||||
})
|
||||
|
||||
if (!isEditing) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -97,15 +102,14 @@ export const EditableMessage = ({
|
|||
<div className={cn("relative", className)}>
|
||||
{/* Edit input */}
|
||||
<div className="bg-background/60 focus-within:ring-accent/20 focus-within:border-accent/80 border-border/80 relative overflow-hidden rounded-xl border backdrop-blur-xl duration-200 focus-within:ring-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
{...inputProps}
|
||||
<LexicalRichEditor
|
||||
ref={editorRef}
|
||||
placeholder="Edit your message..."
|
||||
className="scrollbar-none text-text placeholder:text-text-secondary max-h-40 min-h-14 w-full resize-none bg-transparent px-4 py-3 pr-20 text-sm !outline-none transition-all duration-200"
|
||||
rows={1}
|
||||
disabled={isProcessing}
|
||||
initalEditorState={initialEditorState}
|
||||
className="w-full pr-20"
|
||||
onChange={handleEditorChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
namespace="EditableMessageRichEditor"
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
|
|
@ -122,7 +126,7 @@ export const EditableMessage = ({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isProcessing || isEmpty || content.trim() === initialContent}
|
||||
disabled={isProcessing || isEmpty}
|
||||
className="text-accent hover:text-accent hover:bg-accent/10 flex size-8 items-center justify-center rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Save (Enter)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import type { SerializedEditorState } from "lexical"
|
||||
|
||||
export interface ChatSession {
|
||||
chatId: string
|
||||
title?: string
|
||||
createdAt: Date
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export type RichTextPart = {
|
||||
type: "data-rich-text"
|
||||
data: {
|
||||
state: SerializedEditorState
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { cn } from "@follow/utils"
|
||||
import { CodeHighlightNode, CodeNode } from "@lexical/code"
|
||||
import { LinkNode } from "@lexical/link"
|
||||
import { ListItemNode, ListNode } from "@lexical/list"
|
||||
import { MarkNode } from "@lexical/mark"
|
||||
import { TRANSFORMERS } from "@lexical/markdown"
|
||||
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"
|
||||
import type { InitialConfigType } from "@lexical/react/LexicalComposer"
|
||||
import { LexicalComposer } from "@lexical/react/LexicalComposer"
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
|
||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
|
||||
|
|
@ -14,11 +11,11 @@ import { ListPlugin } from "@lexical/react/LexicalListPlugin"
|
|||
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"
|
||||
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
|
||||
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
|
||||
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
|
||||
import type { EditorState, LexicalEditor } from "lexical"
|
||||
import { $getRoot, ParagraphNode, TextNode } from "lexical"
|
||||
import { $getRoot } from "lexical"
|
||||
import { useImperativeHandle, useRef, useState } from "react"
|
||||
|
||||
import { LexicalRichEditorNodes } from "./nodes"
|
||||
import { KeyboardPlugin } from "./plugins"
|
||||
import { defaultLexicalTheme } from "./theme"
|
||||
import type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
|
||||
|
|
@ -26,6 +23,13 @@ import type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
|
|||
function onError(error: Error) {
|
||||
console.error("Lexical Editor Error:", error)
|
||||
}
|
||||
const defaultEnabledPlugins = {
|
||||
history: true,
|
||||
markdown: true,
|
||||
list: true,
|
||||
link: true,
|
||||
autoFocus: true,
|
||||
}
|
||||
|
||||
export const LexicalRichEditor = ({
|
||||
ref,
|
||||
|
|
@ -36,44 +40,18 @@ export const LexicalRichEditor = ({
|
|||
autoFocus = false,
|
||||
namespace = "LexicalRichEditor",
|
||||
theme = defaultLexicalTheme,
|
||||
enabledPlugins = {
|
||||
history: true,
|
||||
markdown: true,
|
||||
list: true,
|
||||
link: true,
|
||||
autoFocus: true,
|
||||
},
|
||||
enabledPlugins = defaultEnabledPlugins,
|
||||
initalEditorState,
|
||||
}: LexicalRichEditorProps & { ref?: React.RefObject<LexicalRichEditorRef | null> }) => {
|
||||
const editorRef = useRef<LexicalEditor | null>(null)
|
||||
const [isEmpty, setIsEmpty] = useState(true)
|
||||
|
||||
const initialConfig = {
|
||||
const initialConfig: InitialConfigType = {
|
||||
namespace,
|
||||
theme,
|
||||
onError,
|
||||
nodes: [
|
||||
// Core nodes
|
||||
ParagraphNode,
|
||||
TextNode,
|
||||
|
||||
// Rich text nodes
|
||||
HeadingNode, // For HEADING transformer
|
||||
QuoteNode, // For QUOTE transformer
|
||||
|
||||
// List nodes
|
||||
ListNode, // For UNORDERED_LIST, ORDERED_LIST transformers
|
||||
ListItemNode,
|
||||
|
||||
// Code nodes
|
||||
CodeNode, // For CODE transformer (multiline)
|
||||
CodeHighlightNode, // For code syntax highlighting
|
||||
|
||||
// Link nodes
|
||||
LinkNode, // For LINK transformer
|
||||
|
||||
// Text format nodes
|
||||
MarkNode, // For HIGHLIGHT transformer
|
||||
],
|
||||
nodes: LexicalRichEditorNodes,
|
||||
editorState: initalEditorState,
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import type { CreateEditorArgs } from "lexical"
|
||||
import { createEditor } from "lexical"
|
||||
|
||||
import { LexicalRichEditorNodes } from "./nodes"
|
||||
import { defaultLexicalTheme } from "./theme"
|
||||
|
||||
export const createLexicalEditor = (options: CreateEditorArgs) => {
|
||||
const editor = createEditor({
|
||||
theme: defaultLexicalTheme,
|
||||
nodes: LexicalRichEditorNodes,
|
||||
...options,
|
||||
})
|
||||
return editor
|
||||
}
|
||||
|
||||
export const createDefaultLexicalEditor = () => {
|
||||
return createLexicalEditor({
|
||||
namespace: "LexicalRichEditor",
|
||||
theme: defaultLexicalTheme,
|
||||
nodes: LexicalRichEditorNodes,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export { createDefaultLexicalEditor, createLexicalEditor } from "./editor"
|
||||
export { LexicalRichEditor } from "./LexicalRichEditor"
|
||||
export { LexicalRichEditorNodes } from "./nodes"
|
||||
export { KeyboardPlugin } from "./plugins"
|
||||
export { defaultLexicalTheme } from "./theme"
|
||||
export type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { CodeHighlightNode, CodeNode } from "@lexical/code"
|
||||
import { LinkNode } from "@lexical/link"
|
||||
import { ListItemNode, ListNode } from "@lexical/list"
|
||||
import { MarkNode } from "@lexical/mark"
|
||||
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
|
||||
import { ParagraphNode, TextNode } from "lexical"
|
||||
|
||||
export const LexicalRichEditorNodes = [
|
||||
// Core nodes
|
||||
ParagraphNode,
|
||||
TextNode,
|
||||
|
||||
// Rich text nodes
|
||||
HeadingNode, // For HEADING transformer
|
||||
QuoteNode, // For QUOTE transformer
|
||||
|
||||
// List nodes
|
||||
ListNode, // For UNORDERED_LIST, ORDERED_LIST transformers
|
||||
ListItemNode,
|
||||
|
||||
// Code nodes
|
||||
CodeNode, // For CODE transformer (multiline)
|
||||
CodeHighlightNode, // For code syntax highlighting
|
||||
|
||||
// Link nodes
|
||||
LinkNode, // For LINK transformer
|
||||
|
||||
// Text format nodes
|
||||
MarkNode, // For HIGHLIGHT transformer
|
||||
]
|
||||
|
|
@ -22,4 +22,5 @@ export interface LexicalRichEditorProps {
|
|||
link?: boolean
|
||||
autoFocus?: boolean
|
||||
}
|
||||
initalEditorState?: EditorState
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue