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

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

@@ -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
}
}