fix: 修复对话轮次分组器的类型安全问题

- 移除 grouper.ts 中的 as any 类型断言,使用正确的类型守卫
- 导入具体的 RenderBlock 类型替代 import() 语法
- 合并 ConversationView.vue 中的两个 watch 避免竞争条件
- 使用 lastRenderResultId 追踪变化防止重复初始化
This commit is contained in:
fawney19
2026-01-16 19:53:54 +08:00
parent aa59862a70
commit b807c16395
2 changed files with 72 additions and 44 deletions

View File

@@ -122,12 +122,18 @@ const props = defineProps<{
const systemExpanded = ref(false)
const historyExpanded = ref(false)
const expandedTurns = ref<Set<number>>(new Set())
const lastRenderResultId = ref<string>('')
// 将渲染结果转换为分组对话
const groupedConversation = computed(() => {
return groupRenderBlocksIntoTurns(props.renderResult.blocks, props.renderResult.isStream)
})
// 生成 renderResult 的唯一标识
function getRenderResultId(): string {
return `${props.renderResult.blocks.length}-${props.renderResult.isStream}`
}
// 历史轮次(除最后 1-2 轮外的所有轮次)
const historyTurns = computed(() => {
const turns = groupedConversation.value.turns
@@ -163,18 +169,16 @@ function initExpandedTurns() {
}
}
// renderResult 变化重置状态
// 监听 renderResult 变化重置状态并初始化展开轮次
watch(() => props.renderResult, () => {
systemExpanded.value = false
nextTick(() => {
initExpandedTurns()
})
}, { deep: true })
// 初始加载时也要初始化
watch(groupedConversation, () => {
if (expandedTurns.value.size === 0 && groupedConversation.value.turns.length > 0) {
initExpandedTurns()
const currentId = getRenderResultId()
if (currentId !== lastRenderResultId.value) {
lastRenderResultId.value = currentId
systemExpanded.value = false
historyExpanded.value = false
nextTick(() => {
initExpandedTurns()
})
}
}, { immediate: true })
}, { deep: true, immediate: true })
</script>

View File

@@ -11,8 +11,21 @@ import type {
GroupedConversation,
TurnStats,
TurnSummary,
TextContentBlock,
} from './types'
import type {
RenderBlock,
MessageRenderBlock,
TextRenderBlock,
CollapsibleRenderBlock,
CodeRenderBlock,
ToolUseRenderBlock,
ToolResultRenderBlock,
ImageRenderBlock,
ErrorRenderBlock,
} from './render'
// ============================================================
// 常量配置
// ============================================================
@@ -32,8 +45,8 @@ const SUMMARY_MIN_RATIO = 0.6
*/
function extractTextFromBlocks(blocks: ContentBlock[]): string {
return blocks
.filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
.map(b => (b as any).text || '')
.filter((b): b is TextContentBlock => b.type === 'text')
.map(b => b.text || '')
.join(' ')
.trim()
}
@@ -277,7 +290,7 @@ export function groupConversation(conversation: ParsedConversation): GroupedConv
* 用于已渲染的结果进行轮次分组
*/
export function groupRenderBlocksIntoTurns(
blocks: import('./render').RenderBlock[],
blocks: RenderBlock[],
isStream: boolean = false
): GroupedConversation {
const turns: ConversationTurn[] = []
@@ -288,7 +301,7 @@ export function groupRenderBlocksIntoTurns(
for (const block of blocks) {
if (block.type !== 'message') continue
const messageBlock = block as import('./render').MessageRenderBlock
const messageBlock = block as MessageRenderBlock
if (messageBlock.role === 'system') {
// 提取 system prompt
@@ -345,10 +358,10 @@ export function groupRenderBlocksIntoTurns(
/**
* 从渲染块中提取文本
*/
function extractTextFromRenderBlocks(blocks: import('./render').RenderBlock[]): string {
function extractTextFromRenderBlocks(blocks: RenderBlock[]): string {
return blocks
.filter(b => b.type === 'text')
.map(b => (b as any).content || '')
.filter((b): b is TextRenderBlock => b.type === 'text')
.map(b => b.content || '')
.join(' ')
.trim()
}
@@ -356,60 +369,71 @@ function extractTextFromRenderBlocks(blocks: import('./render').RenderBlock[]):
/**
* 将 MessageRenderBlock 转换为 ParsedMessage
*/
function renderBlockToMessage(block: import('./render').MessageRenderBlock): ParsedMessage {
function renderBlockToMessage(block: MessageRenderBlock): ParsedMessage {
const content: ContentBlock[] = []
for (const child of block.content) {
switch (child.type) {
case 'text':
content.push({ type: 'text', text: (child as any).content || '' })
case 'text': {
const textBlock = child as TextRenderBlock
content.push({ type: 'text', text: textBlock.content || '' })
break
case 'collapsible':
// 可能是 thinking从 code 块中提取内容
if ((child as any).title?.includes('思考')) {
const innerBlocks = (child as any).content || []
const codeBlock = innerBlocks.find((b: any) => b.type === 'code')
const thinkingText = codeBlock?.code || ''
content.push({ type: 'thinking', thinking: thinkingText })
}
case 'collapsible': {
const collapsibleBlock = child as CollapsibleRenderBlock
if (collapsibleBlock.title?.includes('思考')) {
const codeBlock = collapsibleBlock.content.find(
(b): b is CodeRenderBlock => b.type === 'code'
)
content.push({ type: 'thinking', thinking: codeBlock?.code || '' })
}
break
case 'tool_use':
}
case 'tool_use': {
const toolUseBlock = child as ToolUseRenderBlock
content.push({
type: 'tool_use',
toolId: (child as any).toolId || '',
toolName: (child as any).toolName || '',
input: (child as any).input || '',
toolId: toolUseBlock.toolId || '',
toolName: toolUseBlock.toolName || '',
input: toolUseBlock.input || '',
})
break
case 'tool_result':
}
case 'tool_result': {
const toolResultBlock = child as ToolResultRenderBlock
content.push({
type: 'tool_result',
toolUseId: '',
content: (child as any).content || '',
isError: (child as any).isError,
content: toolResultBlock.content || '',
isError: toolResultBlock.isError,
})
break
case 'image':
}
case 'image': {
const imageBlock = child as ImageRenderBlock
content.push({
type: 'image',
sourceType: 'url',
url: (child as any).src,
mimeType: (child as any).mimeType,
alt: (child as any).alt,
url: imageBlock.src,
mimeType: imageBlock.mimeType,
alt: imageBlock.alt,
})
break
case 'error':
}
case 'error': {
const errorBlock = child as ErrorRenderBlock
content.push({
type: 'error',
message: (child as any).message || '',
code: (child as any).code,
message: errorBlock.message || '',
code: errorBlock.code,
})
break
}
}
}
return {
role: block.role as any,
role: block.role,
content,
}
}