mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 实现对话轮次折叠视图
- 新增轮次分组逻辑,将消息按 user/assistant 配对分组 - 新增 TurnCard 组件,支持折叠/展开单个轮次 - 新增 TurnBadges 组件,显示思考/工具/图片/错误图标 - 历史轮次默认折叠,最新轮次默认展开 - 折叠时显示用户和助手消息摘要 - 统一 UI 设计规范:边距、字体、图标、圆角
This commit is contained in:
@@ -7,25 +7,22 @@
|
|||||||
<!-- 文本块 -->
|
<!-- 文本块 -->
|
||||||
<pre
|
<pre
|
||||||
v-if="block.type === 'text'"
|
v-if="block.type === 'text'"
|
||||||
class="m-0 text-[13px] leading-relaxed rounded-md"
|
class="m-0 text-sm leading-relaxed whitespace-pre-wrap break-words"
|
||||||
:class="[
|
:class="block.className"
|
||||||
block.className,
|
|
||||||
block.preWrap !== false ? 'whitespace-pre-wrap break-words' : ''
|
|
||||||
]"
|
|
||||||
>{{ block.content }}</pre>
|
>{{ block.content }}</pre>
|
||||||
|
|
||||||
<!-- 可折叠块 -->
|
<!-- 可折叠块 -->
|
||||||
<details
|
<details
|
||||||
v-else-if="block.type === 'collapsible'"
|
v-else-if="block.type === 'collapsible'"
|
||||||
class="group cursor-pointer bg-muted/30 rounded-md px-3 py-2"
|
class="group cursor-pointer bg-muted/30 rounded-lg px-3 py-2"
|
||||||
:class="block.className"
|
:class="block.className"
|
||||||
:open="block.defaultOpen"
|
:open="block.defaultOpen"
|
||||||
>
|
>
|
||||||
<summary class="flex items-center gap-1 list-none select-none [&::-webkit-details-marker]:hidden">
|
<summary class="flex items-center gap-2 list-none select-none [&::-webkit-details-marker]:hidden">
|
||||||
<ChevronRight class="w-4 h-4 transition-transform duration-200 group-open:rotate-90" />
|
<ChevronRight class="w-4 h-4 text-muted-foreground transition-transform duration-200 group-open:rotate-90" />
|
||||||
<span class="text-muted-foreground">{{ block.title }}</span>
|
<span class="text-xs text-muted-foreground">{{ block.title }}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-2 p-3 bg-muted/50 rounded-md">
|
<div class="mt-2 p-3 bg-muted/50 rounded-lg">
|
||||||
<BlockRenderer :blocks="block.content" />
|
<BlockRenderer :blocks="block.content" />
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -33,11 +30,11 @@
|
|||||||
<!-- 代码块 -->
|
<!-- 代码块 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="block.type === 'code'"
|
v-else-if="block.type === 'code'"
|
||||||
class="bg-muted/50 rounded-md overflow-hidden"
|
class="bg-muted/50 rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="block.language"
|
v-if="block.language"
|
||||||
class="px-3 py-1 text-[11px] font-medium bg-muted/50 text-muted-foreground border-b border-border"
|
class="px-3 py-2 text-xs font-medium bg-muted/50 text-muted-foreground border-b border-border"
|
||||||
>
|
>
|
||||||
{{ block.language }}
|
{{ block.language }}
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +48,7 @@
|
|||||||
<Badge
|
<Badge
|
||||||
v-else-if="block.type === 'badge'"
|
v-else-if="block.type === 'badge'"
|
||||||
:variant="block.variant || 'secondary'"
|
:variant="block.variant || 'secondary'"
|
||||||
class="inline-flex w-fit"
|
class="w-fit"
|
||||||
>
|
>
|
||||||
{{ block.label }}
|
{{ block.label }}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -59,19 +56,19 @@
|
|||||||
<!-- 图片块 -->
|
<!-- 图片块 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="block.type === 'image'"
|
v-else-if="block.type === 'image'"
|
||||||
class="bg-muted/30 p-3 rounded-md"
|
class="bg-muted/30 p-3 rounded-lg"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="block.src"
|
v-if="block.src"
|
||||||
:src="block.src"
|
:src="block.src"
|
||||||
:alt="block.alt || '图片'"
|
:alt="block.alt || '图片'"
|
||||||
class="max-w-full max-h-[400px] rounded"
|
class="max-w-full max-h-[400px] rounded-lg"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="flex items-center gap-2 text-muted-foreground text-xs"
|
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>
|
<span>{{ block.mimeType || block.alt || '图片' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,13 +76,13 @@
|
|||||||
<!-- 错误块 -->
|
<!-- 错误块 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="block.type === 'error'"
|
v-else-if="block.type === 'error'"
|
||||||
class="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-md text-destructive text-[13px]"
|
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 shrink-0" />
|
<AlertCircle class="w-4 h-4 shrink-0" />
|
||||||
<span>{{ block.message }}</span>
|
<span>{{ block.message }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="block.code"
|
v-if="block.code"
|
||||||
class="font-mono text-[11px] opacity-80"
|
class="font-mono text-xs opacity-80"
|
||||||
>{{ block.code }}</span>
|
>{{ block.code }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,10 +98,8 @@
|
|||||||
>
|
>
|
||||||
<BlockRenderer :blocks="block.header" />
|
<BlockRenderer :blocks="block.header" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<BlockRenderer :blocks="block.children" />
|
<BlockRenderer :blocks="block.children" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 消息块 -->
|
<!-- 消息块 -->
|
||||||
<div
|
<div
|
||||||
@@ -113,12 +108,12 @@
|
|||||||
:class="messageBlockClasses[block.role] || messageBlockClasses.default"
|
:class="messageBlockClasses[block.role] || messageBlockClasses.default"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1.5 px-3 py-2 text-xs font-medium"
|
class="flex items-center gap-2 px-3 py-2 text-xs font-medium"
|
||||||
:class="messageHeaderClasses[block.role] || messageHeaderClasses.default"
|
:class="messageHeaderClasses[block.role] || messageHeaderClasses.default"
|
||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
:is="getRoleIcon(block.role)"
|
:is="getRoleIcon(block.role)"
|
||||||
class="w-3.5 h-3.5"
|
class="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span>{{ block.roleLabel || getRoleLabel(block.role) }}</span>
|
<span>{{ block.roleLabel || getRoleLabel(block.role) }}</span>
|
||||||
<template v-if="block.badges">
|
<template v-if="block.badges">
|
||||||
@@ -126,13 +121,12 @@
|
|||||||
v-for="(badge, badgeIndex) in block.badges"
|
v-for="(badge, badgeIndex) in block.badges"
|
||||||
:key="badgeIndex"
|
:key="badgeIndex"
|
||||||
:variant="badge.variant || 'secondary'"
|
:variant="badge.variant || 'secondary'"
|
||||||
class="ml-2 text-xs"
|
|
||||||
>
|
>
|
||||||
{{ badge.label }}
|
{{ badge.label }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 pb-3 text-[13px] leading-relaxed">
|
<div class="px-3 pb-3 text-sm leading-relaxed">
|
||||||
<BlockRenderer :blocks="block.content" />
|
<BlockRenderer :blocks="block.content" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,37 +134,36 @@
|
|||||||
<!-- 工具调用块 -->
|
<!-- 工具调用块 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="block.type === 'tool_use'"
|
v-else-if="block.type === 'tool_use'"
|
||||||
class="bg-muted/30 px-3 py-2 rounded-md"
|
class="bg-muted/30 rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5 text-xs font-medium mb-2 text-muted-foreground">
|
<div class="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
<Wrench class="w-3 h-3" />
|
<Wrench class="w-4 h-4" />
|
||||||
<span>{{ block.toolName }}</span>
|
<span>{{ block.toolName }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="block.toolId"
|
v-if="block.toolId"
|
||||||
class="font-mono text-[10px] opacity-60"
|
class="font-mono text-xs opacity-60"
|
||||||
>{{ block.toolId }}</span>
|
>{{ block.toolId }}</span>
|
||||||
</div>
|
</div>
|
||||||
<pre class="m-0 p-3 bg-muted/50 rounded-md font-mono text-xs max-h-[200px] overflow-y-auto whitespace-pre-wrap break-words">{{ 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>
|
||||||
|
|
||||||
<!-- 工具结果块 -->
|
<!-- 工具结果块 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="block.type === 'tool_result'"
|
v-else-if="block.type === 'tool_result'"
|
||||||
class="px-3 py-2 rounded-md"
|
class="rounded-lg overflow-hidden"
|
||||||
:class="block.isError ? 'bg-destructive/10 border border-destructive/20' : 'bg-muted/30'"
|
:class="block.isError ? 'bg-destructive/10 border border-destructive/20' : 'bg-muted/30'"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5 text-xs font-medium mb-2 text-muted-foreground">
|
<div class="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
<FileText class="w-3 h-3" />
|
<FileText class="w-4 h-4" />
|
||||||
<span>工具结果</span>
|
<span>工具结果</span>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="block.isError"
|
v-if="block.isError"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
class="ml-2 text-xs"
|
|
||||||
>
|
>
|
||||||
错误
|
错误
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<pre class="m-0 p-3 bg-muted/50 rounded-md font-mono text-xs max-h-[200px] overflow-y-auto whitespace-pre-wrap break-words">{{ 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>
|
</div>
|
||||||
|
|
||||||
<!-- 分隔符块 -->
|
<!-- 分隔符块 -->
|
||||||
@@ -182,7 +175,7 @@
|
|||||||
<!-- 标签块 -->
|
<!-- 标签块 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="block.type === 'label'"
|
v-else-if="block.type === 'label'"
|
||||||
class="flex gap-2 text-[13px]"
|
class="flex gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">{{ block.label }}:</span>
|
<span class="text-muted-foreground">{{ block.label }}:</span>
|
||||||
<span :class="block.mono ? 'font-mono' : ''">{{ block.value }}</span>
|
<span :class="block.mono ? 'font-mono' : ''">{{ block.value }}</span>
|
||||||
@@ -200,7 +193,6 @@ defineProps<{
|
|||||||
blocks: RenderBlock[]
|
blocks: RenderBlock[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 消息块样式映射
|
|
||||||
const messageBlockClasses: Record<string, string> = {
|
const messageBlockClasses: Record<string, string> = {
|
||||||
user: 'bg-primary/[0.08] border border-primary/20',
|
user: 'bg-primary/[0.08] border border-primary/20',
|
||||||
assistant: 'bg-muted/50 border border-border',
|
assistant: 'bg-muted/50 border border-border',
|
||||||
@@ -209,7 +201,6 @@ const messageBlockClasses: Record<string, string> = {
|
|||||||
default: 'bg-muted/30 border border-border',
|
default: 'bg-muted/30 border border-border',
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息头部样式映射
|
|
||||||
const messageHeaderClasses: Record<string, string> = {
|
const messageHeaderClasses: Record<string, string> = {
|
||||||
user: 'text-primary',
|
user: 'text-primary',
|
||||||
assistant: 'text-foreground',
|
assistant: 'text-foreground',
|
||||||
@@ -220,31 +211,21 @@ const messageHeaderClasses: Record<string, string> = {
|
|||||||
|
|
||||||
const getRoleIcon = (role: string) => {
|
const getRoleIcon = (role: string) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'user':
|
case 'user': return User
|
||||||
return User
|
case 'assistant': return Bot
|
||||||
case 'assistant':
|
case 'system': return Settings
|
||||||
return Bot
|
case 'tool': return Wrench
|
||||||
case 'system':
|
default: return User
|
||||||
return Settings
|
|
||||||
case 'tool':
|
|
||||||
return Wrench
|
|
||||||
default:
|
|
||||||
return User
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRoleLabel = (role: string) => {
|
const getRoleLabel = (role: string) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'user':
|
case 'user': return 'User'
|
||||||
return 'User'
|
case 'assistant': return 'Assistant'
|
||||||
case 'assistant':
|
case 'system': return 'System'
|
||||||
return 'Assistant'
|
case 'tool': return 'Tool'
|
||||||
case 'system':
|
default: return role
|
||||||
return 'System'
|
|
||||||
case 'tool':
|
|
||||||
return 'Tool'
|
|
||||||
default:
|
|
||||||
return role
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-h-[500px] overflow-y-auto scrollbar-gutter-stable">
|
<div class="max-h-[500px] overflow-y-auto">
|
||||||
<div class="flex flex-col gap-3 p-1">
|
<div class="flex flex-col gap-2 p-2">
|
||||||
<!-- 渲染错误提示 -->
|
<!-- 渲染错误提示 -->
|
||||||
<div
|
<div
|
||||||
v-if="renderResult.error"
|
v-if="renderResult.error"
|
||||||
class="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/30 rounded-lg text-destructive text-[13px]"
|
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 shrink-0" />
|
<AlertCircle class="w-4 h-4 shrink-0" />
|
||||||
<span>{{ renderResult.error }}</span>
|
<span>{{ renderResult.error }}</span>
|
||||||
@@ -18,16 +18,87 @@
|
|||||||
{{ emptyMessage }}
|
{{ emptyMessage }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 渲染内容块 -->
|
<!-- 轮次视图 -->
|
||||||
<template v-else>
|
<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
|
<div
|
||||||
v-if="renderResult.isStream"
|
v-if="renderResult.isStream"
|
||||||
class="flex items-center gap-1 px-2 py-1 text-[11px] text-muted-foreground bg-muted/30 rounded w-fit"
|
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>
|
<span>流式响应</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -36,12 +107,74 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AlertCircle, Zap } from 'lucide-vue-next'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import BlockRenderer from './BlockRenderer.vue'
|
import { AlertCircle, Zap, Settings, ChevronRight, ChevronDown } from 'lucide-vue-next'
|
||||||
|
import TurnCard from './TurnCard.vue'
|
||||||
import type { RenderResult } from '../../conversation'
|
import type { RenderResult } from '../../conversation'
|
||||||
|
import { groupRenderBlocksIntoTurns } from '../../conversation/grouper'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
renderResult: RenderResult
|
renderResult: RenderResult
|
||||||
emptyMessage: string
|
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>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
73
frontend/src/features/usage/conversation/converter.ts
Normal file
73
frontend/src/features/usage/conversation/converter.ts
Normal 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)
|
||||||
|
}
|
||||||
425
frontend/src/features/usage/conversation/grouper.ts
Normal file
425
frontend/src/features/usage/conversation/grouper.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -38,6 +38,11 @@ export type {
|
|||||||
FormatDetector,
|
FormatDetector,
|
||||||
RequestParser,
|
RequestParser,
|
||||||
ResponseParser,
|
ResponseParser,
|
||||||
|
// 轮次相关类型
|
||||||
|
TurnStats,
|
||||||
|
TurnSummary,
|
||||||
|
ConversationTurn,
|
||||||
|
GroupedConversation,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
// 导出渲染类型
|
// 导出渲染类型
|
||||||
@@ -102,3 +107,16 @@ export {
|
|||||||
renderRequest,
|
renderRequest,
|
||||||
renderResponse,
|
renderResponse,
|
||||||
} from './registry'
|
} from './registry'
|
||||||
|
|
||||||
|
// 导出轮次分组
|
||||||
|
export {
|
||||||
|
groupMessagesIntoTurns,
|
||||||
|
groupConversation,
|
||||||
|
groupRenderBlocksIntoTurns,
|
||||||
|
} from './grouper'
|
||||||
|
|
||||||
|
// 导出转换器
|
||||||
|
export {
|
||||||
|
contentBlockToRenderBlock,
|
||||||
|
contentBlocksToRenderBlocks,
|
||||||
|
} from './converter'
|
||||||
|
|||||||
@@ -285,3 +285,61 @@ export function createEmptyConversation(
|
|||||||
export function createMessage(role: MessageRole, content: ContentBlock[]): ParsedMessage {
|
export function createMessage(role: MessageRole, content: ContentBlock[]): ParsedMessage {
|
||||||
return { role, content }
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user