重构部分会话消息处理逻辑,优化会话消息渲染

This commit is contained in:
xintaofei
2026-03-10 23:24:27 +08:00
parent a048a4cae2
commit 380f430d5a
15 changed files with 92 additions and 568 deletions

View File

@@ -1,50 +0,0 @@
"use client"
import { memo, useMemo } from "react"
import { useTranslations } from "next-intl"
import type { LiveMessage } from "@/contexts/acp-connections-context"
import { ContentPartsRenderer } from "@/components/message/content-parts-renderer"
import { adaptLiveMessageFromAcp } from "@/lib/adapters/ai-elements-adapter"
import { Message, MessageContent } from "@/components/ai-elements/message"
interface LiveMessageBlockProps {
message: LiveMessage
isStreaming?: boolean
}
export const LiveMessageBlock = memo(function LiveMessageBlock({
message,
isStreaming = true,
}: LiveMessageBlockProps) {
const t = useTranslations("Folder.chat.liveMessageBlock")
const sharedT = useTranslations("Folder.chat.shared")
const hasContent = message.content.length > 0
const adapted = useMemo(
() =>
adaptLiveMessageFromAcp(message, {
isLiveStreaming: isStreaming,
toolCallFailedText: sharedT("toolCallFailed"),
planUpdatedText: sharedT("planUpdated"),
}),
[message, isStreaming, sharedT]
)
return (
<Message from="assistant">
<MessageContent>
{hasContent ? (
<ContentPartsRenderer parts={adapted.content} role="assistant" />
) : (
<div
className="flex items-center gap-1.5 text-muted-foreground py-1"
aria-label={t("assistantThinkingAria")}
>
<span className="h-2 w-2 rounded-full bg-primary animate-bounce [animation-delay:-0.3s]" />
<span className="h-2 w-2 rounded-full bg-primary animate-bounce [animation-delay:-0.15s]" />
<span className="h-2 w-2 rounded-full bg-primary animate-bounce" />
</div>
)}
</MessageContent>
</Message>
)
})

View File

