Merge branch 'NyaDoo/master'

This commit is contained in:
fawney19
2026-01-16 19:47:45 +08:00
8 changed files with 942 additions and 381 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div class="block-renderer">
<div class="flex flex-col gap-2">
<template
v-for="(block, index) in blocks"
:key="index"
@@ -7,22 +7,22 @@
<!-- 文本块 -->
<pre
v-if="block.type === 'text'"
class="render-block text-block"
:class="[block.className, { 'pre-wrap': block.preWrap !== false }]"
class="m-0 text-sm leading-relaxed whitespace-pre-wrap break-words"
:class="block.className"
>{{ block.content }}</pre>
<!-- 可折叠块 -->
<details
v-else-if="block.type === 'collapsible'"
class="render-block collapsible-block"
class="group cursor-pointer bg-muted/30 rounded-lg px-3 py-2"
:class="block.className"
:open="block.defaultOpen"
>
<summary class="collapsible-summary">
<ChevronRight class="w-4 h-4 chevron" />
<span class="text-muted-foreground">{{ block.title }}</span>
<summary class="flex items-center gap-2 list-none select-none [&::-webkit-details-marker]:hidden">
<ChevronRight class="w-4 h-4 text-muted-foreground transition-transform duration-200 group-open:rotate-90" />
<span class="text-xs text-muted-foreground">{{ block.title }}</span>
</summary>
<div class="collapsible-content">
<div class="mt-2 p-3 bg-muted/50 rounded-lg">
<BlockRenderer :blocks="block.content" />
</div>
</details>
@@ -30,16 +30,16 @@
<!-- 代码块 -->
<div
v-else-if="block.type === 'code'"
class="render-block code-block"
class="bg-muted/50 rounded-lg overflow-hidden"
>
<div
v-if="block.language"
class="code-language"
class="px-3 py-2 text-xs font-medium bg-muted/50 text-muted-foreground border-b border-border"
>
{{ block.language }}
</div>
<pre
class="code-content"
class="m-0 p-3 font-mono text-xs max-h-[300px] overflow-auto whitespace-pre-wrap break-words"
:style="block.maxHeight ? { maxHeight: `${block.maxHeight}px` } : {}"
>{{ block.code }}</pre>
</div>
@@ -48,7 +48,7 @@
<Badge
v-else-if="block.type === 'badge'"
:variant="block.variant || 'secondary'"
class="render-block badge-block"
class="w-fit"
>
{{ block.label }}
</Badge>
@@ -56,19 +56,19 @@
<!-- 图片块 -->
<div
v-else-if="block.type === 'image'"
class="render-block image-block"
class="bg-muted/30 p-3 rounded-lg"
>
<img
v-if="block.src"
:src="block.src"
:alt="block.alt || '图片'"
class="rendered-image"
class="max-w-full max-h-[400px] rounded-lg"
>
<div
v-else
class="image-placeholder"
class="flex items-center gap-2 text-muted-foreground text-xs"
>
<ImageIcon class="w-6 h-6" />
<ImageIcon class="w-4 h-4" />
<span>{{ block.mimeType || block.alt || '图片' }}</span>
</div>
</div>
@@ -76,43 +76,44 @@
<!-- 错误块 -->
<div
v-else-if="block.type === 'error'"
class="render-block error-block"
class="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm"
>
<AlertCircle class="w-4 h-4" />
<AlertCircle class="w-4 h-4 shrink-0" />
<span>{{ block.message }}</span>
<span
v-if="block.code"
class="error-code"
class="font-mono text-xs opacity-80"
>{{ block.code }}</span>
</div>
<!-- 容器块 -->
<div
v-else-if="block.type === 'container'"
class="render-block container-block"
class="flex flex-col gap-2"
:class="block.className"
>
<div
v-if="block.header"
class="container-header"
class="flex items-center gap-2"
>
<BlockRenderer :blocks="block.header" />
</div>
<div class="container-content">
<BlockRenderer :blocks="block.children" />
</div>
<BlockRenderer :blocks="block.children" />
</div>
<!-- 消息块 -->
<div
v-else-if="block.type === 'message'"
class="render-block message-block"
:class="block.role"
class="rounded-lg overflow-hidden"
:class="messageBlockClasses[block.role] || messageBlockClasses.default"
>
<div class="message-header">
<div
class="flex items-center gap-2 px-3 py-2 text-xs font-medium"
:class="messageHeaderClasses[block.role] || messageHeaderClasses.default"
>
<component
:is="getRoleIcon(block.role)"
class="w-3.5 h-3.5"
class="w-4 h-4"
/>
<span>{{ block.roleLabel || getRoleLabel(block.role) }}</span>
<template v-if="block.badges">
@@ -120,13 +121,12 @@
v-for="(badge, badgeIndex) in block.badges"
:key="badgeIndex"
:variant="badge.variant || 'secondary'"
class="ml-2 text-xs"
>
{{ badge.label }}
</Badge>
</template>
</div>
<div class="message-content">
<div class="px-3 pb-3 text-sm leading-relaxed">
<BlockRenderer :blocks="block.content" />
</div>
</div>
@@ -134,55 +134,51 @@
<!-- 工具调用块 -->
<div
v-else-if="block.type === 'tool_use'"
class="render-block tool-block"
class="bg-muted/30 rounded-lg overflow-hidden"
>
<div class="tool-header">
<Wrench class="w-3 h-3" />
<div class="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
<Wrench class="w-4 h-4" />
<span>{{ block.toolName }}</span>
<span
v-if="block.toolId"
class="tool-id"
class="font-mono text-xs opacity-60"
>{{ block.toolId }}</span>
</div>
<pre class="tool-content">{{ block.input }}</pre>
<pre class="m-0 p-3 bg-muted/50 font-mono text-xs max-h-[200px] overflow-y-auto whitespace-pre-wrap break-words">{{ block.input }}</pre>
</div>
<!-- 工具结果块 -->
<div
v-else-if="block.type === 'tool_result'"
class="render-block tool-result-block"
:class="{ 'is-error': block.isError }"
class="rounded-lg overflow-hidden"
:class="block.isError ? 'bg-destructive/10 border border-destructive/20' : 'bg-muted/30'"
>
<div class="tool-header">
<FileText class="w-3 h-3" />
<div class="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
<FileText class="w-4 h-4" />
<span>工具结果</span>
<Badge
v-if="block.isError"
variant="destructive"
class="ml-2 text-xs"
>
错误
</Badge>
</div>
<pre class="tool-content">{{ block.content }}</pre>
<pre class="m-0 p-3 bg-muted/50 font-mono text-xs max-h-[200px] overflow-y-auto whitespace-pre-wrap break-words">{{ block.content }}</pre>
</div>
<!-- 分隔符块 -->
<hr
v-else-if="block.type === 'divider'"
class="render-block divider-block"
class="border-0 border-t border-border my-2"
>
<!-- 标签块 -->
<div
v-else-if="block.type === 'label'"
class="render-block label-block"
class="flex gap-2 text-sm"
>
<span class="label-key">{{ block.label }}:</span>
<span
class="label-value"
:class="{ mono: block.mono }"
>{{ block.value }}</span>
<span class="text-muted-foreground">{{ block.label }}:</span>
<span :class="block.mono ? 'font-mono' : ''">{{ block.value }}</span>
</div>
</template>
</div>
@@ -197,295 +193,39 @@ defineProps<{
blocks: RenderBlock[]
}>()
const messageBlockClasses: Record<string, string> = {
user: 'bg-primary/[0.08] border border-primary/20',
assistant: 'bg-muted/50 border border-border',
system: 'bg-muted/30 border border-dashed border-border',
tool: 'bg-muted/30 border border-border',
default: 'bg-muted/30 border border-border',
}
const messageHeaderClasses: Record<string, string> = {
user: 'text-primary',
assistant: 'text-foreground',
system: 'text-muted-foreground',
tool: 'text-muted-foreground',
default: 'text-foreground',
}
const getRoleIcon = (role: string) => {
switch (role) {
case 'user':
return User
case 'assistant':
return Bot
case 'system':
return Settings
case 'tool':
return Wrench
default:
return User
case 'user': return User
case 'assistant': return Bot
case 'system': return Settings
case 'tool': return Wrench
default: return User
}
}
const getRoleLabel = (role: string) => {
switch (role) {
case 'user':
return 'User'
case 'assistant':
return 'Assistant'
case 'system':
return 'System'
case 'tool':
return 'Tool'
default:
return role
case 'user': return 'User'
case 'assistant': return 'Assistant'
case 'system': return 'System'
case 'tool': return 'Tool'
default: return role
}
}
</script>
<style scoped>
.block-renderer {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 通用渲染块样式 */
.render-block {
border-radius: 6px;
}
/* 文本块 */
.text-block {
margin: 0;
font-family: inherit;
font-size: 13px;
line-height: 1.6;
}
.text-block.pre-wrap {
white-space: pre-wrap;
word-break: break-word;
}
/* 可折叠块 */
.collapsible-block {
cursor: pointer;
background: hsl(var(--muted) / 0.3);
padding: 8px 12px;
}
.collapsible-summary {
display: flex;
align-items: center;
gap: 4px;
list-style: none;
user-select: none;
}
.collapsible-summary::-webkit-details-marker {
display: none;
}
.collapsible-summary .chevron {
transition: transform 0.2s;
}
.collapsible-block[open] .chevron {
transform: rotate(90deg);
}
.collapsible-content {
margin-top: 8px;
padding: 12px;
background: hsl(var(--muted) / 0.5);
border-radius: 6px;
}
/* 代码块 */
.code-block {
background: hsl(var(--muted) / 0.5);
overflow: hidden;
}
.code-language {
padding: 4px 12px;
font-size: 11px;
font-weight: 500;
background: hsl(var(--muted) / 0.5);
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
}
.code-content {
margin: 0;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 12px;
max-height: 300px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* 徽章块 */
.badge-block {
display: inline-flex;
}
/* 图片块 */
.image-block {
background: hsl(var(--muted) / 0.3);
padding: 12px;
}
.rendered-image {
max-width: 100%;
max-height: 400px;
border-radius: 4px;
}
.image-placeholder {
display: flex;
align-items: center;
gap: 8px;
color: hsl(var(--muted-foreground));
font-size: 12px;
}
/* 错误块 */
.error-block {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.2);
color: hsl(var(--destructive));
font-size: 13px;
}
.error-code {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 11px;
opacity: 0.8;
}
/* 容器块 */
.container-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.container-header {
display: flex;
align-items: center;
gap: 8px;
}
/* 消息块 */
.message-block {
border-radius: 8px;
overflow: hidden;
}
.message-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 12px;
font-weight: 500;
}
.message-content {
padding: 0 12px 12px;
font-size: 13px;
line-height: 1.6;
}
/* User 消息样式 */
.message-block.user {
background: hsl(var(--primary) / 0.08);
border: 1px solid hsl(var(--primary) / 0.2);
}
.message-block.user .message-header {
color: hsl(var(--primary));
}
/* Assistant 消息样式 */
.message-block.assistant {
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
}
.message-block.assistant .message-header {
color: hsl(var(--foreground));
}
/* System 消息样式 */
.message-block.system {
background: hsl(var(--muted) / 0.3);
border: 1px dashed hsl(var(--border));
}
.message-block.system .message-header {
color: hsl(var(--muted-foreground));
}
/* Tool 消息样式 */
.message-block.tool {
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
}
/* 工具调用块 */
.tool-block,
.tool-result-block {
background: hsl(var(--muted) / 0.3);
padding: 8px 12px;
}
.tool-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
margin-bottom: 8px;
color: hsl(var(--muted-foreground));
}
.tool-id {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 10px;
opacity: 0.6;
}
.tool-content {
margin: 0;
padding: 12px;
background: hsl(var(--muted) / 0.5);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 12px;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.tool-result-block.is-error {
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.2);
}
/* 分隔符块 */
.divider-block {
border: none;
border-top: 1px solid hsl(var(--border));
margin: 8px 0;
}
/* 标签块 */
.label-block {
display: flex;
gap: 8px;
font-size: 13px;
}
.label-key {
color: hsl(var(--muted-foreground));
}
.label-value.mono {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
</style>

View File

@@ -1,12 +1,12 @@
<template>
<div class="conversation-view-wrapper">
<div class="conversation-view-content">
<div class="max-h-[500px] overflow-y-auto">
<div class="flex flex-col gap-2 p-2">
<!-- 渲染错误提示 -->
<div
v-if="renderResult.error"
class="render-error"
class="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm"
>
<AlertCircle class="w-4 h-4" />
<AlertCircle class="w-4 h-4 shrink-0" />
<span>{{ renderResult.error }}</span>
</div>
@@ -18,16 +18,87 @@
{{ emptyMessage }}
</div>
<!-- 渲染内容块 -->
<!-- 轮次视图 -->
<template v-else>
<BlockRenderer :blocks="renderResult.blocks" />
<!-- System Prompt -->
<div
v-if="groupedConversation.system"
class="rounded-lg border border-dashed border-border bg-card overflow-hidden"
>
<div
class="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/30 transition-colors"
@click="systemExpanded = !systemExpanded"
>
<Settings class="w-4 h-4 text-muted-foreground" />
<span class="text-xs font-medium text-muted-foreground">System</span>
<span class="text-xs text-muted-foreground">
({{ groupedConversation.system.length }} 字符)
</span>
<component
:is="systemExpanded ? ChevronDown : ChevronRight"
class="w-4 h-4 text-muted-foreground ml-auto"
/>
</div>
<div
v-if="systemExpanded"
class="p-3 text-sm leading-relaxed"
>
<pre class="m-0 whitespace-pre-wrap break-words">{{ groupedConversation.system }}</pre>
</div>
</div>
<!-- 历史轮次折叠区域 -->
<div
v-if="historyTurns.length > 0"
class="flex flex-col gap-2"
>
<!-- 历史轮次标题栏 -->
<div
class="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/30 rounded-lg border border-dashed border-border transition-colors"
@click="historyExpanded = !historyExpanded"
>
<span class="text-xs font-medium text-muted-foreground">
历史轮次 ({{ historyTurns.length }})
</span>
<component
:is="historyExpanded ? ChevronDown : ChevronRight"
class="w-4 h-4 text-muted-foreground ml-auto"
/>
</div>
<!-- 历史轮次列表 -->
<div
v-if="historyExpanded"
class="flex flex-col gap-2"
>
<TurnCard
v-for="turn in historyTurns"
:key="turn.index"
:turn="turn"
:expanded="expandedTurns.has(turn.index)"
@toggle="toggleTurn(turn.index)"
/>
</div>
</div>
<!-- 最新轮次默认展开但可折叠 -->
<template v-if="latestTurns.length > 0">
<TurnCard
v-for="turn in latestTurns"
:key="turn.index"
:turn="turn"
:expanded="expandedTurns.has(turn.index)"
:is-latest="turn.index === groupedConversation.totalTurns"
@toggle="toggleTurn(turn.index)"
/>
</template>
<!-- 流式响应标记 -->
<div
v-if="renderResult.isStream"
class="stream-indicator"
class="flex items-center gap-1.5 px-3 py-2 text-xs text-muted-foreground bg-muted/30 rounded-lg w-fit"
>
<Zap class="w-3 h-3" />
<Zap class="w-4 h-4" />
<span>流式响应</span>
</div>
</template>
@@ -36,52 +107,74 @@
</template>
<script setup lang="ts">
import { AlertCircle, Zap } from 'lucide-vue-next'
import BlockRenderer from './BlockRenderer.vue'
import { ref, computed, watch, nextTick } from 'vue'
import { AlertCircle, Zap, Settings, ChevronRight, ChevronDown } from 'lucide-vue-next'
import TurnCard from './TurnCard.vue'
import type { RenderResult } from '../../conversation'
import { groupRenderBlocksIntoTurns } from '../../conversation/grouper'
defineProps<{
const props = defineProps<{
renderResult: RenderResult
emptyMessage: string
}>()
// 状态
const systemExpanded = ref(false)
const historyExpanded = ref(false)
const expandedTurns = ref<Set<number>>(new Set())
// 将渲染结果转换为分组对话
const groupedConversation = computed(() => {
return groupRenderBlocksIntoTurns(props.renderResult.blocks, props.renderResult.isStream)
})
// 历史轮次(除最后 1-2 轮外的所有轮次)
const historyTurns = computed(() => {
const turns = groupedConversation.value.turns
if (turns.length <= 2) return []
return turns.slice(0, -2)
})
// 最新轮次(最后 1-2 轮)
const latestTurns = computed(() => {
const turns = groupedConversation.value.turns
if (turns.length <= 2) return turns
return turns.slice(-2)
})
// 切换单个轮次的展开状态
function toggleTurn(index: number) {
const newSet = new Set(expandedTurns.value)
if (newSet.has(index)) {
newSet.delete(index)
} else {
newSet.add(index)
}
expandedTurns.value = newSet
}
// 初始化最新轮次为展开状态
function initExpandedTurns() {
const turns = groupedConversation.value.turns
if (turns.length <= 2) {
expandedTurns.value = new Set(turns.map(t => t.index))
} else {
expandedTurns.value = new Set(turns.slice(-2).map(t => t.index))
}
}
// 当 renderResult 变化时重置状态
watch(() => props.renderResult, () => {
systemExpanded.value = false
nextTick(() => {
initExpandedTurns()
})
}, { deep: true })
// 初始加载时也要初始化
watch(groupedConversation, () => {
if (expandedTurns.value.size === 0 && groupedConversation.value.turns.length > 0) {
initExpandedTurns()
}
}, { immediate: true })
</script>
<style scoped>
.conversation-view-wrapper {
max-height: 500px;
overflow-y: auto;
scrollbar-gutter: stable;
}
.conversation-view-content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px;
}
.render-error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 8px;
color: hsl(var(--destructive));
font-size: 13px;
}
/* 流式响应标记 */
.stream-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
font-size: 11px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.3);
border-radius: 4px;
width: fit-content;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div class="flex items-center gap-1">
<Brain
v-if="showBadge('thinking') && stats.hasThinking"
class="w-3.5 h-3.5 text-muted-foreground"
title="包含思考过程"
/>
<Wrench
v-if="showBadge('tool') && stats.hasToolUse"
class="w-3.5 h-3.5 text-muted-foreground"
title="包含工具调用"
/>
<span
v-if="showBadge('tool') && stats.toolCount > 1"
class="text-xs text-muted-foreground"
>×{{ stats.toolCount }}</span>
<ImageIcon
v-if="showBadge('image') && stats.hasImage"
class="w-3.5 h-3.5 text-muted-foreground"
title="包含图片"
/>
<AlertCircle
v-if="showBadge('error') && stats.hasError"
class="w-3.5 h-3.5 text-muted-foreground"
title="包含错误"
/>
</div>
</template>
<script setup lang="ts">
import { Brain, Wrench, Image as ImageIcon, AlertCircle } from 'lucide-vue-next'
import type { TurnStats } from '../../conversation/types'
type BadgeType = 'thinking' | 'tool' | 'image' | 'error'
const props = defineProps<{
stats: TurnStats
showOnly?: BadgeType[]
}>()
function showBadge(type: BadgeType): boolean {
if (!props.showOnly || props.showOnly.length === 0) {
return true
}
return props.showOnly.includes(type)
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div class="rounded-lg border border-dashed border-border bg-card overflow-hidden">
<!-- 标题栏 -->
<div
class="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/30 transition-colors"
@click="$emit('toggle')"
>
<span
class="text-xs font-medium shrink-0"
:class="isLatest ? 'text-primary' : 'text-muted-foreground'"
>
#{{ turn.index }}
</span>
<TurnBadges :stats="turn.stats" />
<!-- 折叠时显示摘要 -->
<template v-if="!expanded">
<span
v-if="turn.summary.user"
class="text-xs text-muted-foreground truncate"
>
{{ turn.summary.user }}
</span>
<span
v-if="turn.summary.user && turn.summary.assistant"
class="text-xs text-muted-foreground shrink-0"
></span>
<span
v-if="turn.summary.assistant"
class="text-xs text-muted-foreground truncate"
>
{{ turn.summary.assistant }}
</span>
</template>
<component
:is="expanded ? ChevronDown : ChevronRight"
class="w-4 h-4 text-muted-foreground shrink-0 ml-auto"
/>
</div>
<!-- 展开内容 -->
<div
v-if="expanded"
class="p-3 flex flex-col gap-2"
>
<!-- 用户消息 -->
<div
v-if="turn.user"
class="rounded-lg bg-primary/[0.08] border border-primary/20 overflow-hidden"
>
<div class="flex items-center gap-2 px-3 py-2 text-xs font-medium text-primary">
<User class="w-4 h-4" />
<span>User</span>
</div>
<div class="px-3 pb-3 text-sm leading-relaxed">
<BlockRenderer :blocks="userRenderBlocks" />
</div>
</div>
<!-- 助手消息 -->
<div
v-if="turn.assistant"
class="rounded-lg bg-muted/50 border border-border overflow-hidden"
>
<div class="flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground">
<Bot class="w-4 h-4" />
<span>Assistant</span>
<TurnBadges
:stats="turn.stats"
:show-only="['thinking', 'tool']"
/>
</div>
<div class="px-3 pb-3 text-sm leading-relaxed">
<BlockRenderer :blocks="assistantRenderBlocks" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { User, Bot, ChevronRight, ChevronDown } from 'lucide-vue-next'
import BlockRenderer from './BlockRenderer.vue'
import TurnBadges from './TurnBadges.vue'
import type { ConversationTurn } from '../../conversation/types'
import { contentBlocksToRenderBlocks } from '../../conversation/converter'
const props = defineProps<{
turn: ConversationTurn
expanded: boolean
isLatest?: boolean
}>()
defineEmits<{
toggle: []
}>()
const userRenderBlocks = computed(() => {
if (!props.turn.user) return []
return contentBlocksToRenderBlocks(props.turn.user.content)
})
const assistantRenderBlocks = computed(() => {
if (!props.turn.assistant) return []
return contentBlocksToRenderBlocks(props.turn.assistant.content)
})
</script>

View File

@@ -0,0 +1,73 @@
/**
* ContentBlock 到 RenderBlock 的转换器
*/
import type { ContentBlock } from './types'
import type { RenderBlock } from './render'
import {
createTextBlock,
createCollapsibleBlock,
createCodeBlock,
createToolUseBlock,
createToolResultBlock,
createImageBlock,
createErrorBlock,
} from './render'
/**
* 将单个 ContentBlock 转换为 RenderBlock
*/
export function contentBlockToRenderBlock(block: ContentBlock): RenderBlock | null {
switch (block.type) {
case 'text':
return createTextBlock(block.text)
case 'thinking':
return createCollapsibleBlock(
`思考过程 (${block.thinking.length} 字符)`,
[createCodeBlock(block.thinking)],
{ defaultOpen: false }
)
case 'tool_use': {
const input = typeof block.input === 'string'
? block.input
: JSON.stringify(block.input, null, 2)
return createToolUseBlock(block.toolName, input, block.toolId)
}
case 'tool_result': {
const content = typeof block.content === 'string'
? block.content
: JSON.stringify(block.content, null, 2)
return createToolResultBlock(content, block.isError)
}
case 'image':
return createImageBlock({
src: block.sourceType === 'base64'
? `data:${block.mimeType || 'image/png'};base64,${block.data}`
: block.url,
mimeType: block.mimeType,
alt: block.alt,
})
case 'error':
return createErrorBlock(block.message, block.code)
case 'code':
return createCodeBlock(block.code, block.language)
default:
return null
}
}
/**
* 将 ContentBlock 数组转换为 RenderBlock 数组
*/
export function contentBlocksToRenderBlocks(blocks: ContentBlock[]): RenderBlock[] {
return blocks
.map(contentBlockToRenderBlock)
.filter((b): b is RenderBlock => b !== null)
}

View File

@@ -0,0 +1,425 @@
/**
* 对话轮次分组器
* 将消息列表按轮次分组,生成摘要和统计信息
*/
import type {
ParsedMessage,
ParsedConversation,
ContentBlock,
ConversationTurn,
GroupedConversation,
TurnStats,
TurnSummary,
} from './types'
// ============================================================
// 常量配置
// ============================================================
/** 摘要最大长度 */
const SUMMARY_MAX_LENGTH = 50
/** 摘要截断时的最小保留比例 */
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 || '')
.join(' ')
.trim()
}
/**
* 生成摘要文本
* 智能截断,在词边界或标点处断开
*/
function generateSummary(text: string, maxLength: number = SUMMARY_MAX_LENGTH): string {
if (!text) return ''
// 移除多余空白
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length <= maxLength) {
return normalized
}
// 截取前 maxLength 个字符
let truncated = normalized.slice(0, maxLength)
// 尝试在词边界或标点处断开
const lastSpace = truncated.lastIndexOf(' ')
const lastPunctCN = Math.max(
truncated.lastIndexOf(''),
truncated.lastIndexOf('。'),
truncated.lastIndexOf('、'),
truncated.lastIndexOf('')
)
const lastPunctEN = Math.max(
truncated.lastIndexOf(','),
truncated.lastIndexOf('.'),
truncated.lastIndexOf(';')
)
const cutPoint = Math.max(lastSpace, lastPunctCN, lastPunctEN)
const minCutPoint = maxLength * SUMMARY_MIN_RATIO
if (cutPoint > minCutPoint) {
truncated = truncated.slice(0, cutPoint)
}
return `${truncated}...`
}
/**
* 生成消息摘要
* 优先提取文本,如果没有文本则根据内容类型生成描述
*/
function generateMessageSummary(message: ParsedMessage | undefined): string | undefined {
if (!message || message.content.length === 0) {
return undefined
}
// 优先提取文本内容
const text = extractTextFromBlocks(message.content)
if (text) {
return generateSummary(text)
}
// 没有文本时,根据内容类型生成描述
const hasThinking = message.content.some(b => b.type === 'thinking')
const hasToolUse = message.content.some(b => b.type === 'tool_use')
const hasToolResult = message.content.some(b => b.type === 'tool_result')
const hasImage = message.content.some(b => b.type === 'image')
const hasError = message.content.some(b => b.type === 'error')
const parts: string[] = []
if (hasThinking) parts.push('思考过程')
if (hasToolUse) parts.push('工具调用')
if (hasToolResult) parts.push('工具结果')
if (hasImage) parts.push('图片')
if (hasError) parts.push('错误')
return parts.length > 0 ? `[${parts.join(', ')}]` : undefined
}
// ============================================================
// 统计信息计算
// ============================================================
/**
* 计算消息的字符数
*/
function countMessageChars(message: ParsedMessage | undefined): number {
if (!message) return 0
return message.content.reduce((total, block) => {
switch (block.type) {
case 'text':
return total + (block.text?.length || 0)
case 'thinking':
return total + (block.thinking?.length || 0)
case 'code':
return total + (block.code?.length || 0)
default:
return total
}
}, 0)
}
/**
* 计算轮次统计信息
*/
function calculateTurnStats(
userMessage: ParsedMessage | undefined,
assistantMessage: ParsedMessage | undefined
): TurnStats {
const allBlocks = [
...(userMessage?.content || []),
...(assistantMessage?.content || []),
]
const toolUseBlocks = allBlocks.filter(b => b.type === 'tool_use')
return {
userChars: countMessageChars(userMessage),
assistantChars: countMessageChars(assistantMessage),
hasThinking: allBlocks.some(b => b.type === 'thinking'),
hasToolUse: toolUseBlocks.length > 0,
toolCount: toolUseBlocks.length,
hasImage: allBlocks.some(b => b.type === 'image'),
hasError: allBlocks.some(b => b.type === 'error'),
}
}
// ============================================================
// 轮次分组
// ============================================================
/**
* 将消息列表按轮次分组
*
* 分组规则:
* 1. 每个 user 消息开始一个新轮次
* 2. 紧随其后的 assistant 消息属于同一轮次
* 3. 连续的 assistant 消息合并到同一轮次
* 4. tool 消息归属于前一个 assistant 的轮次
*/
export function groupMessagesIntoTurns(messages: ParsedMessage[]): ConversationTurn[] {
const turns: ConversationTurn[] = []
let currentTurn: Partial<ConversationTurn> | null = null
let turnIndex = 0
for (const message of messages) {
if (message.role === 'user') {
// 保存之前的轮次
if (currentTurn) {
turns.push(finalizeTurn(currentTurn, turnIndex))
turnIndex++
}
// 开始新轮次
currentTurn = {
user: message,
assistant: undefined,
}
} else if (message.role === 'assistant') {
if (!currentTurn) {
// 没有 user 消息的 assistant可能是响应体
currentTurn = {
user: undefined,
assistant: message,
}
} else if (currentTurn.assistant) {
// 已有 assistant合并内容
currentTurn.assistant = {
role: 'assistant',
content: [...currentTurn.assistant.content, ...message.content],
}
} else {
currentTurn.assistant = message
}
} else if (message.role === 'tool') {
// tool 消息归属于当前轮次的 assistant
if (currentTurn?.assistant) {
currentTurn.assistant = {
role: 'assistant',
content: [...currentTurn.assistant.content, ...message.content],
}
}
}
// system 消息在分组前已被提取,这里忽略
}
// 保存最后一个轮次
if (currentTurn) {
turns.push(finalizeTurn(currentTurn, turnIndex))
}
return turns
}
/**
* 完成轮次构建,添加摘要和统计信息
*/
function finalizeTurn(
partial: Partial<ConversationTurn>,
index: number
): ConversationTurn {
const summary: TurnSummary = {
user: generateMessageSummary(partial.user),
assistant: generateMessageSummary(partial.assistant),
}
const stats = calculateTurnStats(partial.user, partial.assistant)
return {
index: index + 1, // 从 1 开始
user: partial.user,
assistant: partial.assistant,
summary,
stats,
}
}
// ============================================================
// 主入口
// ============================================================
/**
* 将解析后的对话转换为分组对话
*/
export function groupConversation(conversation: ParsedConversation): GroupedConversation {
// 过滤掉 system 消息system 已在 conversation.system 中)
const nonSystemMessages = conversation.messages.filter(m => m.role !== 'system')
const turns = groupMessagesIntoTurns(nonSystemMessages)
return {
system: conversation.system,
turns,
totalTurns: turns.length,
isStream: conversation.isStream,
apiFormat: conversation.apiFormat,
}
}
/**
* 从 RenderResult 的 blocks 中提取轮次
* 用于已渲染的结果进行轮次分组
*/
export function groupRenderBlocksIntoTurns(
blocks: import('./render').RenderBlock[],
isStream: boolean = false
): GroupedConversation {
const turns: ConversationTurn[] = []
let currentTurn: Partial<ConversationTurn> | null = null
let turnIndex = 0
let systemPrompt: string | undefined
for (const block of blocks) {
if (block.type !== 'message') continue
const messageBlock = block as import('./render').MessageRenderBlock
if (messageBlock.role === 'system') {
// 提取 system prompt
systemPrompt = extractTextFromRenderBlocks(messageBlock.content)
continue
}
if (messageBlock.role === 'user') {
// 保存之前的轮次
if (currentTurn) {
turns.push(finalizeTurnFromRenderBlocks(currentTurn, turnIndex))
turnIndex++
}
// 开始新轮次
currentTurn = {
user: renderBlockToMessage(messageBlock),
assistant: undefined,
}
} else if (messageBlock.role === 'assistant') {
if (!currentTurn) {
currentTurn = {
user: undefined,
assistant: renderBlockToMessage(messageBlock),
}
} else if (currentTurn.assistant) {
// 合并 assistant 内容
const existingContent = currentTurn.assistant.content
const newContent = renderBlockToMessage(messageBlock).content
currentTurn.assistant = {
role: 'assistant',
content: [...existingContent, ...newContent],
}
} else {
currentTurn.assistant = renderBlockToMessage(messageBlock)
}
}
}
// 保存最后一个轮次
if (currentTurn) {
turns.push(finalizeTurnFromRenderBlocks(currentTurn, turnIndex))
}
return {
system: systemPrompt,
turns,
totalTurns: turns.length,
isStream,
apiFormat: 'unknown',
}
}
/**
* 从渲染块中提取文本
*/
function extractTextFromRenderBlocks(blocks: import('./render').RenderBlock[]): string {
return blocks
.filter(b => b.type === 'text')
.map(b => (b as any).content || '')
.join(' ')
.trim()
}
/**
* 将 MessageRenderBlock 转换为 ParsedMessage
*/
function renderBlockToMessage(block: import('./render').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 || '' })
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 })
}
break
case 'tool_use':
content.push({
type: 'tool_use',
toolId: (child as any).toolId || '',
toolName: (child as any).toolName || '',
input: (child as any).input || '',
})
break
case 'tool_result':
content.push({
type: 'tool_result',
toolUseId: '',
content: (child as any).content || '',
isError: (child as any).isError,
})
break
case 'image':
content.push({
type: 'image',
sourceType: 'url',
url: (child as any).src,
mimeType: (child as any).mimeType,
alt: (child as any).alt,
})
break
case 'error':
content.push({
type: 'error',
message: (child as any).message || '',
code: (child as any).code,
})
break
}
}
return {
role: block.role as any,
content,
}
}
/**
* 从渲染块构建的轮次完成
*/
function finalizeTurnFromRenderBlocks(
partial: Partial<ConversationTurn>,
index: number
): ConversationTurn {
return finalizeTurn(partial, index)
}

