Files
codeg/src/components/message/message-list-view.tsx
2026-03-10 20:02:59 +08:00

398 lines
11 KiB
TypeScript

"use client"
import { memo, useCallback, useEffect, useMemo, useRef } from "react"
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
import { ContentPartsRenderer } from "./content-parts-renderer"
import {
adaptMessageTurns,
type AdaptedContentPart,
type MessageGroup,
type UserImageDisplay,
type UserResourceDisplay,
groupAdaptedMessages,
extractUserResourcesFromText,
} from "@/lib/adapters/ai-elements-adapter"
import { TurnStats } from "./turn-stats"
import { LiveTurnStats } from "./live-turn-stats"
import { UserResourceLinks } from "./user-resource-links"
import { UserImageAttachments } from "./user-image-attachments"
import { useSessionStats } from "@/contexts/session-stats-context"
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
import { MessageThread } from "@/components/ai-elements/message-thread"
import { Message, MessageContent } from "@/components/ai-elements/message"
import { Loader2 } from "lucide-react"
import { useTranslations } from "next-intl"
import {
buildPlanKey,
extractLatestPlanEntriesFromMessages,
} from "@/lib/agent-plan"
import type { ConnectionStatus } from "@/lib/types"
import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"
import { useStickToBottomContext } from "use-stick-to-bottom"
interface MessageListViewProps {
conversationId: number
connStatus?: ConnectionStatus | null
isActive?: boolean
sendSignal?: number
}
interface ResolvedMessageGroup extends MessageGroup {
parts: AdaptedContentPart[]
resources: UserResourceDisplay[]
images: UserImageDisplay[]
}
type ThreadRenderItem =
| {
key: string
kind: "turn"
group: ResolvedMessageGroup
phase: "persisted" | "optimistic" | "streaming"
}
| {
key: string
kind: "typing"
}
function fallbackExtractUserResources(
group: MessageGroup,
attachedResourcesText: string
): {
parts: AdaptedContentPart[]
resources: UserResourceDisplay[]
images: UserImageDisplay[]
} {
if (group.role !== "user") {
return {
parts: group.parts,
resources: group.userResources ?? [],
images: group.userImages ?? [],
}
}
const parsedResources: UserResourceDisplay[] = []
const parsedParts: AdaptedContentPart[] = []
for (const part of group.parts) {
if (part.type !== "text") {
parsedParts.push(part)
continue
}
const extracted = extractUserResourcesFromText(part.text)
if (extracted.resources.length > 0) {
parsedResources.push(...extracted.resources)
if (extracted.text.length > 0) {
parsedParts.push({ type: "text", text: extracted.text })
}
} else {
parsedParts.push(part)
}
}
const resources = [...(group.userResources ?? []), ...parsedResources]
const dedupedResources: UserResourceDisplay[] = []
const seen = new Set<string>()
for (const resource of resources) {
const key = `${resource.name}::${resource.uri}`
if (seen.has(key)) continue
seen.add(key)
dedupedResources.push(resource)
}
if (parsedParts.length === 0 && dedupedResources.length > 0) {
parsedParts.push({ type: "text", text: attachedResourcesText })
}
return {
parts: parsedParts,
resources: dedupedResources,
images: group.userImages ?? [],
}
}
function resolveMessageGroup(
group: MessageGroup,
attachedResourcesText: string
): ResolvedMessageGroup {
const resolved = fallbackExtractUserResources(group, attachedResourcesText)
return {
...group,
parts: resolved.parts,
resources: resolved.resources,
images: resolved.images,
}
}
const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
group,
dimmed = false,
}: {
group: ResolvedMessageGroup
dimmed?: boolean
}) {
return (
<div className={dimmed ? "opacity-70" : undefined}>
<Message from={group.role}>
{group.role === "user" && group.images.length > 0 ? (
<UserImageAttachments images={group.images} className="self-end" />
) : null}
<MessageContent>
<ContentPartsRenderer parts={group.parts} role={group.role} />
</MessageContent>
{group.role === "user" && group.resources.length > 0 ? (
<UserResourceLinks resources={group.resources} className="self-end" />
) : null}
</Message>
{group.role === "assistant" && (
<TurnStats
usage={group.usage}
duration_ms={group.duration_ms}
model={group.model}
models={group.models}
/>
)}
</div>
)
})
const PendingTypingIndicator = memo(function PendingTypingIndicator() {
return (
<Message from="assistant">
<MessageContent>
<div className="flex items-center gap-1.5 py-1">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_infinite]" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_0.2s_infinite]" />
<span className="inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60 animate-[pulse_1.4s_ease-in-out_0.4s_infinite]" />
</div>
</MessageContent>
</Message>
)
})
const AutoScrollOnSend = memo(function AutoScrollOnSend({
signal,
enabled,
}: {
signal: number
enabled: boolean
}) {
const { scrollToBottom } = useStickToBottomContext()
const lastSignalRef = useRef(signal)
useEffect(() => {
if (!enabled) return
if (signal === lastSignalRef.current) return
lastSignalRef.current = signal
scrollToBottom()
const rafId = requestAnimationFrame(() => {
scrollToBottom()
})
return () => {
cancelAnimationFrame(rafId)
}
}, [enabled, scrollToBottom, signal])
return null
})
export function MessageListView({
conversationId,
connStatus,
isActive = true,
sendSignal = 0,
}: MessageListViewProps) {
const t = useTranslations("Folder.chat.messageList")
const sharedT = useTranslations("Folder.chat.shared")
const { detail, loading, error } = useDbMessageDetail(conversationId)
const { getSession, getTimelineTurns } = useConversationRuntime()
const session = getSession(conversationId)
const liveMessage = session?.liveMessage ?? null
const timelineTurns = getTimelineTurns(conversationId)
const { setSessionStats } = useSessionStats()
const sessionStats = detail?.session_stats ?? null
useEffect(() => {
if (isActive) {
setSessionStats(sessionStats)
}
}, [isActive, sessionStats, setSessionStats])
const shouldUseSmoothResize = !(isActive && !loading && timelineTurns.length)
const attachedResourcesText = sharedT("attachedResources")
const groupedTimeline = useMemo(
() =>
timelineTurns.reduce<
Array<{
phase: "persisted" | "optimistic" | "streaming"
turns: typeof timelineTurns
}>
>((acc, item) => {
const current = acc[acc.length - 1]
if (current && current.phase === item.phase) {
current.turns.push(item)
return acc
}
acc.push({
phase: item.phase,
turns: [item],
})
return acc
}, []),
[timelineTurns]
)
const threadItems = useMemo<ThreadRenderItem[]>(() => {
const items: ThreadRenderItem[] = []
for (
let chunkIndex = 0;
chunkIndex < groupedTimeline.length;
chunkIndex++
) {
const chunk = groupedTimeline[chunkIndex]
const adapted = adaptMessageTurns(
chunk.turns.map((item) => item.turn),
{
attachedResources: sharedT("attachedResources"),
toolCallFailed: sharedT("toolCallFailed"),
}
)
const groups = groupAdaptedMessages(adapted).map((group) =>
resolveMessageGroup(group, attachedResourcesText)
)
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
const group = groups[groupIndex]
items.push({
key: `${chunk.phase}-${chunkIndex}-${group.id}-${groupIndex}`,
kind: "turn",
group,
phase: chunk.phase,
})
}
}
const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null
if (connStatus === "prompting" && lastPhase === "optimistic") {
items.push({ key: "pending-typing", kind: "typing" })
}
return items
}, [
attachedResourcesText,
connStatus,
groupedTimeline,
sharedT,
timelineTurns,
])
const historicalMessages = useMemo(
() =>
adaptMessageTurns(
timelineTurns
.filter((item) => item.phase !== "streaming")
.map((item) => item.turn),
{
attachedResources: sharedT("attachedResources"),
toolCallFailed: sharedT("toolCallFailed"),
}
),
[sharedT, timelineTurns]
)
const historicalPlanEntries = useMemo(
() => extractLatestPlanEntriesFromMessages(historicalMessages),
[historicalMessages]
)
const historicalPlanKey = useMemo(
() => buildPlanKey(historicalPlanEntries),
[historicalPlanEntries]
)
const renderThreadItem = useCallback((item: ThreadRenderItem) => {
switch (item.kind) {
case "turn":
return (
<HistoricalMessageGroup
group={item.group}
dimmed={item.phase === "optimistic"}
/>
)
case "typing":
return <PendingTypingIndicator />
default:
return null
}
}, [])
const emptyState = useMemo(
() => (
<div className="px-4 py-12 text-center">
<p className="text-muted-foreground text-sm">
{t("emptyConversation")}
</p>
</div>
),
[t]
)
const agentPlanOverlayKey = liveMessage?.id ?? `history-${conversationId}`
const hasRenderableContent = threadItems.length > 0 || Boolean(liveMessage)
if (loading && !hasRenderableContent) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t("loading")}</span>
</div>
</div>
)
}
if (error && !hasRenderableContent) {
return (
<div className="p-6">
<div className="text-center py-12">
<p className="text-destructive text-sm">
{t("error", { message: error })}
</p>
</div>
</div>
)
}
return (
<div className="relative flex h-full min-h-0 flex-col">
<MessageThread
className="flex-1 min-h-0"
resize={shouldUseSmoothResize ? "smooth" : undefined}
>
<AutoScrollOnSend signal={sendSignal} enabled={isActive} />
<VirtualizedMessageThread
items={threadItems}
getItemKey={(item) => item.key}
renderItem={renderThreadItem}
emptyState={emptyState}
estimateSize={180}
overscan={10}
/>
</MessageThread>
{liveMessage && connStatus === "prompting" && (
<LiveTurnStats
message={liveMessage}
isStreaming={connStatus === "prompting"}
/>
)}
<AgentPlanOverlay
key={agentPlanOverlayKey}
message={liveMessage ?? null}
entries={historicalPlanEntries}
planKey={historicalPlanKey}
defaultExpanded={connStatus === "prompting"}
/>
</div>
)
}