@@ -540,6 +540,9 @@ const ConversationTabView = memo(function ConversationTabView({
connStatus={connStatus} connStatus={connStatus}
isActive={isActive} isActive={isActive}
sendSignal={sendSignal} sendSignal={sendSignal}
sessionStats={detail?.session_stats ?? null}
detailLoading={detailLoading}
detailError={detailError}
/> />
) )

View File

@@ -1,17 +1,14 @@
"use client" "use client"
import { memo, useCallback, useEffect, useMemo, useRef } from "react" import { memo, useCallback, useEffect, useMemo, useRef } from "react"
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
import { ContentPartsRenderer } from "./content-parts-renderer" import { ContentPartsRenderer } from "./content-parts-renderer"
import { import {
adaptMessageTurns, adaptMessageTurns,
type AdaptedContentPart,
type MessageGroup, type MessageGroup,
type UserImageDisplay, type UserImageDisplay,
type UserResourceDisplay, type UserResourceDisplay,
groupAdaptedMessages, groupAdaptedMessages,
extractUserResourcesFromText,
} from "@/lib/adapters/ai-elements-adapter" } from "@/lib/adapters/ai-elements-adapter"
import { TurnStats } from "./turn-stats" import { TurnStats } from "./turn-stats"
import { LiveTurnStats } from "./live-turn-stats" import { LiveTurnStats } from "./live-turn-stats"
@@ -27,7 +24,7 @@ import {
buildPlanKey, buildPlanKey,
extractLatestPlanEntriesFromMessages, extractLatestPlanEntriesFromMessages,
} from "@/lib/agent-plan" } from "@/lib/agent-plan"
import type { ConnectionStatus } from "@/lib/types" import type { ConnectionStatus, SessionStats } from "@/lib/types"
import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread" import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"
import { useStickToBottomContext } from "use-stick-to-bottom" import { useStickToBottomContext } from "use-stick-to-bottom"
@@ -36,10 +33,12 @@ interface MessageListViewProps {
connStatus?: ConnectionStatus | null connStatus?: ConnectionStatus | null
isActive?: boolean isActive?: boolean
sendSignal?: number sendSignal?: number
sessionStats?: SessionStats | null
detailLoading?: boolean
detailError?: string | null
} }
interface ResolvedMessageGroup extends MessageGroup { interface ResolvedMessageGroup extends MessageGroup {
parts: AdaptedContentPart[]
resources: UserResourceDisplay[] resources: UserResourceDisplay[]
images: UserImageDisplay[] images: UserImageDisplay[]
} }
@@ -56,75 +55,6 @@ type ThreadRenderItem =
kind: "typing" 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({ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
group, group,
dimmed = false, dimmed = false,
@@ -200,17 +130,18 @@ export function MessageListView({
connStatus, connStatus,
isActive = true, isActive = true,
sendSignal = 0, sendSignal = 0,
sessionStats = null,
detailLoading = false,
detailError = null,
}: MessageListViewProps) { }: MessageListViewProps) {
const t = useTranslations("Folder.chat.messageList") const t = useTranslations("Folder.chat.messageList")
const sharedT = useTranslations("Folder.chat.shared") const sharedT = useTranslations("Folder.chat.shared")
const { detail, loading, error } = useDbMessageDetail(conversationId)
const { getSession, getTimelineTurns } = useConversationRuntime() const { getSession, getTimelineTurns } = useConversationRuntime()
const session = getSession(conversationId) const session = getSession(conversationId)
const liveMessage = session?.liveMessage ?? null const liveMessage = session?.liveMessage ?? null
const timelineTurns = getTimelineTurns(conversationId) const timelineTurns = getTimelineTurns(conversationId)
const { setSessionStats } = useSessionStats() const { setSessionStats } = useSessionStats()
const sessionStats = detail?.session_stats ?? null
useEffect(() => { useEffect(() => {
if (isActive) { if (isActive) {
@@ -218,88 +149,71 @@ export function MessageListView({
} }
}, [isActive, sessionStats, setSessionStats]) }, [isActive, sessionStats, setSessionStats])
const shouldUseSmoothResize = !(isActive && !loading && timelineTurns.length) const shouldUseSmoothResize = !(
const attachedResourcesText = sharedT("attachedResources") isActive &&
!detailLoading &&
const groupedTimeline = useMemo( timelineTurns.length
() =>
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 adapterText = useMemo(
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"), attachedResources: sharedT("attachedResources"),
toolCallFailed: sharedT("toolCallFailed"), toolCallFailed: sharedT("toolCallFailed"),
}),
[sharedT]
)
const { threadItems, nonStreamingAdapted } = useMemo(() => {
const allTurns = timelineTurns.map((item) => item.turn)
const allAdapted = adaptMessageTurns(allTurns, adapterText)
// Collect non-streaming adapted messages for plan extraction
const nonStreaming = allAdapted.filter(
(_, index) => timelineTurns[index].phase !== "streaming"
)
// Group adapted messages per phase-chunk to prevent merging
// assistant turns across phase boundaries (e.g. persisted + streaming).
const items: ThreadRenderItem[] = []
let chunkStart = 0
while (chunkStart < allAdapted.length) {
const chunkPhase = timelineTurns[chunkStart].phase
let chunkEnd = chunkStart + 1
while (
chunkEnd < allAdapted.length &&
timelineTurns[chunkEnd].phase === chunkPhase
) {
chunkEnd++
} }
) const chunkAdapted = allAdapted.slice(chunkStart, chunkEnd)
const groups = groupAdaptedMessages(adapted).map((group) => const groups = groupAdaptedMessages(chunkAdapted)
resolveMessageGroup(group, attachedResourcesText) for (let i = 0; i < groups.length; i++) {
) const group = groups[i]
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
const group = groups[groupIndex]
items.push({ items.push({
key: `${chunk.phase}-${chunkIndex}-${group.id}-${groupIndex}`, key: `${chunkPhase}-${chunkStart}-${group.id}-${i}`,
kind: "turn", kind: "turn",
group, group: {
phase: chunk.phase, ...group,
resources: group.userResources ?? [],
images: group.userImages ?? [],
},
phase: chunkPhase,
}) })
} }
chunkStart = chunkEnd
} }
const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null
if (connStatus === "prompting" && lastPhase === "optimistic") { if (connStatus === "prompting" && lastPhase === "optimistic") {
items.push({ key: "pending-typing", kind: "typing" }) items.push({ key: "pending-typing", kind: "typing" })
} }
return items
}, [
attachedResourcesText,
connStatus,
groupedTimeline,
sharedT,
timelineTurns,
])
const historicalMessages = useMemo( return { threadItems: items, nonStreamingAdapted: nonStreaming }
() => }, [adapterText, connStatus, timelineTurns])
adaptMessageTurns(
timelineTurns
.filter((item) => item.phase !== "streaming")
.map((item) => item.turn),
{
attachedResources: sharedT("attachedResources"),
toolCallFailed: sharedT("toolCallFailed"),
}
),
[sharedT, timelineTurns]
)
const historicalPlanEntries = useMemo( const historicalPlanEntries = useMemo(
() => extractLatestPlanEntriesFromMessages(historicalMessages), () => extractLatestPlanEntriesFromMessages(nonStreamingAdapted),
[historicalMessages] [nonStreamingAdapted]
) )
const historicalPlanKey = useMemo( const historicalPlanKey = useMemo(
() => buildPlanKey(historicalPlanEntries), () => buildPlanKey(historicalPlanEntries),
@@ -337,7 +251,7 @@ export function MessageListView({
const hasRenderableContent = threadItems.length > 0 || Boolean(liveMessage) const hasRenderableContent = threadItems.length > 0 || Boolean(liveMessage)
if (loading && !hasRenderableContent) { if (detailLoading && !hasRenderableContent) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -348,12 +262,12 @@ export function MessageListView({
) )
} }
if (error && !hasRenderableContent) { if (detailError && !hasRenderableContent) {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-destructive text-sm"> <p className="text-destructive text-sm">
{t("error", { message: error })} {t("error", { message: detailError })}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -306,6 +306,22 @@ function reducer(
const current = const current =
state.byConversationId.get(action.conversationId) ?? state.byConversationId.get(action.conversationId) ??
createEmptySession(action.conversationId) createEmptySession(action.conversationId)
// Guard: prevent stale liveMessage from ACP reconnects overriding
// persisted data. When a session has no active liveMessage and no
// pending interaction (idle or reconciling without a live turn),
// a SET_LIVE_MESSAGE from a reconnected ACP connection carries
// the completed response that is already in persistedTurns.
// Accepting it would cause duplicate assistant text in the timeline.
if (
action.liveMessage !== null &&
current.liveMessage === null &&
current.syncState !== "awaiting_persist" &&
current.persistedTurns.length > 0
) {
return state
}
const nextSession: ConversationRuntimeSession = { const nextSession: ConversationRuntimeSession = {
...current, ...current,
liveMessage: action.liveMessage, liveMessage: action.liveMessage,

View File

@@ -1114,8 +1114,7 @@
}, },
"shared": { "shared": {
"attachedResources": "الموارد المرفقة", "attachedResources": "الموارد المرفقة",
"toolCallFailed": "فشل استدعاء الأداة", "toolCallFailed": "فشل استدعاء الأداة"
"planUpdated": "تم تحديث الخطة"
}, },
"messageThread": { "messageThread": {
"emptyTitle": "لا توجد رسائل بعد", "emptyTitle": "لا توجد رسائل بعد",
@@ -1147,9 +1146,6 @@
"noEnabledAgents": "لا يوجد وكلاء مفعّلون", "noEnabledAgents": "لا يوجد وكلاء مفعّلون",
"openAgentsSettings": "فتح إعدادات الوكلاء" "openAgentsSettings": "فتح إعدادات الوكلاء"
}, },
"liveMessageBlock": {
"assistantThinkingAria": "المساعد يفكر"
},
"agentPlanOverlay": { "agentPlanOverlay": {
"title": "خطة الوكيل", "title": "خطة الوكيل",
"collapsePlanAria": "طي الخطة", "collapsePlanAria": "طي الخطة",

View File

@@ -1114,8 +1114,7 @@
}, },
"shared": { "shared": {
"attachedResources": "Angehängte Ressourcen", "attachedResources": "Angehängte Ressourcen",
"toolCallFailed": "Tool-Aufruf fehlgeschlagen", "toolCallFailed": "Tool-Aufruf fehlgeschlagen"
"planUpdated": "Plan aktualisiert"
}, },
"messageThread": { "messageThread": {
"emptyTitle": "Noch keine Nachrichten", "emptyTitle": "Noch keine Nachrichten",
@@ -1147,9 +1146,6 @@
"noEnabledAgents": "Keine aktivierten Agenten", "noEnabledAgents": "Keine aktivierten Agenten",
"openAgentsSettings": "Agenten-Einstellungen öffnen" "openAgentsSettings": "Agenten-Einstellungen öffnen"
}, },
"liveMessageBlock": {
"assistantThinkingAria": "Assistent denkt nach"
},
"agentPlanOverlay": { "agentPlanOverlay": {
"title": "Agentenplan", "title": "Agentenplan",
"collapsePlanAria": "Plan einklappen", "collapsePlanAria": "Plan einklappen",

View File

@@ -1114,8 +1114,7 @@
}, },
"shared": { "shared": {
"attachedResources": "Attached resources", "attachedResources": "Attached resources",
"toolCallFailed": "Tool call failed", "toolCallFailed": "Tool call failed"
"planUpdated": "Plan updated"
}, },
"messageThread": { "messageThread": {
"emptyTitle": "No messages yet", "emptyTitle": "No messages yet",
@@ -1147,9 +1146,6 @@
"noEnabledAgents": "No enabled agents", "noEnabledAgents": "No enabled agents",
"openAgentsSettings": "Open Agents settings" "openAgentsSettings": "Open Agents settings"
}, },
"liveMessageBlock": {
"assistantThinkingAria": "Assistant is thinking"
},
"agentPlanOverlay": { "agentPlanOverlay": {
"title": "Agent Plan", "title": "Agent Plan",
"collapsePlanAria": "Collapse plan", "collapsePlanAria": "Collapse plan",

View File

@@ -1114,8 +1114,7 @@
}, },
"shared": { "shared": {
"attachedResources": "Recursos adjuntos", "attachedResources": "Recursos adjuntos",
"toolCallFailed": "Falló la llamada de herramienta", "toolCallFailed": "Falló la llamada de herramienta"
"planUpdated": "Plan actualizado"
}, },
"messageThread": { "messageThread": {
"emptyTitle": "Aún no hay mensajes", "emptyTitle": "Aún no hay mensajes",
@@ -1147,9 +1146,6 @@
"noEnabledAgents": "No hay agentes habilitados", "noEnabledAgents": "No hay agentes habilitados",
"openAgentsSettings": "Abrir ajustes de agentes" "openAgentsSettings": "Abrir ajustes de agentes"
}, },
"liveMessageBlock": {
"assistantThinkingAria": "El asistente está pensando"
},
"agentPlanOverlay": { "agentPlanOverlay": {
"title": "Plan del agente", "title": "Plan del agente",
"collapsePlanAria": "Contraer plan", "collapsePlanAria": "Contraer plan",

View File

@@ -1114,8 +1114,7 @@
}, },
"shared": { "shared": {
"attachedResources": "Ressources jointes", "attachedResources": "Ressources jointes",
"toolCallFailed": "Échec de l'appel d'outil", "toolCallFailed": "Échec de l'appel d'outil"
"planUpdated": "Plan mis à jour"
}, },
"messageThread": { "messageThread": {
"emptyTitle": "Aucun message pour le moment", "emptyTitle": "Aucun message pour le moment",
@@ -1147,9 +1146,6 @@
"noEnabledAgents": "Aucun agent activé", "noEnabledAgents": "Aucun agent activé",
"openAgentsSettings": "Ouvrir les paramètres des agents" "openAgentsSettings": "Ouvrir les paramètres des agents"
}, },
"liveMessageBlock": {
"assistantThinkingAria": "L'assistant réfléchit"
},
"agentPlanOverlay": { "agentPlanOverlay": {
"title": "Plan de l'agent", "title": "Plan de l'agent",
"collapsePlanAria": "Réduire le plan", "collapsePlanAria": "Réduire le plan",

View File

@@ -1114,8 +1114,7 @@
}, },
"shared": { "shared": {
"attachedResources": "添付リソース", "attachedResources": "添付リソース",
"toolCallFailed": "ツール呼び出しに失敗しました", "toolCallFailed": "ツール呼び出しに失敗しました"
"planUpdated": "プランを更新しました"
}, },
"messageThread": { "messageThread": {
"emptyTitle": "まだメッセージはありません", "emptyTitle": "まだメッセージはありません",
@@ -1147,9 +1146,6 @@
"noEnabledAgents": "有効なエージェントがありません", "noEnabledAgents": "有効なエージェントがありません",
"openAgentsSettings": "エージェント設定を開く" "openAgentsSettings": "エージェント設定を開く"
}, },
"liveMessageBlock": {
"assistantThinkingAria": "アシスタントが考え中です"
},
"agentPlanOverlay": { "agentPlanOverlay": {
"title": "エージェントプラン", "title": "エージェントプラン",
"collapsePlanAria": "プランを折りたたむ", "collapsePlanAria": "プランを折りたたむ",

View File

@@ -1114,8 +1114,7 @@
}, },
"shared": { "shared": {
"attachedResources": "첨부된 리소스", "attachedResources": "첨부된 리소스",
"toolCallFailed": "도구 호출 실패", "toolCallFailed": "도구 호출 실패"
"planUpdated": "계획이 업데이트되었습니다"
}, },
"messageThread": { "messageThread": {
"emptyTitle": "아직 메시지가 없습니다", "emptyTitle": "아직 메시지가 없습니다",
@@ -1147,9 +1146,6 @@
"noEnabledAgents": "활성화된 에이전트가 없습니다", "noEnabledAgents": "활성화된 에이전트가 없습니다",
"openAgentsSettings": "에이전트 설정 열기" "openAgentsSettings": "에이전트 설정 열기"
}, },
"liveMessageBlock": {
"assistantThinkingAria": "어시스턴트가 생각 중"
},
"agentPlanOverlay": { "agentPlanOverlay": {
"title": "에이전트 계획", "title": "에이전트 계획",
"collapsePlanAria": "계획 접기", "collapsePlanAria": "계획 접기",

View File

@@ -1114,8 +1114,7 @@
}, },
"shared": { "shared": {
"attachedResources": "Recursos anexados", "attachedResources": "Recursos anexados",
"toolCallFailed": "Falha na chamada da ferramenta", "toolCallFailed": "Falha na chamada da ferramenta"
"planUpdated": "Plano atualizado"
}, },
"messageThread": { "messageThread": {
"emptyTitle": "Ainda não há mensagens", "emptyTitle": "Ainda não há mensagens",
@@ -1147,9 +1146,6 @@
"noEnabledAgents": "Nenhum agente habilitado", "noEnabledAgents": "Nenhum agente habilitado",
"openAgentsSettings": "Abrir configurações de agentes" "openAgentsSettings": "Abrir configurações de agentes"
}, },
"liveMessageBlock": {
"assistantThinkingAria": "O assistente está pensando"
},
"agentPlanOverlay": { "agentPlanOverlay": {
"title": "Plano do agente", "title": "Plano do agente",
"collapsePlanAria": "Recolher plano", "collapsePlanAria": "Recolher plano",

View File

@@ -1114,8 +1114,7 @@
}, },
"shared": { "shared": {
"attachedResources": "附加资源", "attachedResources": "附加资源",
"toolCallFailed": "工具调用失败", "toolCallFailed": "工具调用失败"
"planUpdated": "计划已更新"
}, },
"messageThread": { "messageThread": {
"emptyTitle": "暂无消息", "emptyTitle": "暂无消息",
@@ -1147,9 +1146,6 @@
"noEnabledAgents": "暂无已启用的 Agent", "noEnabledAgents": "暂无已启用的 Agent",
"openAgentsSettings": "打开 Agents 设置" "openAgentsSettings": "打开 Agents 设置"
}, },
"liveMessageBlock": {
"assistantThinkingAria": "助手正在思考"
},
"agentPlanOverlay": { "agentPlanOverlay": {
"title": "Agent 计划", "title": "Agent 计划",
"collapsePlanAria": "折叠计划", "collapsePlanAria": "折叠计划",

View File

@@ -1114,8 +1114,7 @@
}, },
"shared": { "shared": {
"attachedResources": "附加資源", "attachedResources": "附加資源",
"toolCallFailed": "工具呼叫失敗", "toolCallFailed": "工具呼叫失敗"
"planUpdated": "計畫已更新"
}, },
"messageThread": { "messageThread": {
"emptyTitle": "暫無訊息", "emptyTitle": "暫無訊息",
@@ -1147,9 +1146,6 @@
"noEnabledAgents": "暫無已啟用的 Agent", "noEnabledAgents": "暫無已啟用的 Agent",
"openAgentsSettings": "開啟 Agents 設定" "openAgentsSettings": "開啟 Agents 設定"
}, },
"liveMessageBlock": {
"assistantThinkingAria": "助手正在思考"
},
"agentPlanOverlay": { "agentPlanOverlay": {
"title": "Agent 計畫", "title": "Agent 計畫",
"collapsePlanAria": "摺疊計畫", "collapsePlanAria": "摺疊計畫",

View File

@@ -4,8 +4,6 @@ import type {
MessageRole, MessageRole,
TurnUsage, TurnUsage,
} from "@/lib/types" } from "@/lib/types"
import type { LiveMessage } from "@/contexts/acp-connections-context"
import { inferLiveToolName } from "@/lib/tool-call-normalization"
/** /**
* Adapted content part types for AI SDK Elements components * Adapted content part types for AI SDK Elements components
@@ -71,7 +69,6 @@ export interface AdaptedMessage {
export interface AdapterMessageText { export interface AdapterMessageText {
attachedResources: string attachedResources: string
toolCallFailed: string toolCallFailed: string
planUpdated: string
} }
type InlineToolSegment = type InlineToolSegment =
@@ -606,7 +603,7 @@ function buildToolResultMap(
*/ */
export function adaptMessageTurn( export function adaptMessageTurn(
turn: MessageTurn, turn: MessageTurn,
text: Pick<AdapterMessageText, "attachedResources" | "toolCallFailed"> text: AdapterMessageText
): AdaptedMessage { ): AdaptedMessage {
const adaptedContent: AdaptedContentPart[] = [] const adaptedContent: AdaptedContentPart[] = []
const resultMap = buildToolResultMap(turn.blocks) const resultMap = buildToolResultMap(turn.blocks)
@@ -733,7 +730,7 @@ export function adaptMessageTurn(
*/ */
export function adaptMessageTurns( export function adaptMessageTurns(
turns: MessageTurn[], turns: MessageTurn[],
text: Pick<AdapterMessageText, "attachedResources" | "toolCallFailed"> text: AdapterMessageText
): AdaptedMessage[] { ): AdaptedMessage[] {
return turns.map((turn) => adaptMessageTurn(turn, text)) return turns.map((turn) => adaptMessageTurn(turn, text))
} }
@@ -819,319 +816,3 @@ export function groupAdaptedMessages(
return groups return groups
} }
/**
* Map ACP tool call status to ToolCallState for display.
*/
function mapAcpStatusToToolCallState(status: string): ToolCallState {
switch (status) {
case "pending":
return "input-streaming"
case "in_progress":
return "input-available"
case "completed":
return "output-available"
case "failed":
return "output-error"
default:
return "input-available"
}
}
function isReadToolName(toolName: string): boolean {
const normalized = toolName.trim().toLowerCase()
return normalized === "read" || normalized === "read file"
}
function isTaskMarkdownToolName(toolName: string): boolean {
const normalized = toolName.trim().toLowerCase()
return (
normalized === "task" ||
normalized === "taskcreate" ||
normalized === "taskupdate" ||
normalized === "tasklist" ||
normalized.includes("explore")
)
}
function looksLikeJsonPayload(text: string): boolean {
const trimmed = text.trimStart()
return trimmed.startsWith("{") || trimmed.startsWith("[")
}
function collectReadOutputText(value: unknown, depth: number = 0): string[] {
if (depth > 6 || value === null || value === undefined) {
return []
}
if (typeof value === "string") {
return value.length > 0 ? [value] : []
}
if (Array.isArray(value)) {
return value.flatMap((item) => collectReadOutputText(item, depth + 1))
}
if (typeof value !== "object") {
return []
}
const obj = value as Record<string, unknown>
const parts: string[] = []
const type = typeof obj.type === "string" ? obj.type.toLowerCase() : null
const text = obj.text
if (
typeof text === "string" &&
text.length > 0 &&
(type === null || type === "text")
) {
parts.push(text)
}
for (const nestedKey of ["content", "output", "result", "data"]) {
parts.push(...collectReadOutputText(obj[nestedKey], depth + 1))
}
return parts
}
function extractReadTextFromJsonOutput(output: string): string | null {
if (!looksLikeJsonPayload(output)) {
return null
}
try {
const parsed: unknown = JSON.parse(output)
const parts = collectReadOutputText(parsed)
if (parts.length === 0) return null
const text = parts.join("\n")
return text.length > 0 ? text : null
} catch {
return null
}
}
function decodeJsonTextValue(value: string): string {
try {
return JSON.parse(`"${value}"`) as string
} catch {
return value.replace(/\\"/g, '"').replace(/\\\\/g, "\\")
}
}
function extractTextFromMalformedJsonOutput(output: string): string | null {
const textValues = Array.from(
output.matchAll(/"text"\s*:\s*"((?:[^"\\]|\\.)*)"/g)
)
.map((match) => decodeJsonTextValue(match[1] ?? ""))
.map((value) => value.trim())
.filter((value) => value.length > 0)
if (textValues.length === 0) {
return null
}
return textValues.join("\n")
}
function stripWrappedMarkdownFence(text: string): string {
const normalized = text.replace(/\r\n/g, "\n")
const match = normalized.match(
/^\s*```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n```\s*$/
)
if (!match) return text
return match[1]
}
function normalizeReadDisplayText(text: string): string {
return stripWrappedMarkdownFence(text)
}
function selectTaskMarkdownOutput(params: {
rawOutput: string | null
content: string | null
isFinalState: boolean
}): string | null {
for (const candidate of [params.content, params.rawOutput]) {
if (typeof candidate !== "string" || candidate.length === 0) continue
const extractedFromJson =
extractReadTextFromJsonOutput(candidate) ??
extractTextFromMalformedJsonOutput(candidate)
if (extractedFromJson) {
return normalizeReadDisplayText(extractedFromJson)
}
if (!looksLikeJsonPayload(candidate)) {
return normalizeReadDisplayText(candidate)
}
}
if (!params.isFinalState) return null
const fallback = params.content ?? params.rawOutput
if (typeof fallback !== "string") return null
const extracted =
extractReadTextFromJsonOutput(fallback) ??
extractTextFromMalformedJsonOutput(fallback)
if (extracted) {
return normalizeReadDisplayText(extracted)
}
if (!looksLikeJsonPayload(fallback)) {
return normalizeReadDisplayText(fallback)
}
return null
}
function selectLiveToolOutput(params: {
toolName: string
rawOutput: string | null
content: string | null
isFinalState: boolean
}): string | null {
if (isTaskMarkdownToolName(params.toolName)) {
return selectTaskMarkdownOutput(params)
}
if (!isReadToolName(params.toolName)) {
return params.rawOutput ?? params.content
}
for (const candidate of [params.content, params.rawOutput]) {
if (typeof candidate !== "string" || candidate.length === 0) continue
const extracted = extractReadTextFromJsonOutput(candidate)
if (extracted) return normalizeReadDisplayText(extracted)
if (!looksLikeJsonPayload(candidate))
return normalizeReadDisplayText(candidate)
}
if (!params.isFinalState) return null
const fallback = params.rawOutput ?? params.content
return typeof fallback === "string"
? normalizeReadDisplayText(fallback)
: null
}
function formatPlanEntries(
entries: Array<{ content: string; priority: string; status: string }>,
planUpdatedText: string
): string {
if (entries.length === 0) {
return planUpdatedText
}
const lines = entries.map(
(entry) => `- [${entry.status}] ${entry.content} (${entry.priority})`
)
return `${planUpdatedText}:\n${lines.join("\n")}`
}
interface AdaptLiveMessageOptions {
isLiveStreaming?: boolean
toolCallFailedText: string
planUpdatedText: string
}
function isReasoningBlock(block: LiveMessage["content"][number]): boolean {
return block.type === "thinking" || block.type === "plan"
}
function findLastReasoningIndex(message: LiveMessage): number {
for (let index = message.content.length - 1; index >= 0; index -= 1) {
if (isReasoningBlock(message.content[index])) {
return index
}
}
return -1
}
/**
* Transform a LiveMessage (from ACP) to AdaptedMessage format
* This is used for live streaming messages from the ACP protocol
*/
export function adaptLiveMessageFromAcp(
message: LiveMessage,
options: AdaptLiveMessageOptions
): AdaptedMessage {
const isLiveStreaming = options.isLiveStreaming ?? true
const adaptedContent: AdaptedContentPart[] = []
const lastStreamingReasoningIndex = isLiveStreaming
? findLastReasoningIndex(message)
: -1
message.content.forEach((block, index) => {
switch (block.type) {
case "text":
adaptedContent.push({
type: "text",
text: block.text,
})
break
case "thinking":
adaptedContent.push({
type: "reasoning",
content: block.text,
isStreaming: index === lastStreamingReasoningIndex,
})
break
case "tool_call": {
const { info } = block
const toolName = inferLiveToolName({
title: info.title,
kind: info.kind,
rawInput: info.raw_input,
})
const state = mapAcpStatusToToolCallState(info.status)
const isFinalState =
state === "output-available" || state === "output-error"
const hasExplicitOutput =
info.raw_output !== null || info.content !== null
const selectedOutput = selectLiveToolOutput({
toolName,
rawOutput: info.raw_output,
content: info.content,
isFinalState,
})
const output = isFinalState
? selectedOutput
: hasExplicitOutput
? selectedOutput
: null
adaptedContent.push({
type: "tool-call",
toolCallId: info.tool_call_id,
toolName,
displayTitle: info.title,
input: info.raw_input,
state,
output,
errorText:
state === "output-error"
? selectedOutput || options.toolCallFailedText
: undefined,
})
break
}
case "plan":
adaptedContent.push({
type: "reasoning",
content: formatPlanEntries(block.entries, options.planUpdatedText),
isStreaming: index === lastStreamingReasoningIndex,
})
break
}
})
return {
id: message.id,
role: message.role,
content: adaptedContent,
timestamp: new Date().toISOString(), // Live messages don't have timestamps
}
}