refactor: 重构请求详情对话视图的渲染架构

- 新增 BlockRenderer 组件,支持递归渲染多种内容块类型
- 将 ConversationView 改为使用 RenderResult 和 BlockRenderer
- 删除旧的 messageExtractor 工具,改用 conversationParser 库
- 更新 RequestDetailDrawer 中的对话解析和复制逻辑
This commit is contained in:
fawney19
2026-01-16 02:53:47 +08:00
parent 1366c0b973
commit 02770d56df
5 changed files with 630 additions and 858 deletions
@@ -450,7 +450,7 @@
<!-- 对话视图 -->
<ConversationView
v-if="contentViewMode === 'conversation'"
:conversation="requestConversation"
:render-result="requestRenderResult"
empty-message="无请求体信息"
/>
<!-- JSON 视图 -->
@@ -478,7 +478,7 @@
<!-- 对话视图 -->
<ConversationView
v-if="contentViewMode === 'conversation'"
:conversation="responseConversation"
:render-result="responseRenderResult"
empty-message="无响应体信息"
/>
<!-- JSON 视图 -->
@@ -533,15 +533,13 @@ import JsonContent from './RequestDetailDrawer/JsonContent.vue'
import ConversationView from './RequestDetailDrawer/ConversationView.vue'
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
// 消息提取工具
// 对话解析器
import {
detectApiFormat,
extractRequestMessages,
extractResponseMessages,
formatConversationAsText,
type ExtractedConversation,
type ApiFormat,
} from '../utils/messageExtractor'
renderRequest,
renderResponse,
type RenderResult,
type RenderBlock,
} from '../lib/conversationParser'
const props = defineProps<{
isOpen: boolean
@@ -600,30 +598,20 @@ const currentHeaderData = computed(() => {
: detail.value.provider_request_headers
})
// 检测到的 API 格式
const detectedApiFormat = computed<ApiFormat>(() => {
if (!detail.value) return 'unknown'
return detectApiFormat(
detail.value.request_body,
detail.value.response_body,
detail.value.api_format
)
})
// 请求体对话提取结果
const requestConversation = computed<ExtractedConversation>(() => {
// 请求体渲染结果
const requestRenderResult = computed<RenderResult>(() => {
if (!detail.value?.request_body) {
return { messages: [], isStream: false }
return { blocks: [], isStream: false }
}
return extractRequestMessages(detail.value.request_body, detectedApiFormat.value)
return renderRequest(detail.value.request_body, detail.value.response_body, detail.value.api_format)
})
// 响应体对话提取结果
const responseConversation = computed<ExtractedConversation>(() => {
// 响应体渲染结果
const responseRenderResult = computed<RenderResult>(() => {
if (!detail.value?.response_body) {
return { messages: [], isStream: false }
return { blocks: [], isStream: false }
}
return extractResponseMessages(detail.value.response_body, detectedApiFormat.value)
return renderResponse(detail.value.response_body, detail.value.request_body, detail.value.api_format)
})
// 当前 Tab 是否支持对话视图
@@ -634,12 +622,12 @@ const supportsConversationView = computed(() => {
// 当前对话数据是否有效(用于禁用按钮)
const hasValidConversation = computed(() => {
if (activeTab.value === 'request-body') {
return !requestConversation.value.parseError &&
(requestConversation.value.system || requestConversation.value.messages.length > 0)
return !requestRenderResult.value.error &&
requestRenderResult.value.blocks.length > 0
}
if (activeTab.value === 'response-body') {
return !responseConversation.value.parseError &&
responseConversation.value.messages.length > 0
return !responseRenderResult.value.error &&
responseRenderResult.value.blocks.length > 0
}
return false
})
@@ -880,6 +868,61 @@ function getTierRangeText(tier: { up_to?: number | null }, index: number, tiers:
return `> ${formatNumber(start)} tokens`
}
/** 将 RenderResult 格式化为可复制的文本 */
function formatRenderResultAsText(result: RenderResult): string {
if (result.error) {
return `[Error] ${result.error}`
}
const parts: string[] = []
for (const block of result.blocks) {
const text = formatBlockAsText(block)
if (text) {
parts.push(text)
}
}
return parts.join('\n\n---\n\n')
}
/** 将单个 RenderBlock 格式化为文本 */
function formatBlockAsText(block: RenderBlock): string {
switch (block.type) {
case 'text':
return block.content
case 'code':
return block.language
? `\`\`\`${block.language}\n${block.code}\n\`\`\``
: `\`\`\`\n${block.code}\n\`\`\``
case 'collapsible':
return `[${block.title}]\n${block.content.map(formatBlockAsText).filter(Boolean).join('\n')}`
case 'error':
return `[Error${block.code ? `: ${block.code}` : ''}] ${block.message}`
case 'image':
return `[Image: ${block.mimeType || block.alt || 'unknown'}]`
case 'tool_use':
return `[Tool: ${block.toolName}]\n${block.input}`
case 'tool_result':
return `[Tool Result${block.isError ? ' (Error)' : ''}]\n${block.content}`
case 'message': {
const roleLabel = block.roleLabel || block.role
const contentText = block.content.map(formatBlockAsText).filter(Boolean).join('\n\n')
return `[${roleLabel}]\n${contentText}`
}
case 'container':
return block.children.map(formatBlockAsText).filter(Boolean).join('\n')
case 'label':
return `${block.label}: ${block.value}`
case 'divider':
return '---'
case 'badge':
return ''
default:
return ''
}
}
// 复制内容(支持 JSON 和对话两种模式)
function copyContent(tabName: string) {
if (!detail.value) return
@@ -890,9 +933,9 @@ function copyContent(tabName: string) {
// 对话视图模式:复制格式化的对话文本
if (contentViewMode.value === 'conversation') {
if (tabName === 'request-body') {
textToCopy = formatConversationAsText(requestConversation.value)
textToCopy = formatRenderResultAsText(requestRenderResult.value)
} else if (tabName === 'response-body') {
textToCopy = formatConversationAsText(responseConversation.value)
textToCopy = formatRenderResultAsText(responseRenderResult.value)
}
} else {
// JSON 视图模式:复制原始 JSON
@@ -0,0 +1,491 @@
<template>
<div class="block-renderer">
<template
v-for="(block, index) in blocks"
:key="index"
>
<!-- 文本块 -->
<pre
v-if="block.type === 'text'"
class="render-block text-block"
:class="[block.className, { 'pre-wrap': block.preWrap !== false }]"
>{{ block.content }}</pre>
<!-- 可折叠块 -->
<details
v-else-if="block.type === 'collapsible'"
class="render-block collapsible-block"
: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>
<div class="collapsible-content">
<BlockRenderer :blocks="block.content" />
</div>
</details>
<!-- 代码块 -->
<div
v-else-if="block.type === 'code'"
class="render-block code-block"
>
<div
v-if="block.language"
class="code-language"
>
{{ block.language }}
</div>
<pre
class="code-content"
:style="block.maxHeight ? { maxHeight: `${block.maxHeight}px` } : {}"
>{{ block.code }}</pre>
</div>
<!-- 徽章块 -->
<Badge
v-else-if="block.type === 'badge'"
:variant="block.variant || 'secondary'"
class="render-block badge-block"
>
{{ block.label }}
</Badge>
<!-- 图片块 -->
<div
v-else-if="block.type === 'image'"
class="render-block image-block"
>
<img
v-if="block.src"
:src="block.src"
:alt="block.alt || '图片'"
class="rendered-image"
>
<div
v-else
class="image-placeholder"
>
<ImageIcon class="w-6 h-6" />
<span>{{ block.mimeType || block.alt || '图片' }}</span>
</div>
</div>
<!-- 错误块 -->
<div
v-else-if="block.type === 'error'"
class="render-block error-block"
>
<AlertCircle class="w-4 h-4" />
<span>{{ block.message }}</span>
<span
v-if="block.code"
class="error-code"
>{{ block.code }}</span>
</div>
<!-- 容器块 -->
<div
v-else-if="block.type === 'container'"
class="render-block container-block"
:class="block.className"
>
<div
v-if="block.header"
class="container-header"
>
<BlockRenderer :blocks="block.header" />
</div>
<div class="container-content">
<BlockRenderer :blocks="block.children" />
</div>
</div>
<!-- 消息块 -->
<div
v-else-if="block.type === 'message'"
class="render-block message-block"
:class="block.role"
>
<div class="message-header">
<component
:is="getRoleIcon(block.role)"
class="w-3.5 h-3.5"
/>
<span>{{ block.roleLabel || getRoleLabel(block.role) }}</span>
<template v-if="block.badges">
<Badge
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">
<BlockRenderer :blocks="block.content" />
</div>
</div>
<!-- 工具调用块 -->
<div
v-else-if="block.type === 'tool_use'"
class="render-block tool-block"
>
<div class="tool-header">
<Wrench class="w-3 h-3" />
<span>{{ block.toolName }}</span>
<span
v-if="block.toolId"
class="tool-id"
>{{ block.toolId }}</span>
</div>
<pre class="tool-content">{{ block.input }}</pre>
</div>
<!-- 工具结果块 -->
<div
v-else-if="block.type === 'tool_result'"
class="render-block tool-result-block"
:class="{ 'is-error': block.isError }"
>
<div class="tool-header">
<FileText class="w-3 h-3" />
<span>工具结果</span>
<Badge
v-if="block.isError"
variant="destructive"
class="ml-2 text-xs"
>
错误
</Badge>
</div>
<pre class="tool-content">{{ block.content }}</pre>
</div>
<!-- 分隔符块 -->
<hr
v-else-if="block.type === 'divider'"
class="render-block divider-block"
>
<!-- 标签块 -->
<div
v-else-if="block.type === 'label'"
class="render-block label-block"
>
<span class="label-key">{{ block.label }}:</span>
<span
class="label-value"
:class="{ mono: block.mono }"
>{{ block.value }}</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { User, Bot, Settings, Wrench, AlertCircle, ChevronRight, FileText, Image as ImageIcon } from 'lucide-vue-next'
import Badge from '@/components/ui/badge.vue'
import type { RenderBlock } from '../../lib/conversationParser'
defineProps<{
blocks: RenderBlock[]
}>()
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
}
}
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
}
}
</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>
@@ -1,171 +1,66 @@
<template>
<div class="conversation-view">
<!-- 解析错误提示 -->
<div
v-if="conversation.parseError"
class="parse-error"
>
<AlertCircle class="w-4 h-4" />
<span>{{ conversation.parseError }}</span>
<div class="conversation-view-wrapper">
<div class="conversation-view-content">
<!-- 渲染错误提示 -->
<div
v-if="renderResult.error"
class="render-error"
>
<AlertCircle class="w-4 h-4" />
<span>{{ renderResult.error }}</span>
</div>
<!-- 空内容提示 -->
<div
v-else-if="renderResult.blocks.length === 0"
class="text-sm text-muted-foreground"
>
{{ emptyMessage }}
</div>
<!-- 渲染内容块 -->
<template v-else>
<BlockRenderer :blocks="renderResult.blocks" />
<!-- 流式响应标记 -->
<div
v-if="renderResult.isStream"
class="stream-indicator"
>
<Zap class="w-3 h-3" />
<span>流式响应</span>
</div>
</template>
</div>
<!-- 空内容提示 -->
<div
v-else-if="!conversation.system && conversation.messages.length === 0"
class="text-sm text-muted-foreground"
>
{{ emptyMessage }}
</div>
<template v-else>
<!-- System Prompt -->
<div
v-if="conversation.system"
class="message-block system"
>
<div class="message-header">
<Settings class="w-3.5 h-3.5" />
<span>System</span>
</div>
<div class="message-content">
<pre class="whitespace-pre-wrap">{{ conversation.system }}</pre>
</div>
</div>
<!-- 对话消息 -->
<div
v-for="(msg, index) in conversation.messages"
:key="index"
class="message-block"
:class="[msg.role, msg.type]"
>
<div class="message-header">
<component
:is="getRoleIcon(msg.role)"
class="w-3.5 h-3.5"
/>
<span>{{ getRoleLabel(msg.role) }}</span>
<Badge
v-if="msg.type === 'thinking'"
variant="secondary"
class="ml-2 text-xs"
>
思考
</Badge>
<Badge
v-if="msg.type === 'tool_use'"
variant="outline"
class="ml-2 text-xs"
>
{{ msg.metadata?.toolName || '工具调用' }}
</Badge>
<Badge
v-if="msg.type === 'tool_result'"
variant="outline"
class="ml-2 text-xs"
>
工具结果
</Badge>
<Badge
v-if="msg.type === 'image'"
variant="secondary"
class="ml-2 text-xs"
>
图片
</Badge>
</div>
<div class="message-content">
<!-- 思考过程:可折叠 -->
<details
v-if="msg.type === 'thinking'"
class="thinking-details"
>
<summary class="thinking-summary">
<ChevronRight class="w-4 h-4 chevron" />
<span class="text-muted-foreground">点击展开思考过程 ({{ msg.content.length }} 字符)</span>
</summary>
<pre class="thinking-content whitespace-pre-wrap">{{ msg.content }}</pre>
</details>
<!-- 工具调用/结果:代码块样式 -->
<pre
v-else-if="msg.type === 'tool_use' || msg.type === 'tool_result'"
class="tool-content whitespace-pre-wrap"
>{{ msg.content }}</pre>
<!-- 普通文本 -->
<pre
v-else
class="whitespace-pre-wrap"
>{{ msg.content }}</pre>
</div>
</div>
<!-- 流式响应标记 -->
<div
v-if="conversation.isStream"
class="stream-indicator"
>
<Zap class="w-3 h-3" />
<span>流式响应</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { User, Bot, Settings, Wrench, AlertCircle, ChevronRight, Zap } from 'lucide-vue-next'
import Badge from '@/components/ui/badge.vue'
import type { ExtractedConversation, MessageRole } from '../../utils/messageExtractor'
import { AlertCircle, Zap } from 'lucide-vue-next'
import BlockRenderer from './BlockRenderer.vue'
import type { RenderResult } from '../../lib/conversationParser'
defineProps<{
conversation: ExtractedConversation
renderResult: RenderResult
emptyMessage: string
}>()
const getRoleIcon = (role: MessageRole) => {
switch (role) {
case 'user':
return User
case 'assistant':
return Bot
case 'system':
return Settings
case 'tool':
return Wrench
default:
return User
}
}
const getRoleLabel = (role: MessageRole) => {
switch (role) {
case 'user':
return 'User'
case 'assistant':
return 'Assistant'
case 'system':
return 'System'
case 'tool':
return 'Tool'
default:
return role
}
}
</script>
<style scoped>
.conversation-view {
.conversation-view-wrapper {
max-height: 500px;
overflow-y: auto;
scrollbar-gutter: stable;
}
.conversation-view-content {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 500px;
overflow-y: auto;
padding: 4px;
}
.parse-error {
.render-error {
display: flex;
align-items: center;
gap: 8px;
@@ -177,120 +72,6 @@ const getRoleLabel = (role: MessageRole) => {
font-size: 13px;
}
.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;
}
.message-content pre {
margin: 0;
font-family: inherit;
}
/* 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));
}
/* 思考过程样式 */
.message-block.thinking {
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
}
.thinking-details {
cursor: pointer;
}
.thinking-summary {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0;
list-style: none;
user-select: none;
}
.thinking-summary::-webkit-details-marker {
display: none;
}
.thinking-summary .chevron {
transition: transform 0.2s;
}
.thinking-details[open] .chevron {
transform: rotate(90deg);
}
.thinking-content {
margin-top: 8px;
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: 300px;
overflow-y: auto;
}
/* 工具调用样式 */
.tool-content {
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;
}
/* 流式响应标记 */
.stream-indicator {
display: flex;