refactor: reuse context bar blocks for AI chat transcripts

This commit is contained in:
DIYgod 2025-10-20 13:58:06 +08:00
parent 2c6f947cd4
commit 231b2ce083
No known key found for this signature in database
4 changed files with 70 additions and 299 deletions

View File

@ -17,13 +17,14 @@ const BlockContainer: FC<{
disabled?: boolean
onDisableClick?: () => void
content: ReactNode
}> = memo(({ icon, label, onRemove, content, disabled, onDisableClick }) => {
readOnly?: boolean
}> = memo(({ icon, label, onRemove, content, disabled, onDisableClick, readOnly }) => {
const isStringContent = typeof content === "string"
return (
<div
className={clsx(
"group relative flex h-7 min-w-0 max-w-[calc(50%-0.5rem)] flex-shrink-0 items-center gap-2 overflow-hidden rounded-lg px-2.5",
"group relative flex h-7 min-w-0 flex-shrink-0 items-center gap-2 overflow-hidden rounded-lg px-2.5",
"bg-fill-tertiary border-border border",
disabled && "cursor-pointer border-dashed opacity-50",
)}
@ -36,7 +37,8 @@ const BlockContainer: FC<{
<div
className={clsx(
"min-w-0",
!disabled &&
!readOnly &&
!disabled &&
"group-hover:[mask-image:linear-gradient(to_right,black_0%,black_calc(100%-3rem),rgba(0,0,0,0.8)_calc(100%-2rem),rgba(0,0,0,0.3)_calc(100%-1rem),transparent_100%)]",
)}
>
@ -54,7 +56,7 @@ const BlockContainer: FC<{
</div>
</div>
{onRemove && !disabled && (
{onRemove && !disabled && !readOnly && (
<button
type="button"
onClick={onRemove}
@ -76,7 +78,8 @@ export const CombinedContextBlock: FC<{
viewBlock?: MainViewBlock
feedBlock?: MainFeedBlock
unreadOnlyBlock?: UnreadOnlyBlock
}> = memo(({ viewBlock, feedBlock, unreadOnlyBlock }) => {
readOnly?: boolean
}> = memo(({ viewBlock, feedBlock, unreadOnlyBlock, readOnly = false }) => {
const { t } = useTranslation("common")
const blockActions = useChatBlockActions()
@ -89,11 +92,16 @@ export const CombinedContextBlock: FC<{
unreadOnlyBlock && blockActions.removeBlock(unreadOnlyBlock.id)
}
const handleEnable = () => {
viewBlock && blockActions.toggleBlockDisabled(viewBlock.id, false)
feedBlock && blockActions.toggleBlockDisabled(feedBlock.id, false)
}
// Determine what to display
const displayContent = feedBlock ? (
<span className="flex items-center gap-1">
<FeedTitle feedId={feedBlock.value} fallback={feedBlock.value} />
{unreadOnlyBlock && <i className="i-mgc-round-cute-fi size-3" title="Unread Only" />}
<FeedTitle feedId={feedBlock.value} fallback={feedBlock.value} className="min-w-0 truncate" />
{unreadOnlyBlock && <i className="i-mgc-round-cute-fi size-3 shrink-0" title="Unread Only" />}
</span>
) : (
<span className="flex items-center gap-1">
@ -109,41 +117,42 @@ export const CombinedContextBlock: FC<{
return (
<BlockContainer
icon={viewIcon || feedIcon}
disabled={viewBlock?.disabled || feedBlock?.disabled}
onRemove={handleRemove}
onDisableClick={() => {
viewBlock && blockActions.toggleBlockDisabled(viewBlock.id, false)
feedBlock && blockActions.toggleBlockDisabled(feedBlock.id, false)
}}
disabled={viewBlock?.disabled || feedBlock?.disabled || unreadOnlyBlock?.disabled}
onRemove={!readOnly ? handleRemove : undefined}
onDisableClick={!readOnly ? handleEnable : undefined}
content={displayContent}
readOnly={readOnly}
/>
)
})
CombinedContextBlock.displayName = "CombinedContextBlock"
export const ContextBlock: FC<{ block: AIChatContextBlock }> = memo(({ block }) => {
const blockActions = useChatBlockActions()
export const ContextBlock: FC<{ block: AIChatContextBlock; readOnly?: boolean }> = memo(
({ block, readOnly }) => {
const blockActions = useChatBlockActions()
const { icon, label, displayContent } = useContextBlockPresentation(block)
const { icon, label, displayContent } = useContextBlockPresentation(block)
return (
<BlockContainer
icon={icon}
label={label}
disabled={block.disabled}
onRemove={() => {
if (block.type === "mainEntry") {
blockActions.toggleBlockDisabled(block.id, true)
} else {
blockActions.removeBlock(block.id)
}
}}
onDisableClick={() => {
if (block.type === "mainEntry") {
blockActions.toggleBlockDisabled(block.id, false)
}
}}
content={displayContent}
/>
)
})
return (
<BlockContainer
icon={icon}
label={label}
disabled={block.disabled}
onRemove={() => {
if (block.type === "mainEntry") {
blockActions.toggleBlockDisabled(block.id, true)
} else {
blockActions.removeBlock(block.id)
}
}}
onDisableClick={() => {
if (block.type === "mainEntry") {
blockActions.toggleBlockDisabled(block.id, false)
}
}}
content={displayContent}
readOnly={readOnly}
/>
)
},
)

View File

@ -14,7 +14,11 @@ export const EntryTitle: FC<{ entryId?: string; fallback: string }> = ({ entryId
return <span>{entryTitle}</span>
}
export const FeedTitle: FC<{ feedId?: string; fallback: string }> = ({ feedId, fallback }) => {
export const FeedTitle: FC<{ feedId?: string; fallback: string; className?: string }> = ({
feedId,
fallback,
className,
}) => {
const category = feedId?.startsWith(ROUTE_FEED_IN_FOLDER)
? feedId.slice(ROUTE_FEED_IN_FOLDER.length)
: undefined
@ -24,11 +28,11 @@ export const FeedTitle: FC<{ feedId?: string; fallback: string }> = ({ feedId, f
if (!feedId || !feedTitles) {
if (category) {
return <span>{category}</span>
return <span className={className}>{category}</span>
}
return <span className="text-text-tertiary">{fallback}</span>
return <span className={`text-text-tertiary ${className}`}>{fallback}</span>
}
return <span>{feedTitles}</span>
return <span className={className}>{feedTitles}</span>
}

View File

@ -1,227 +0,0 @@
import { getView } from "@follow/constants"
import { cn } from "@follow/utils/utils"
import { t } from "i18next"
import * as React from "react"
import type { AIChatContextBlock, ValueContextBlock } from "~/modules/ai-chat/store/types"
import { getBlockStyles } from "./ai-block-constants"
import { FeedTitle } from "./BlockTitleComponents"
import { ImageThumbnail } from "./ImageThumbnail"
import { useContextBlockPresentation } from "./useContextBlockPresentation"
interface AIDataBlockItemProps {
block: AIChatContextBlock
index: number
}
type ValueBlockOf<Type extends ValueContextBlock["type"]> = Omit<ValueContextBlock, "type"> & {
type: Type
}
interface CombinedDataBlockItemProps {
viewBlock?: ValueBlockOf<"mainView">
feedBlock?: ValueBlockOf<"mainFeed">
unreadOnlyBlock?: ValueBlockOf<"unreadOnly">
}
/**
* Gets the display content for a context block
*/
/**
* Renders the appropriate icon or image thumbnail for a block
*/
const BlockIcon: React.FC<{
block: AIChatContextBlock
styles: ReturnType<typeof getBlockStyles>
presentation: ReturnType<typeof useContextBlockPresentation>
}> = React.memo(({ block, styles, presentation }) => {
if (
block.type === "fileAttachment" &&
presentation.attachment &&
presentation.isImageAttachment
) {
return (
<div
className={cn("flex size-4 flex-shrink-0 items-center justify-center rounded", styles.icon)}
>
<ImageThumbnail attachment={presentation.attachment} />
</div>
)
}
const iconClass = presentation.icon
if (!iconClass) {
return null
}
return (
<div
className={cn("flex size-4 flex-shrink-0 items-center justify-center rounded", styles.icon)}
>
<i className={cn("size-2.5", iconClass)} />
</div>
)
})
BlockIcon.displayName = "BlockIcon"
/**
* Shared block container component to ensure consistency
*/
const BlockContainer: React.FC<{
styles: ReturnType<typeof getBlockStyles>
block: AIChatContextBlock
label?: string
displayContent: React.ReactNode
title?: string
presentation: ReturnType<typeof useContextBlockPresentation>
}> = React.memo(({ styles, block, label, displayContent, title, presentation }) => {
return (
<div
key={block.id}
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5",
"border bg-gradient-to-r backdrop-blur-sm",
styles.container,
)}
>
<BlockIcon block={block} styles={styles} presentation={presentation} />
{/* Label and content */}
<div className="flex min-w-0 items-center gap-1">
{label && (
<>
<span className={cn("text-xs font-medium", styles.label)}>{label}</span>
<span className="text-text-secondary text-xs">·</span>
</>
)}
<span
className="text-text max-w-24 truncate text-xs font-medium"
title={title || (typeof displayContent === "string" ? displayContent : undefined)}
>
{displayContent}
</span>
</div>
</div>
)
})
BlockContainer.displayName = "BlockContainer"
/**
* Individual block item component with optimized rendering and animations
*/
export const AIDataBlockItem: React.FC<AIDataBlockItemProps> = React.memo(({ block }) => {
const styles = React.useMemo(() => getBlockStyles(block.type), [block.type])
const presentation = useContextBlockPresentation(block)
return (
<BlockContainer
styles={styles}
block={block}
label={presentation.label}
displayContent={presentation.displayContent}
title={presentation.title}
presentation={presentation}
/>
)
})
AIDataBlockItem.displayName = "AIDataBlockItem"
/**
* Combined block item component for main view with optional feed and unread filter
* Displays view icon with appropriate content based on available blocks
*/
export const CombinedDataBlockItem: React.FC<CombinedDataBlockItemProps> = React.memo(
({ viewBlock, feedBlock, unreadOnlyBlock }) => {
const { styles, displayContent, title, block } = React.useMemo(() => {
const unreadOnlyText = unreadOnlyBlock ? " (Unread Only)" : ""
const unreadIcon = unreadOnlyBlock && (
<i className="i-mgc-round-cute-fi size-3" title="Unread Only" />
)
// Helper function to create content with optional unread indicator
const createContent = (mainContent: React.ReactNode) => (
<span className="flex items-center gap-1">
{mainContent}
{unreadIcon}
</span>
)
// Handle case when viewBlock is null/undefined
if (!viewBlock) {
// Prioritize feedBlock if available
if (feedBlock) {
return {
styles: getBlockStyles("mainFeed"),
displayContent: createContent(
<FeedTitle feedId={feedBlock.value} fallback={feedBlock.value} />,
),
title: `${feedBlock.value}${unreadOnlyText}`,
block: feedBlock,
}
}
// Handle unreadOnly-only case
if (unreadOnlyBlock) {
return {
styles: getBlockStyles("unreadOnly"),
displayContent: createContent("Unread Only"),
title: "Unread Only",
block: unreadOnlyBlock,
}
}
// Fallback case
const fallbackBlock = { id: "fallback", type: "mainView" as const, value: "" }
return {
styles: getBlockStyles("mainView"),
displayContent: createContent("No context"),
title: "No context",
block: fallbackBlock,
}
}
// Handle case when viewBlock exists
const view = getView(Number(viewBlock.value))
const viewName = view?.name ? t(view.name, { ns: "common" }) : viewBlock.value
// Determine primary content: feedBlock takes precedence over viewBlock
const primaryContent = feedBlock ? (
<FeedTitle feedId={feedBlock.value} fallback={feedBlock.value} />
) : (
viewName
)
const titleText = feedBlock
? `${viewName} - ${feedBlock.value}${unreadOnlyText}`
: `${viewName}${unreadOnlyText}`
return {
styles: getBlockStyles("mainView"),
displayContent: createContent(primaryContent),
title: titleText,
block: viewBlock,
}
}, [viewBlock, feedBlock, unreadOnlyBlock])
const presentation = useContextBlockPresentation(block)
return (
<BlockContainer
styles={styles}
block={block}
label={presentation.label}
displayContent={displayContent}
title={title}
presentation={presentation}
/>
)
},
)
CombinedDataBlockItem.displayName = "CombinedDataBlockItem"

View File

@ -1,11 +1,9 @@
import { cn } from "@follow/utils/utils"
import * as React from "react"
import { CombinedContextBlock, ContextBlock } from "~/modules/ai-chat/components/context-bar/blocks"
import { useDisplayBlocks } from "~/modules/ai-chat/hooks/useDisplayBlocks"
import type { AIChatContextBlock } from "~/modules/ai-chat/store/types"
import { AIDataBlockItem, CombinedDataBlockItem } from "./AIDataBlockItem"
interface AIDataBlockPartProps {
blocks: AIChatContextBlock[]
}
@ -23,35 +21,22 @@ export const AIDataBlockPart: React.FC<AIDataBlockPartProps> = React.memo(({ blo
}
return (
<div className="min-w-0 max-w-full text-left">
<div
className={cn(
"inline-flex flex-wrap items-center gap-1.5 rounded-lg py-1 pl-2 pr-1",
"bg-fill-secondary border-border/50 border",
)}
>
{/* Compact context indicator */}
<div className="text-text-secondary flex items-center gap-1">
<i className="i-mgc-link-cute-re size-3" />
<span className="text-xs">Context:</span>
</div>
<div className="flex items-center gap-2">
{displayBlocks.map((item) => {
if (item.kind === "combined") {
return (
<CombinedContextBlock
key={`combined-${item.viewBlock?.id}-${item.feedBlock?.id}-${item.unreadOnlyBlock?.id}`}
viewBlock={item.viewBlock}
feedBlock={item.feedBlock}
unreadOnlyBlock={item.unreadOnlyBlock}
readOnly
/>
)
}
{/* Render individual block items */}
{displayBlocks.map((item, index) => {
if (item.kind === "combined") {
return (
<CombinedDataBlockItem
key={`combined-${item.viewBlock?.id}-${item.feedBlock?.id}-${item.unreadOnlyBlock?.id}`}
viewBlock={item.viewBlock}
feedBlock={item.feedBlock}
unreadOnlyBlock={item.unreadOnlyBlock}
/>
)
}
return <AIDataBlockItem key={item.block.id} block={item.block} index={index} />
})}
</div>
return <ContextBlock key={item.block.id} block={item.block} readOnly />
})}
</div>
)
})