mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 重构请求详情对话视图的渲染架构
- 新增 BlockRenderer 组件,支持递归渲染多种内容块类型 - 将 ConversationView 改为使用 RenderResult 和 BlockRenderer - 删除旧的 messageExtractor 工具,改用 conversationParser 库 - 更新 RequestDetailDrawer 中的对话解析和复制逻辑
This commit is contained in:
@@ -69,7 +69,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 请求头规则配置 -->
|
<!-- 请求头规则配置 -->
|
||||||
<Collapsible v-model:open="rulesExpanded" class="mt-2">
|
<Collapsible
|
||||||
|
v-model:open="rulesExpanded"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
<CollapsibleTrigger as-child>
|
<CollapsibleTrigger as-child>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -80,7 +83,10 @@
|
|||||||
:class="{ 'rotate-90': rulesExpanded }"
|
:class="{ 'rotate-90': rulesExpanded }"
|
||||||
/>
|
/>
|
||||||
<span>请求头规则</span>
|
<span>请求头规则</span>
|
||||||
<span v-if="editingRules.length > 0" class="text-primary">
|
<span
|
||||||
|
v-if="editingRules.length > 0"
|
||||||
|
class="text-primary"
|
||||||
|
>
|
||||||
({{ editingRules.length }})
|
({{ editingRules.length }})
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -94,17 +100,23 @@
|
|||||||
>
|
>
|
||||||
<!-- 操作类型选择 -->
|
<!-- 操作类型选择 -->
|
||||||
<Select
|
<Select
|
||||||
:model-value="rule.action"
|
|
||||||
v-model:open="ruleSelectOpen[index]"
|
v-model:open="ruleSelectOpen[index]"
|
||||||
|
:model-value="rule.action"
|
||||||
@update:model-value="(v) => updateRuleAction(index, v as 'set' | 'drop' | 'rename')"
|
@update:model-value="(v) => updateRuleAction(index, v as 'set' | 'drop' | 'rename')"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-24 h-7 text-xs">
|
<SelectTrigger class="w-24 h-7 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent :side-offset="4">
|
<SelectContent :side-offset="4">
|
||||||
<SelectItem value="set">设置</SelectItem>
|
<SelectItem value="set">
|
||||||
<SelectItem value="drop">删除</SelectItem>
|
设置
|
||||||
<SelectItem value="rename">重命名</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="drop">
|
||||||
|
删除
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="rename">
|
||||||
|
重命名
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
|||||||
@@ -450,7 +450,7 @@
|
|||||||
<!-- 对话视图 -->
|
<!-- 对话视图 -->
|
||||||
<ConversationView
|
<ConversationView
|
||||||
v-if="contentViewMode === 'conversation'"
|
v-if="contentViewMode === 'conversation'"
|
||||||
:conversation="requestConversation"
|
:render-result="requestRenderResult"
|
||||||
empty-message="无请求体信息"
|
empty-message="无请求体信息"
|
||||||
/>
|
/>
|
||||||
<!-- JSON 视图 -->
|
<!-- JSON 视图 -->
|
||||||
@@ -478,7 +478,7 @@
|
|||||||
<!-- 对话视图 -->
|
<!-- 对话视图 -->
|
||||||
<ConversationView
|
<ConversationView
|
||||||
v-if="contentViewMode === 'conversation'"
|
v-if="contentViewMode === 'conversation'"
|
||||||
:conversation="responseConversation"
|
:render-result="responseRenderResult"
|
||||||
empty-message="无响应体信息"
|
empty-message="无响应体信息"
|
||||||
/>
|
/>
|
||||||
<!-- JSON 视图 -->
|
<!-- JSON 视图 -->
|
||||||
@@ -533,15 +533,13 @@ import JsonContent from './RequestDetailDrawer/JsonContent.vue'
|
|||||||
import ConversationView from './RequestDetailDrawer/ConversationView.vue'
|
import ConversationView from './RequestDetailDrawer/ConversationView.vue'
|
||||||
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
|
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
|
||||||
|
|
||||||
// 消息提取工具
|
// 对话解析器
|
||||||
import {
|
import {
|
||||||
detectApiFormat,
|
renderRequest,
|
||||||
extractRequestMessages,
|
renderResponse,
|
||||||
extractResponseMessages,
|
type RenderResult,
|
||||||
formatConversationAsText,
|
type RenderBlock,
|
||||||
type ExtractedConversation,
|
} from '../lib/conversationParser'
|
||||||
type ApiFormat,
|
|
||||||
} from '../utils/messageExtractor'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -600,30 +598,20 @@ const currentHeaderData = computed(() => {
|
|||||||
: detail.value.provider_request_headers
|
: detail.value.provider_request_headers
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检测到的 API 格式
|
// 请求体渲染结果
|
||||||
const detectedApiFormat = computed<ApiFormat>(() => {
|
const requestRenderResult = computed<RenderResult>(() => {
|
||||||
if (!detail.value) return 'unknown'
|
|
||||||
return detectApiFormat(
|
|
||||||
detail.value.request_body,
|
|
||||||
detail.value.response_body,
|
|
||||||
detail.value.api_format
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 请求体对话提取结果
|
|
||||||
const requestConversation = computed<ExtractedConversation>(() => {
|
|
||||||
if (!detail.value?.request_body) {
|
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) {
|
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 是否支持对话视图
|
// 当前 Tab 是否支持对话视图
|
||||||
@@ -634,12 +622,12 @@ const supportsConversationView = computed(() => {
|
|||||||
// 当前对话数据是否有效(用于禁用按钮)
|
// 当前对话数据是否有效(用于禁用按钮)
|
||||||
const hasValidConversation = computed(() => {
|
const hasValidConversation = computed(() => {
|
||||||
if (activeTab.value === 'request-body') {
|
if (activeTab.value === 'request-body') {
|
||||||
return !requestConversation.value.parseError &&
|
return !requestRenderResult.value.error &&
|
||||||
(requestConversation.value.system || requestConversation.value.messages.length > 0)
|
requestRenderResult.value.blocks.length > 0
|
||||||
}
|
}
|
||||||
if (activeTab.value === 'response-body') {
|
if (activeTab.value === 'response-body') {
|
||||||
return !responseConversation.value.parseError &&
|
return !responseRenderResult.value.error &&
|
||||||
responseConversation.value.messages.length > 0
|
responseRenderResult.value.blocks.length > 0
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -880,6 +868,61 @@ function getTierRangeText(tier: { up_to?: number | null }, index: number, tiers:
|
|||||||
return `> ${formatNumber(start)} tokens`
|
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 和对话两种模式)
|
// 复制内容(支持 JSON 和对话两种模式)
|
||||||
function copyContent(tabName: string) {
|
function copyContent(tabName: string) {
|
||||||
if (!detail.value) return
|
if (!detail.value) return
|
||||||
@@ -890,9 +933,9 @@ function copyContent(tabName: string) {
|
|||||||
// 对话视图模式:复制格式化的对话文本
|
// 对话视图模式:复制格式化的对话文本
|
||||||
if (contentViewMode.value === 'conversation') {
|
if (contentViewMode.value === 'conversation') {
|
||||||
if (tabName === 'request-body') {
|
if (tabName === 'request-body') {
|
||||||
textToCopy = formatConversationAsText(requestConversation.value)
|
textToCopy = formatRenderResultAsText(requestRenderResult.value)
|
||||||
} else if (tabName === 'response-body') {
|
} else if (tabName === 'response-body') {
|
||||||
textToCopy = formatConversationAsText(responseConversation.value)
|
textToCopy = formatRenderResultAsText(responseRenderResult.value)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// JSON 视图模式:复制原始 JSON
|
// 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>
|
<template>
|
||||||
<div class="conversation-view">
|
<div class="conversation-view-wrapper">
|
||||||
<!-- 解析错误提示 -->
|
<div class="conversation-view-content">
|
||||||
<div
|
<!-- 渲染错误提示 -->
|
||||||
v-if="conversation.parseError"
|
<div
|
||||||
class="parse-error"
|
v-if="renderResult.error"
|
||||||
>
|
class="render-error"
|
||||||
<AlertCircle class="w-4 h-4" />
|
>
|
||||||
<span>{{ conversation.parseError }}</span>
|
<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>
|
||||||
|
|
||||||
<!-- 空内容提示 -->
|
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { User, Bot, Settings, Wrench, AlertCircle, ChevronRight, Zap } from 'lucide-vue-next'
|
import { AlertCircle, Zap } from 'lucide-vue-next'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import BlockRenderer from './BlockRenderer.vue'
|
||||||
import type { ExtractedConversation, MessageRole } from '../../utils/messageExtractor'
|
import type { RenderResult } from '../../lib/conversationParser'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
conversation: ExtractedConversation
|
renderResult: RenderResult
|
||||||
emptyMessage: string
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.conversation-view {
|
.conversation-view-wrapper {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-view-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
max-height: 500px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parse-error {
|
.render-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -177,120 +72,6 @@ const getRoleLabel = (role: MessageRole) => {
|
|||||||
font-size: 13px;
|
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 {
|
.stream-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,555 +0,0 @@
|
|||||||
/**
|
|
||||||
* 消息提取工具
|
|
||||||
* 从不同 API 格式的请求体/响应体中提取人类可读的对话内容
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 类型定义
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export type ApiFormat = 'claude' | 'openai' | 'gemini' | 'unknown'
|
|
||||||
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'
|
|
||||||
export type ContentType = 'text' | 'thinking' | 'tool_use' | 'tool_result' | 'image' | 'file'
|
|
||||||
|
|
||||||
export interface ExtractedMessage {
|
|
||||||
role: MessageRole
|
|
||||||
content: string
|
|
||||||
type: ContentType
|
|
||||||
metadata?: {
|
|
||||||
toolName?: string
|
|
||||||
toolId?: string
|
|
||||||
fileName?: string
|
|
||||||
mimeType?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtractedConversation {
|
|
||||||
system?: string
|
|
||||||
messages: ExtractedMessage[]
|
|
||||||
isStream: boolean
|
|
||||||
parseError?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// API 格式检测
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function detectApiFormat(
|
|
||||||
requestBody: any,
|
|
||||||
responseBody: any,
|
|
||||||
apiFormatHint?: string
|
|
||||||
): ApiFormat {
|
|
||||||
// 1. 优先使用后端提供的 api_format
|
|
||||||
if (apiFormatHint) {
|
|
||||||
const hint = apiFormatHint.toLowerCase()
|
|
||||||
if (hint.includes('claude')) return 'claude'
|
|
||||||
if (hint.includes('openai')) return 'openai'
|
|
||||||
if (hint.includes('gemini')) return 'gemini'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 从请求体结构推断
|
|
||||||
if (requestBody) {
|
|
||||||
// Gemini: 使用 contents 而非 messages
|
|
||||||
if (requestBody.contents && Array.isArray(requestBody.contents)) {
|
|
||||||
return 'gemini'
|
|
||||||
}
|
|
||||||
// Claude vs OpenAI: 都有 messages,通过响应体区分
|
|
||||||
if (requestBody.messages) {
|
|
||||||
const respBody = isStreamResponse(responseBody)
|
|
||||||
? responseBody.chunks?.[0]
|
|
||||||
: responseBody
|
|
||||||
|
|
||||||
// Claude 响应特征: type="message" 或 content_block 事件
|
|
||||||
if (
|
|
||||||
respBody?.type === 'message' ||
|
|
||||||
respBody?.type?.startsWith('content_block') ||
|
|
||||||
respBody?.type?.startsWith('message_')
|
|
||||||
) {
|
|
||||||
return 'claude'
|
|
||||||
}
|
|
||||||
// OpenAI 响应特征: choices 数组
|
|
||||||
if (respBody?.choices || respBody?.object?.includes('chat.completion')) {
|
|
||||||
return 'openai'
|
|
||||||
}
|
|
||||||
// 默认按 Claude 处理(Aether 主要用途)
|
|
||||||
return 'claude'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 流式响应检测
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function isStreamResponse(body: any): boolean {
|
|
||||||
return body?.metadata?.stream === true && Array.isArray(body?.chunks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 请求体提取
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function extractRequestMessages(
|
|
||||||
requestBody: any,
|
|
||||||
apiFormat: ApiFormat
|
|
||||||
): ExtractedConversation {
|
|
||||||
if (!requestBody) {
|
|
||||||
return { messages: [], isStream: false, parseError: '无请求体' }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (apiFormat) {
|
|
||||||
case 'claude':
|
|
||||||
return extractClaudeRequest(requestBody)
|
|
||||||
case 'openai':
|
|
||||||
return extractOpenAIRequest(requestBody)
|
|
||||||
case 'gemini':
|
|
||||||
return extractGeminiRequest(requestBody)
|
|
||||||
default:
|
|
||||||
return { messages: [], isStream: false, parseError: '无法识别的 API 格式' }
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return { messages: [], isStream: false, parseError: `解析失败: ${e}` }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 响应体提取
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function extractResponseMessages(
|
|
||||||
responseBody: any,
|
|
||||||
apiFormat: ApiFormat
|
|
||||||
): ExtractedConversation {
|
|
||||||
if (!responseBody) {
|
|
||||||
return { messages: [], isStream: false, parseError: '无响应体' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const isStream = isStreamResponse(responseBody)
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (apiFormat) {
|
|
||||||
case 'claude':
|
|
||||||
return isStream
|
|
||||||
? extractClaudeStreamResponse(responseBody.chunks)
|
|
||||||
: extractClaudeResponse(responseBody)
|
|
||||||
case 'openai':
|
|
||||||
return isStream
|
|
||||||
? extractOpenAIStreamResponse(responseBody.chunks)
|
|
||||||
: extractOpenAIResponse(responseBody)
|
|
||||||
case 'gemini':
|
|
||||||
return isStream
|
|
||||||
? extractGeminiStreamResponse(responseBody.chunks)
|
|
||||||
: extractGeminiResponse(responseBody)
|
|
||||||
default:
|
|
||||||
return { messages: [], isStream, parseError: '无法识别的 API 格式' }
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return { messages: [], isStream, parseError: `解析失败: ${e}` }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Claude 格式提取
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function extractClaudeRequest(body: any): ExtractedConversation {
|
|
||||||
const result: ExtractedConversation = { messages: [], isStream: false }
|
|
||||||
|
|
||||||
// 提取 system prompt
|
|
||||||
if (body.system) {
|
|
||||||
if (typeof body.system === 'string') {
|
|
||||||
result.system = body.system
|
|
||||||
} else if (Array.isArray(body.system)) {
|
|
||||||
result.system = body.system
|
|
||||||
.filter((b: any) => b.type === 'text')
|
|
||||||
.map((b: any) => b.text)
|
|
||||||
.join('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 messages
|
|
||||||
if (Array.isArray(body.messages)) {
|
|
||||||
for (const msg of body.messages) {
|
|
||||||
const role = msg.role as MessageRole
|
|
||||||
|
|
||||||
if (typeof msg.content === 'string') {
|
|
||||||
result.messages.push({ role, content: msg.content, type: 'text' })
|
|
||||||
} else if (Array.isArray(msg.content)) {
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
result.messages.push({ role, content: block.text, type: 'text' })
|
|
||||||
} else if (block.type === 'image') {
|
|
||||||
result.messages.push({
|
|
||||||
role,
|
|
||||||
content: '[图片]',
|
|
||||||
type: 'image',
|
|
||||||
metadata: { mimeType: block.source?.media_type },
|
|
||||||
})
|
|
||||||
} else if (block.type === 'tool_use') {
|
|
||||||
result.messages.push({
|
|
||||||
role,
|
|
||||||
content: JSON.stringify(block.input, null, 2),
|
|
||||||
type: 'tool_use',
|
|
||||||
metadata: { toolName: block.name, toolId: block.id },
|
|
||||||
})
|
|
||||||
} else if (block.type === 'tool_result') {
|
|
||||||
const content =
|
|
||||||
typeof block.content === 'string'
|
|
||||||
? block.content
|
|
||||||
: JSON.stringify(block.content, null, 2)
|
|
||||||
result.messages.push({
|
|
||||||
role,
|
|
||||||
content,
|
|
||||||
type: 'tool_result',
|
|
||||||
metadata: { toolId: block.tool_use_id },
|
|
||||||
})
|
|
||||||
} else if (block.type === 'thinking') {
|
|
||||||
result.messages.push({ role, content: block.thinking, type: 'thinking' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractClaudeResponse(body: any): ExtractedConversation {
|
|
||||||
const result: ExtractedConversation = { messages: [], isStream: false }
|
|
||||||
|
|
||||||
if (Array.isArray(body.content)) {
|
|
||||||
for (const block of body.content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
result.messages.push({ role: 'assistant', content: block.text, type: 'text' })
|
|
||||||
} else if (block.type === 'thinking') {
|
|
||||||
result.messages.push({ role: 'assistant', content: block.thinking, type: 'thinking' })
|
|
||||||
} else if (block.type === 'tool_use') {
|
|
||||||
result.messages.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: JSON.stringify(block.input, null, 2),
|
|
||||||
type: 'tool_use',
|
|
||||||
metadata: { toolName: block.name, toolId: block.id },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractClaudeStreamResponse(chunks: any[]): ExtractedConversation {
|
|
||||||
const result: ExtractedConversation = { messages: [], isStream: true }
|
|
||||||
|
|
||||||
// 按 content block index 分组累积
|
|
||||||
const blocks: Map<number, { type: ContentType; parts: string[]; metadata?: any }> = new Map()
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
if (chunk.type === 'content_block_start') {
|
|
||||||
const index = chunk.index ?? 0
|
|
||||||
const block = chunk.content_block
|
|
||||||
if (block?.type === 'text') {
|
|
||||||
blocks.set(index, { type: 'text', parts: [block.text || ''] })
|
|
||||||
} else if (block?.type === 'thinking') {
|
|
||||||
blocks.set(index, { type: 'thinking', parts: [block.thinking || ''] })
|
|
||||||
} else if (block?.type === 'tool_use') {
|
|
||||||
blocks.set(index, {
|
|
||||||
type: 'tool_use',
|
|
||||||
parts: [],
|
|
||||||
metadata: { toolName: block.name, toolId: block.id },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (chunk.type === 'content_block_delta') {
|
|
||||||
const index = chunk.index ?? 0
|
|
||||||
const delta = chunk.delta
|
|
||||||
const block = blocks.get(index)
|
|
||||||
if (block) {
|
|
||||||
if (delta?.type === 'text_delta') {
|
|
||||||
block.parts.push(delta.text || '')
|
|
||||||
} else if (delta?.type === 'thinking_delta') {
|
|
||||||
block.parts.push(delta.thinking || '')
|
|
||||||
} else if (delta?.type === 'input_json_delta') {
|
|
||||||
block.parts.push(delta.partial_json || '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换为消息
|
|
||||||
for (const [, block] of Array.from(blocks.entries()).sort((a, b) => a[0] - b[0])) {
|
|
||||||
result.messages.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: block.parts.join(''),
|
|
||||||
type: block.type,
|
|
||||||
metadata: block.metadata,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// OpenAI 格式提取
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function extractOpenAIRequest(body: any): ExtractedConversation {
|
|
||||||
const result: ExtractedConversation = { messages: [], isStream: false }
|
|
||||||
|
|
||||||
if (Array.isArray(body.messages)) {
|
|
||||||
for (const msg of body.messages) {
|
|
||||||
const role = msg.role as MessageRole
|
|
||||||
|
|
||||||
if (role === 'system') {
|
|
||||||
result.system = (result.system || '') + (typeof msg.content === 'string' ? msg.content : '')
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof msg.content === 'string') {
|
|
||||||
result.messages.push({ role, content: msg.content, type: 'text' })
|
|
||||||
} else if (Array.isArray(msg.content)) {
|
|
||||||
// Vision API 格式
|
|
||||||
for (const part of msg.content) {
|
|
||||||
if (part.type === 'text') {
|
|
||||||
result.messages.push({ role, content: part.text, type: 'text' })
|
|
||||||
} else if (part.type === 'image_url') {
|
|
||||||
result.messages.push({ role, content: '[图片]', type: 'image' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具调用
|
|
||||||
if (msg.tool_calls) {
|
|
||||||
for (const call of msg.tool_calls) {
|
|
||||||
result.messages.push({
|
|
||||||
role,
|
|
||||||
content: call.function?.arguments || '{}',
|
|
||||||
type: 'tool_use',
|
|
||||||
metadata: { toolName: call.function?.name, toolId: call.id },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具结果
|
|
||||||
if (msg.tool_call_id) {
|
|
||||||
result.messages.push({
|
|
||||||
role: 'tool',
|
|
||||||
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
||||||
type: 'tool_result',
|
|
||||||
metadata: { toolId: msg.tool_call_id },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractOpenAIResponse(body: any): ExtractedConversation {
|
|
||||||
const result: ExtractedConversation = { messages: [], isStream: false }
|
|
||||||
|
|
||||||
const message = body.choices?.[0]?.message
|
|
||||||
if (message) {
|
|
||||||
if (message.content) {
|
|
||||||
result.messages.push({ role: 'assistant', content: message.content, type: 'text' })
|
|
||||||
}
|
|
||||||
if (message.tool_calls) {
|
|
||||||
for (const call of message.tool_calls) {
|
|
||||||
result.messages.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: call.function?.arguments || '{}',
|
|
||||||
type: 'tool_use',
|
|
||||||
metadata: { toolName: call.function?.name, toolId: call.id },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractOpenAIStreamResponse(chunks: any[]): ExtractedConversation {
|
|
||||||
const result: ExtractedConversation = { messages: [], isStream: true }
|
|
||||||
const textParts: string[] = []
|
|
||||||
const toolCalls: Map<number, { name: string; id: string; args: string[] }> = new Map()
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
const delta = chunk.choices?.[0]?.delta
|
|
||||||
if (delta?.content) {
|
|
||||||
textParts.push(delta.content)
|
|
||||||
}
|
|
||||||
if (delta?.tool_calls) {
|
|
||||||
for (const call of delta.tool_calls) {
|
|
||||||
const index = call.index ?? 0
|
|
||||||
if (!toolCalls.has(index)) {
|
|
||||||
toolCalls.set(index, { name: call.function?.name || '', id: call.id || '', args: [] })
|
|
||||||
}
|
|
||||||
if (call.function?.arguments) {
|
|
||||||
toolCalls.get(index)!.args.push(call.function.arguments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textParts.length) {
|
|
||||||
result.messages.push({ role: 'assistant', content: textParts.join(''), type: 'text' })
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [, call] of toolCalls) {
|
|
||||||
result.messages.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: call.args.join(''),
|
|
||||||
type: 'tool_use',
|
|
||||||
metadata: { toolName: call.name, toolId: call.id },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Gemini 格式提取
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function extractGeminiRequest(body: any): ExtractedConversation {
|
|
||||||
const result: ExtractedConversation = { messages: [], isStream: false }
|
|
||||||
|
|
||||||
// System instruction
|
|
||||||
if (body.system_instruction || body.systemInstruction) {
|
|
||||||
const sysInst = body.system_instruction || body.systemInstruction
|
|
||||||
if (sysInst.parts) {
|
|
||||||
result.system = sysInst.parts
|
|
||||||
.filter((p: any) => p.text)
|
|
||||||
.map((p: any) => p.text)
|
|
||||||
.join('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contents
|
|
||||||
if (Array.isArray(body.contents)) {
|
|
||||||
for (const content of body.contents) {
|
|
||||||
const role = content.role === 'model' ? 'assistant' : ((content.role || 'user') as MessageRole)
|
|
||||||
|
|
||||||
if (Array.isArray(content.parts)) {
|
|
||||||
for (const part of content.parts) {
|
|
||||||
if (part.text) {
|
|
||||||
result.messages.push({ role, content: part.text, type: 'text' })
|
|
||||||
} else if (part.inlineData) {
|
|
||||||
result.messages.push({
|
|
||||||
role,
|
|
||||||
content: '[图片]',
|
|
||||||
type: 'image',
|
|
||||||
metadata: { mimeType: part.inlineData.mimeType },
|
|
||||||
})
|
|
||||||
} else if (part.functionCall) {
|
|
||||||
result.messages.push({
|
|
||||||
role,
|
|
||||||
content: JSON.stringify(part.functionCall.args, null, 2),
|
|
||||||
type: 'tool_use',
|
|
||||||
metadata: { toolName: part.functionCall.name },
|
|
||||||
})
|
|
||||||
} else if (part.functionResponse) {
|
|
||||||
result.messages.push({
|
|
||||||
role,
|
|
||||||
content: JSON.stringify(part.functionResponse.response, null, 2),
|
|
||||||
type: 'tool_result',
|
|
||||||
metadata: { toolName: part.functionResponse.name },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractGeminiResponse(body: any): ExtractedConversation {
|
|
||||||
const result: ExtractedConversation = { messages: [], isStream: false }
|
|
||||||
|
|
||||||
const candidate = body.candidates?.[0]
|
|
||||||
if (candidate?.content?.parts) {
|
|
||||||
for (const part of candidate.content.parts) {
|
|
||||||
if (part.text) {
|
|
||||||
result.messages.push({ role: 'assistant', content: part.text, type: 'text' })
|
|
||||||
} else if (part.functionCall) {
|
|
||||||
result.messages.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: JSON.stringify(part.functionCall.args, null, 2),
|
|
||||||
type: 'tool_use',
|
|
||||||
metadata: { toolName: part.functionCall.name },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractGeminiStreamResponse(chunks: any[]): ExtractedConversation {
|
|
||||||
const result: ExtractedConversation = { messages: [], isStream: true }
|
|
||||||
const textParts: string[] = []
|
|
||||||
const toolCalls: { name: string; args: any }[] = []
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
const parts = chunk.candidates?.[0]?.content?.parts
|
|
||||||
if (parts) {
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.text) {
|
|
||||||
textParts.push(part.text)
|
|
||||||
} else if (part.functionCall) {
|
|
||||||
toolCalls.push(part.functionCall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textParts.length) {
|
|
||||||
result.messages.push({ role: 'assistant', content: textParts.join(''), type: 'text' })
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const call of toolCalls) {
|
|
||||||
result.messages.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: JSON.stringify(call.args, null, 2),
|
|
||||||
type: 'tool_use',
|
|
||||||
metadata: { toolName: call.name },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 格式化输出
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function formatConversationAsText(conversation: ExtractedConversation): string {
|
|
||||||
const lines: string[] = []
|
|
||||||
|
|
||||||
if (conversation.system) {
|
|
||||||
lines.push('=== System ===')
|
|
||||||
lines.push(conversation.system)
|
|
||||||
lines.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const msg of conversation.messages) {
|
|
||||||
const roleLabel = msg.role.charAt(0).toUpperCase() + msg.role.slice(1)
|
|
||||||
let header = `=== ${roleLabel} ===`
|
|
||||||
|
|
||||||
if (msg.type === 'thinking') {
|
|
||||||
header = `=== ${roleLabel} (Thinking) ===`
|
|
||||||
} else if (msg.type === 'tool_use') {
|
|
||||||
header = `=== ${roleLabel} (Tool: ${msg.metadata?.toolName || 'unknown'}) ===`
|
|
||||||
} else if (msg.type === 'tool_result') {
|
|
||||||
header = `=== Tool Result ===`
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(header)
|
|
||||||
lines.push(msg.content)
|
|
||||||
lines.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n').trim()
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user