Luxx/dashboard/src/components/ProcessBlock.vue

386 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div ref="processRef" 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 v-if="streaming && item.key === lastThinkingKey" class="loading-dots">...</span>
<span class="arrow" :class="{ open: expandedKeys.has(item.key) }" v-html="chevronDown"></span>
</div>
<div v-if="expandedKeys.has(item.key)" class="step-content">
<div class="thinking-text">{{ item.content }}</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 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>
</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.fullResult || item.resultSummary }}</pre>
</div>
</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 processRef = ref(null)
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') {
items.push({
key: step.id || `thinking-${step.index}`,
type: 'thinking',
index: step.index,
content: step.content || '',
brief: step.content ? step.content.slice(0, 50) + (step.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) {
match.resultSummary = step.content ? step.content.slice(0, 200) : ''
match.fullResult = step.content || ''
match.isSuccess = step.success !== false
match.loading = false
} else {
// 如果没有找到对应的 tool_call创建一个占位符
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: step.content ? step.content.slice(0, 200) : '',
fullResult: step.content || ''
})
}
} 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 orderedThinkingItems = computed(() =>
allItems.value
.filter(i => i.type === 'thinking')
.sort((a, b) => (a.index || 0) - (b.index || 0))
)
const orderedToolCallItems = computed(() =>
allItems.value
.filter(i => i.type === 'tool_call')
.sort((a, b) => (a.index || 0) - (b.index || 0))
)
const orderedTextItems = computed(() =>
allItems.value
.filter(i => i.type === 'text')
.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>`
</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) */
.thinking .step-header,
.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;
}
.thinking .step-header:hover,
.tool_call .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);
}
.step-label {
font-weight: 500;
color: var(--text-primary);
flex-shrink: 0;
min-width: 130px;
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow {
margin-left: auto;
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;
}
.step-badge.success {
background: var(--success-bg);
color: var(--success-color);
}
.step-badge.error {
background: var(--danger-bg);
color: var(--danger-color);
}
.step-brief {
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
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;
}
.tool_call.loading .step-header {
background: var(--bg-hover);
}
/* Expandable step content panel */
.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 */
.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 cursor indicator */
.streaming-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-tertiary);
}
/* Add separator only when there are step items above the indicator */
.process-block:has(.step-item) .streaming-indicator {
margin-top: 8px;
padding: 8px 0 0;
border-top: 1px solid var(--border-light);
}
</style>