View File

@@ -38,6 +38,11 @@ export type {
FormatDetector,
RequestParser,
ResponseParser,
// 轮次相关类型
TurnStats,
TurnSummary,
ConversationTurn,
GroupedConversation,
} from './types'
// 导出渲染类型
@@ -102,3 +107,16 @@ export {
renderRequest,
renderResponse,
} from './registry'
// 导出轮次分组
export {
groupMessagesIntoTurns,
groupConversation,
groupRenderBlocksIntoTurns,
} from './grouper'
// 导出转换器
export {
contentBlockToRenderBlock,
contentBlocksToRenderBlocks,
} from './converter'

View File

@@ -285,3 +285,61 @@ export function createEmptyConversation(
export function createMessage(role: MessageRole, content: ContentBlock[]): ParsedMessage {
return { role, content }
}
// ============================================================
// 对话轮次相关类型
// ============================================================
/** 轮次统计信息 */
export interface TurnStats {
/** 用户消息字符数 */
userChars: number
/** 助手消息字符数 */
assistantChars: number
/** 是否包含思考过程 */
hasThinking: boolean
/** 是否包含工具调用 */
hasToolUse: boolean
/** 工具调用次数 */
toolCount: number
/** 是否包含图片 */
hasImage: boolean
/** 是否包含错误 */
hasError: boolean
}
/** 轮次摘要 */
export interface TurnSummary {
/** 用户消息摘要 */
user?: string
/** 助手消息摘要 */
assistant?: string
}
/** 对话轮次 */
export interface ConversationTurn {
/** 轮次序号(从 1 开始) */
index: number
/** 用户消息 */
user?: ParsedMessage
/** 助手回复 */
assistant?: ParsedMessage
/** 摘要文本(用于折叠预览) */
summary: TurnSummary
/** 统计信息 */
stats: TurnStats
}
/** 分组后的对话结构 */
export interface GroupedConversation {
/** 系统提示词 */
system?: string
/** 对话轮次列表 */
turns: ConversationTurn[]
/** 总轮次数 */
totalTurns: number
/** 是否为流式响应 */
isStream: boolean
/** 原始 API 格式 */
apiFormat: ApiFormat
}