feat(usage): 请求详情页面添加对话视图功能

This commit is contained in:
LewisPen
2026-01-15 18:23:55 +08:00
parent dd387735d4
commit 38607332b9
3 changed files with 1029 additions and 4 deletions

View File

@@ -362,14 +362,41 @@
class="h-4 mx-1"
/>
</template>
<!-- 请求体/响应体专用JSON/对话 视图切换单按钮 -->
<template v-if="supportsConversationView">
<button
:title="contentViewMode === 'json' ? '切换到对话视图' : '切换到 JSON 视图'"
class="p-1.5 rounded transition-colors"
:class="hasValidConversation || contentViewMode === 'conversation'
? 'text-muted-foreground hover:bg-muted'
: 'text-muted-foreground/40 cursor-not-allowed'"
:disabled="!hasValidConversation && contentViewMode === 'json'"
@click="toggleContentView"
>
<Code2
v-if="contentViewMode === 'conversation'"
class="w-4 h-4"
/>
<MessageSquareText
v-else
class="w-4 h-4"
/>
</button>
<Separator
orientation="vertical"
class="h-4 mx-1"
/>
</template>
<!-- 展开/收缩 -->
<button
:title="currentExpandDepth === 0 ? '展开全部' : '收缩全部'"
class="p-1.5 rounded transition-colors"
:class="viewMode === 'compare'
:class="viewMode === 'compare' || contentViewMode === 'conversation'
? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:bg-muted'"
:disabled="viewMode === 'compare'"
:disabled="viewMode === 'compare' || contentViewMode === 'conversation'"
@click="currentExpandDepth === 0 ? expandAll() : collapseAll()"
>
<Maximize2
@@ -389,7 +416,7 @@
? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:bg-muted'"
:disabled="viewMode === 'compare'"
@click="copyJsonToClipboard(activeTab)"
@click="copyContent(activeTab)"
>
<Check
v-if="copiedStates[activeTab]"
@@ -420,7 +447,15 @@
</TabsContent>
<TabsContent value="request-body">
<!-- 对话视图 -->
<ConversationView
v-if="contentViewMode === 'conversation'"
:conversation="requestConversation"
empty-message="无请求体信息"
/>
<!-- JSON 视图 -->
<JsonContent
v-else
:data="detail.request_body"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
@@ -440,7 +475,15 @@
</TabsContent>
<TabsContent value="response-body">
<!-- 对话视图 -->
<ConversationView
v-if="contentViewMode === 'conversation'"
:conversation="responseConversation"
empty-message="无响应体信息"
/>
<!-- JSON 视图 -->
<JsonContent
v-else
:data="detail.response_body"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
@@ -480,15 +523,26 @@ import Separator from '@/components/ui/separator.vue'
import Skeleton from '@/components/ui/skeleton.vue'
import Tabs from '@/components/ui/tabs.vue'
import TabsContent from '@/components/ui/tabs-content.vue'
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server } from 'lucide-vue-next'
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2 } from 'lucide-vue-next'
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
import { log } from '@/utils/logger'
// 子组件
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
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'
const props = defineProps<{
isOpen: boolean
requestId: string | null
@@ -506,6 +560,7 @@ const copiedStates = ref<Record<string, boolean>>({})
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
const currentExpandDepth = ref(1)
const dataSource = ref<'client' | 'provider'>('client')
const contentViewMode = ref<'json' | 'conversation'>('json')
const { copyToClipboard } = useClipboard()
const historicalPricing = ref<{
input_price: string
@@ -520,6 +575,10 @@ watch(activeTab, (newTab) => {
if (newTab !== 'request-headers' && viewMode.value === 'compare') {
viewMode.value = 'formatted'
}
// 切换到不支持对话视图的 Tab 时,重置为 JSON 视图
if (!['request-body', 'response-body'].includes(newTab)) {
contentViewMode.value = 'json'
}
})
// 检测暗色模式
@@ -541,6 +600,50 @@ 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>(() => {
if (!detail.value?.request_body) {
return { messages: [], isStream: false }
}
return extractRequestMessages(detail.value.request_body, detectedApiFormat.value)
})
// 响应体对话提取结果
const responseConversation = computed<ExtractedConversation>(() => {
if (!detail.value?.response_body) {
return { messages: [], isStream: false }
}
return extractResponseMessages(detail.value.response_body, detectedApiFormat.value)
})
// 当前 Tab 是否支持对话视图
const supportsConversationView = computed(() => {
return ['request-body', 'response-body'].includes(activeTab.value)
})
// 当前对话数据是否有效(用于禁用按钮)
const hasValidConversation = computed(() => {
if (activeTab.value === 'request-body') {
return !requestConversation.value.parseError &&
(requestConversation.value.system || requestConversation.value.messages.length > 0)
}
if (activeTab.value === 'response-body') {
return !responseConversation.value.parseError &&
responseConversation.value.messages.length > 0
}
return false
})
// 价格来源标签
// tiered_pricing.source 表示定价来源: 'provider' 或 'global'
const priceSourceLabel = computed(() => {
@@ -813,6 +916,67 @@ function copyJsonToClipboard(tabName: string) {
}
}
// 复制内容(支持 JSON 和对话两种模式)
function copyContent(tabName: string) {
if (!detail.value) return
if (viewMode.value === 'compare') return
let textToCopy = ''
// 对话视图模式:复制格式化的对话文本
if (contentViewMode.value === 'conversation') {
if (tabName === 'request-body') {
textToCopy = formatConversationAsText(requestConversation.value)
} else if (tabName === 'response-body') {
textToCopy = formatConversationAsText(responseConversation.value)
}
} else {
// JSON 视图模式:复制原始 JSON
let data: any = null
switch (tabName) {
case 'request-headers':
data = dataSource.value === 'provider'
? detail.value.provider_request_headers
: detail.value.request_headers
break
case 'request-body':
data = detail.value.request_body
break
case 'response-headers':
data = actualResponseHeaders.value
break
case 'response-body':
data = detail.value.response_body
break
case 'metadata':
data = detail.value.metadata
break
}
if (data) {
textToCopy = JSON.stringify(data, null, 2)
}
}
if (textToCopy) {
copyToClipboard(textToCopy, false)
copiedStates.value[tabName] = true
setTimeout(() => {
copiedStates.value[tabName] = false
}, 2000)
}
}
// 切换内容视图模式
function toggleContentView() {
if (contentViewMode.value === 'json') {
if (hasValidConversation.value) {
contentViewMode.value = 'conversation'
}
} else {
contentViewMode.value = 'json'
}
}
function expandAll() {
currentExpandDepth.value = 999
}

View File

@@ -0,0 +1,306 @@
<template>
<div class="conversation-view">
<!-- 解析错误提示 -->
<div
v-if="conversation.parseError"
class="parse-error"
>
<AlertCircle class="w-4 h-4" />
<span>{{ conversation.parseError }}</span>
</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'
defineProps<{
conversation: ExtractedConversation
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 {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 500px;
overflow-y: auto;
padding: 4px;
}
.parse-error {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 8px;
color: hsl(var(--destructive));
font-size: 13px;
}
.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;
align-items: center;
gap: 4px;
padding: 4px 8px;
font-size: 11px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.3);
border-radius: 4px;
width: fit-content;
}
</style>

View File

@@ -0,0 +1,555 @@
/**
* 消息提取工具
* 从不同 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 [index, 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()
}