重构部分会话消息处理逻辑,优化会话消息渲染
This commit is contained in:
@@ -4,8 +4,6 @@ import type {
|
||||
MessageRole,
|
||||
TurnUsage,
|
||||
} 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
|
||||
@@ -71,7 +69,6 @@ export interface AdaptedMessage {
|
||||
export interface AdapterMessageText {
|
||||
attachedResources: string
|
||||
toolCallFailed: string
|
||||
planUpdated: string
|
||||
}
|
||||
|
||||
type InlineToolSegment =
|
||||
@@ -606,7 +603,7 @@ function buildToolResultMap(
|
||||
*/
|
||||
export function adaptMessageTurn(
|
||||
turn: MessageTurn,
|
||||
text: Pick<AdapterMessageText, "attachedResources" | "toolCallFailed">
|
||||
text: AdapterMessageText
|
||||
): AdaptedMessage {
|
||||
const adaptedContent: AdaptedContentPart[] = []
|
||||
const resultMap = buildToolResultMap(turn.blocks)
|
||||
@@ -733,7 +730,7 @@ export function adaptMessageTurn(
|
||||
*/
|
||||
export function adaptMessageTurns(
|
||||
turns: MessageTurn[],
|
||||
text: Pick<AdapterMessageText, "attachedResources" | "toolCallFailed">
|
||||
text: AdapterMessageText
|
||||
): AdaptedMessage[] {
|
||||
return turns.map((turn) => adaptMessageTurn(turn, text))
|
||||
}
|
||||
@@ -819,319 +816,3 @@ export function groupAdaptedMessages(
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user