373 lines
14 KiB
Vue
373 lines
14 KiB
Vue
<template>
|
||
<div class="process-block" :class="{ 'is-streaming': streaming }">
|
||
<!-- Single loop: render all steps in index order for proper alternation -->
|
||
<template v-for="item in orderedItems">
|
||
<!-- Thinking Step -->
|
||
<div v-if="item.type === 'thinking'" :key="`thinking-${item.key}`" class="step-item thinking">
|
||
<div class="step-header" @click="toggleExpand(item.key)">
|
||
<span v-html="brainIcon"></span>
|
||
<span class="step-label">思考中</span>
|
||
<span class="step-brief">{{ item.brief || '正在思考...' }}</span>
|
||
<span class="step-status">
|
||
<span v-if="streaming && item.key === lastThinkingKey" class="loading-dots">...</span>
|
||
<span class="arrow" :class="{ open: expandedKeys.has(item.key) }" v-html="chevronDown"></span>
|
||
</span>
|
||
</div>
|
||
<div v-if="expandedKeys.has(item.key)" class="step-content">
|
||
<div class="thinking-text">{{ item.displayContent }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tool Call Step -->
|
||
<div v-else-if="item.type === 'tool_call'" :key="`tool-${item.key}`" class="step-item tool_call" :class="{ loading: item.loading }">
|
||
<div class="step-header" @click="toggleExpand(item.key)">
|
||
<span v-html="toolIcon"></span>
|
||
<span class="step-label">{{ item.name || '工具调用' }}</span>
|
||
<span class="step-brief">{{ item.brief || '' }}</span>
|
||
<span class="step-status">
|
||
<span v-if="item.loading" class="loading-dots">...</span>
|
||
<span v-else-if="item.isSuccess === true" class="step-badge success">成功</span>
|
||
<span v-else-if="item.isSuccess === false" class="step-badge error">失败</span>
|
||
<span class="arrow" :class="{ open: expandedKeys.has(item.key) }" v-html="chevronDown"></span>
|
||
</span>
|
||
</div>
|
||
<div v-if="expandedKeys.has(item.key)" class="step-content">
|
||
<div class="tool-detail">
|
||
<span class="detail-label">参数</span>
|
||
<pre>{{ formatArgs(item.args) }}</pre>
|
||
</div>
|
||
<div v-if="item.resultSummary || item.fullResult" class="tool-detail" style="margin-top: 8px;">
|
||
<span class="detail-label">结果</span>
|
||
<pre>{{ item.displayResult }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Error Step -->
|
||
<div v-else-if="item.type === 'error'" :key="`error-${item.key}`" class="step-item error">
|
||
<div class="step-header">
|
||
<span v-html="alertIcon"></span>
|
||
<span class="step-label">错误</span>
|
||
<span class="step-badge error">错误</span>
|
||
</div>
|
||
<div class="step-content error-content">
|
||
<pre>{{ item.content }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Text Step -->
|
||
<div v-else-if="item.type === 'text'" :key="`text-${item.key}`" class="text-content md-content" v-html="renderMarkdown(item.content)"></div>
|
||
</template>
|
||
|
||
<!-- Streaming indicator -->
|
||
<div v-if="streaming && !hasContent" class="streaming-indicator">
|
||
<span v-html="sparkleIcon"></span>
|
||
<span>AI 正在输入...</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
import { renderMarkdown } from '../utils/markdown.js'
|
||
|
||
const props = defineProps({
|
||
processSteps: { type: Array, default: () => [] },
|
||
toolCalls: { type: Array, default: () => [] },
|
||
streaming: { type: Boolean, default: false },
|
||
})
|
||
|
||
const expandedKeys = ref(new Set())
|
||
|
||
// 构建 processItems 从 processSteps
|
||
const allItems = computed(() => {
|
||
const items = []
|
||
|
||
if (props.processSteps && props.processSteps.length > 0) {
|
||
for (const step of props.processSteps) {
|
||
if (step.type === 'thinking') {
|
||
const content = step.content || ''
|
||
items.push({
|
||
key: step.id || `thinking-${step.index}`,
|
||
type: 'thinking',
|
||
index: step.index,
|
||
content: content,
|
||
displayContent: content.length > 1024 ? content.slice(0, 1024) + '...' : content,
|
||
brief: content.slice(0, 50) + (content.length > 50 ? '...' : ''),
|
||
})
|
||
} else if (step.type === 'tool_call') {
|
||
items.push({
|
||
key: step.id || `tool-${step.index}`,
|
||
type: 'tool_call',
|
||
index: step.index,
|
||
id: step.id,
|
||
name: step.name,
|
||
args: step.arguments || step.args || '{}', // 后端发送 arguments 字段
|
||
brief: step.name || '',
|
||
loading: step.loading,
|
||
isSuccess: step.isSuccess,
|
||
resultSummary: step.resultSummary,
|
||
fullResult: step.fullResult,
|
||
})
|
||
} else if (step.type === 'tool_result') {
|
||
// 合并 tool_result 到对应的 tool_call
|
||
const toolId = step.id_ref || step.id
|
||
const match = items.findLast(it => it.type === 'tool_call' && it.id === toolId)
|
||
if (match) {
|
||
let resultContent = step.content || ''
|
||
let displayContent = resultContent
|
||
let hasError = false
|
||
|
||
// 尝试解析 JSON 并格式化显示
|
||
try {
|
||
const parsed = JSON.parse(resultContent)
|
||
// 检查标准 ToolResult 格式
|
||
if (parsed.error) {
|
||
displayContent = `错误: ${parsed.error}`
|
||
hasError = true
|
||
} else if (parsed.success !== undefined && parsed.data !== undefined) {
|
||
// 标准 ToolResult 格式: 只显示 data 部分
|
||
displayContent = JSON.stringify(parsed.data, null, 2)
|
||
} else {
|
||
// 旧格式或其他 JSON
|
||
displayContent = JSON.stringify(parsed, null, 2)
|
||
}
|
||
} catch (e) {
|
||
// 不是 JSON,保持原样
|
||
}
|
||
|
||
match.resultSummary = displayContent.slice(0, 200)
|
||
match.fullResult = displayContent
|
||
match.displayResult = displayContent.length > 2048 ? displayContent.slice(0, 2048) + '...' : displayContent
|
||
match.isSuccess = !hasError && step.success !== false
|
||
match.loading = false
|
||
} else {
|
||
// 如果没有找到对应的 tool_call,创建一个占位符
|
||
const placeholderContent = step.content || ''
|
||
items.push({
|
||
key: `result-${step.id || step.index}`,
|
||
type: 'tool_call',
|
||
index: step.index,
|
||
id: step.id_ref || step.id,
|
||
name: step.name || '工具结果',
|
||
args: '{}', // 占位符默认空参数
|
||
brief: step.name || '工具结果',
|
||
loading: false,
|
||
isSuccess: true,
|
||
resultSummary: placeholderContent.slice(0, 200),
|
||
fullResult: placeholderContent,
|
||
displayResult: placeholderContent.length > 1024 ? placeholderContent.slice(0, 1024) + '...' : placeholderContent
|
||
})
|
||
}
|
||
} else if (step.type === 'text') {
|
||
items.push({
|
||
key: step.id || `text-${step.index}`,
|
||
type: 'text',
|
||
index: step.index,
|
||
content: step.content || '',
|
||
})
|
||
}
|
||
}
|
||
} else if (props.toolCalls && props.toolCalls.length > 0) {
|
||
// 兼容旧的 toolCalls 格式
|
||
for (const tc of props.toolCalls) {
|
||
items.push({
|
||
key: tc.id || `tool-${tc.index}`,
|
||
type: 'tool_call',
|
||
id: tc.id,
|
||
name: tc.name,
|
||
args: tc.arguments,
|
||
brief: tc.name || '',
|
||
})
|
||
}
|
||
}
|
||
|
||
return items
|
||
})
|
||
|
||
// Ordered by index for proper step alternation
|
||
const orderedItems = computed(() =>
|
||
[...allItems.value].sort((a, b) => (a.index || 0) - (b.index || 0))
|
||
)
|
||
|
||
const hasContent = computed(() => allItems.value.length > 0)
|
||
const lastThinkingKey = computed(() => {
|
||
const items = allItems.value.filter(i => i.type === 'thinking')
|
||
return items.length > 0 ? items[items.length - 1].key : null
|
||
})
|
||
|
||
function toggleExpand(key) {
|
||
if (expandedKeys.value.has(key)) {
|
||
expandedKeys.value.delete(key)
|
||
} else {
|
||
expandedKeys.value.add(key)
|
||
}
|
||
expandedKeys.value = new Set(expandedKeys.value)
|
||
}
|
||
|
||
function formatArgs(args) {
|
||
if (!args) return '{}'
|
||
if (typeof args === 'string') {
|
||
try {
|
||
return JSON.stringify(JSON.parse(args), null, 2)
|
||
} catch {
|
||
return args
|
||
}
|
||
}
|
||
return JSON.stringify(args, null, 2)
|
||
}
|
||
|
||
// Icons
|
||
const brainIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"></path><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"></path><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"></path><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"></path><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"></path><path d="M3.477 10.896a4 4 0 0 1 .585-.396"></path><path d="M19.938 10.5a4 4 0 0 1 .585.396"></path><path d="M6 18a4 4 0 0 1-1.967-.516"></path><path d="M19.967 17.484A4 4 0 0 1 18 18"></path></svg>`
|
||
|
||
const toolIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`
|
||
|
||
const chevronDown = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`
|
||
|
||
const sparkleIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"></path></svg>`
|
||
|
||
const alertIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>`
|
||
</script>
|
||
|
||
<style scoped>
|
||
.process-block {
|
||
width: 100%;
|
||
}
|
||
|
||
/* Step items (shared) */
|
||
.step-item {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.step-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 0.4; }
|
||
50% { opacity: 1; }
|
||
}
|
||
|
||
/* Step header (shared by thinking and tool_call) */
|
||
.step-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-light);
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: background 0.15s;
|
||
width: 100%;
|
||
}
|
||
|
||
.step-header:hover { background: var(--bg-hover); }
|
||
|
||
.thinking .step-header svg:first-child { color: #f59e0b; }
|
||
.tool_call .step-header svg:first-child { color: var(--tool-color); }
|
||
.tool_call.loading .step-header { background: var(--bg-hover); }
|
||
|
||
.step-label {
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
flex-shrink: 0;
|
||
flex: 2;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.step-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 3;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.arrow {
|
||
transition: transform 0.2s;
|
||
color: var(--text-tertiary);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.step-badge {
|
||
font-size: 11px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-weight: 500;
|
||
flex-shrink: 0;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.step-badge.success {
|
||
background: var(--success-bg);
|
||
color: var(--success-color);
|
||
}
|
||
|
||
.step-badge.error {
|
||
background: var(--danger-bg);
|
||
color: var(--danger-color);
|
||
}
|
||
|
||
.step-item.error {
|
||
background: var(--danger-bg);
|
||
border: 1px solid var(--danger-color);
|
||
border-radius: 8px;
|
||
padding: 8px 12px;
|
||
margin: 4px 0;
|
||
}
|
||
.step-item.error .step-label { color: var(--danger-color); font-weight: 600; }
|
||
.step-item.error svg { color: var(--danger-color); }
|
||
|
||
.error-content {
|
||
margin-top: 8px;
|
||
padding: 8px;
|
||
background: rgba(255, 255, 255, 0.5);
|
||
border-radius: 4px;
|
||
}
|
||
.error-content pre {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
font-size: 0.85rem;
|
||
color: var(--danger-color);
|
||
}
|
||
|
||
.step-brief {
|
||
font-size: 11px;
|
||
color: var(--text-tertiary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
flex: 5;
|
||
min-width: 0;
|
||
}
|
||
|
||
.arrow.open {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
.loading-dots {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: var(--tool-color);
|
||
animation: pulse 1s ease-in-out infinite;
|
||
}
|
||
|
||
.step-content { padding: 12px; margin-top: 4px; background: var(--bg-code); border: 1px solid var(--border-light); border-radius: 8px; overflow: hidden; }
|
||
.thinking-text { font-size: 13px; color: var(--text-secondary); line-height: 1.6; white-space: pre-wrap; }
|
||
.tool-detail { font-size: 13px; }
|
||
.detail-label { color: var(--text-tertiary); font-size: 11px; font-weight: 600; display: block; margin-bottom: 4px; }
|
||
.tool-detail pre { padding: 8px; background: var(--bg-primary); border-radius: 4px; border: 1px solid var(--border-light); font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 12px; line-height: 1.5; color: var(--text-secondary); overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
||
|
||
.text-content { padding: 0; font-size: 15px; line-height: 1.7; color: var(--text-primary); word-break: break-word; contain: layout style; }
|
||
.text-content :deep(.placeholder) { color: var(--text-tertiary); }
|
||
|
||
.streaming-indicator { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-tertiary); }
|
||
.process-block:has(.step-item) .streaming-indicator { margin-top: 8px; padding: 8px 0 0; border-top: 1px solid var(--border-light); }
|
||
</style>
|