nanoClaw/frontend/src/components/ProcessBlock.vue

433 lines
13 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 }">
<!-- 流式加载还没有任何步骤时 -->
<div v-if="streaming && processItems.length === 0" class="streaming-placeholder">
<div class="streaming-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
</div>
<span class="streaming-text">正在思考中<span class="dots">...</span></span>
</div>
<!-- 按序渲染步骤 -->
<template v-else>
<template v-for="item in processItems" :key="item.key">
<!-- 思考过程 -->
<div v-if="item.type === 'thinking'" class="step-item thinking">
<div class="step-header" @click="toggleItem(item.key)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
<span class="step-label">思考过程</span>
<span v-if="item.summary" class="step-brief">{{ item.summary }}</span>
<svg class="arrow" :class="{ open: expandedKeys[item.key] }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div v-if="expandedKeys[item.key]" class="step-content">
<div class="thinking-text">{{ item.content }}</div>
</div>
</div>
<!-- 工具调用 -->
<div v-else-if="item.type === 'tool_call'" class="step-item tool_call" :class="{ loading: item.loading }">
<div class="step-header" @click="toggleItem(item.key)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
</svg>
<span class="step-label">{{ item.loading ? `执行工具: ${item.toolName}` : `调用工具: ${item.toolName}` }}</span>
<span v-if="item.summary && !item.loading" class="step-brief">{{ item.summary }}</span>
<span v-if="item.resultSummary" class="step-badge" :class="{ success: item.isSuccess, error: !item.isSuccess }">{{ item.resultSummary }}</span>
<span v-if="item.loading" class="loading-dots">...</span>
<svg v-if="!item.loading" class="arrow" :class="{ open: expandedKeys[item.key] }" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div v-if="expandedKeys[item.key] && !item.loading" class="step-content">
<div class="tool-detail" style="margin-bottom: 8px;">
<span class="detail-label">调用参数:</span>
<pre>{{ item.arguments }}</pre>
</div>
<div v-if="item.result" class="tool-detail">
<span class="detail-label">返回结果:</span>
<pre>{{ item.result }}</pre>
</div>
</div>
</div>
<!-- 文本内容 - 直接渲染 markdown -->
<div v-else-if="item.type === 'text'" class="step-item text-content md-content" v-html="item.rendered"></div>
</template>
<!-- 流式进行中指示器 -->
<div v-if="streaming" class="streaming-indicator">
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<span>正在生成...</span>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { renderMarkdown } from '../utils/markdown'
import { formatJson, truncate } from '../utils/format'
import { useCodeEnhancement } from '../composables/useCodeEnhancement'
const props = defineProps({
thinkingContent: { type: String, default: '' },
toolCalls: { type: Array, default: () => [] },
processSteps: { type: Array, default: () => [] },
streamingContent: { type: String, default: '' },
streaming: { type: Boolean, default: false }
})
const expandedKeys = ref({})
// 流式时自动展开
watch(() => props.streaming, (v) => {
if (v) expandedKeys.value = {}
})
const processRef = ref(null)
function toggleItem(key) {
expandedKeys.value[key] = !expandedKeys.value[key]
}
function getResultSummary(result) {
try {
const parsed = typeof result === 'string' ? JSON.parse(result) : result
if (parsed.success === true) return { text: '成功', success: true }
if (parsed.success === false || parsed.error) return { text: parsed.error || '失败', success: false }
if (parsed.results) return { text: `${parsed.results.length} 条结果`, success: true }
return { text: '完成', success: true }
} catch {
return { text: '完成', success: true }
}
}
const processItems = computed(() => {
const items = []
// 优先使用 processSteps按顺序
if (props.processSteps && props.processSteps.length > 0) {
for (const step of props.processSteps) {
if (!step) continue
if (step.type === 'thinking') {
items.push({ type: 'thinking', content: step.content, summary: truncate(step.content), key: `thinking-${step.index}` })
} else if (step.type === 'tool_call') {
items.push({
type: 'tool_call',
toolName: step.name || '未知工具',
arguments: formatJson(step.arguments),
summary: truncate(step.arguments),
id: step.id,
key: `tool_call-${step.id || step.index}`,
loading: false,
result: null,
})
} else if (step.type === 'tool_result') {
const summary = getResultSummary(step.content)
const match = items.findLast(it => it.type === 'tool_call' && it.id === step.id)
if (match) {
match.result = formatJson(step.content)
match.resultSummary = summary.text
match.isSuccess = summary.success
match.loading = false
}
} else if (step.type === 'text') {
items.push({
type: 'text',
content: step.content,
rendered: renderMarkdown(step.content),
key: `text-${step.index}`,
})
}
}
// 流式中最后一个 tool_call尚无结果时标记为 loading
if (props.streaming && items.length > 0) {
const last = items[items.length - 1]
if (last.type === 'tool_call' && !last.result) {
last.loading = true
}
}
// 流式中追加正在增长的文本(仅当还没有 text 类型的步骤时)
if (props.streaming && props.streamingContent) {
const hasTextStep = items.some(it => it.type === 'text')
if (!hasTextStep) {
items.push({
type: 'text',
content: props.streamingContent,
rendered: renderMarkdown(props.streamingContent) || '<span class="placeholder">...</span>',
key: 'text-streaming',
})
}
}
} else {
// 回退逻辑:旧版 thinking + toolCalls
if (props.thinkingContent) {
items.push({ type: 'thinking', content: props.thinkingContent, summary: truncate(props.thinkingContent), key: 'thinking-0' })
} else if (props.streaming && items.length === 0) {
items.push({ type: 'thinking', content: '', key: 'thinking-loading' })
}
if (props.toolCalls && props.toolCalls.length > 0) {
props.toolCalls.forEach((call, i) => {
const toolName = call.function?.name || '未知工具'
const result = call.result ? getResultSummary(call.result) : null
items.push({
type: 'tool_call',
toolName,
arguments: formatJson(call.function?.arguments),
summary: truncate(call.function?.arguments),
id: call.id,
key: `tool_call-${call.id || i}`,
loading: !call.result && props.streaming,
result: call.result ? formatJson(call.result) : null,
resultSummary: result ? result.text : null,
isSuccess: result ? result.success : undefined,
})
})
}
// 旧模式下追加流式文本
if (props.streaming && props.streamingContent) {
items.push({
type: 'text',
content: props.streamingContent,
rendered: renderMarkdown(props.streamingContent) || '<span class="placeholder">...</span>',
key: 'text-streaming',
})
}
}
return items
})
// 增强 processBlock 内代码块
const { enhance, debouncedEnhance } = useCodeEnhancement(processRef, processItems, { deep: true })
// 流式时使用节流的代码块增强减少 DOM 操作
watch(() => props.streamingContent?.length, () => {
if (props.streaming) debouncedEnhance()
})
</script>
<style scoped>
.process-block {
width: 100%;
}
/* 流式占位 */
.streaming-placeholder {
padding: 16px 20px;
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-hover);
border-radius: 8px;
border: 1px solid var(--border-light);
}
.streaming-icon {
width: 28px;
height: 28px;
border-radius: 6px;
background: #fef3c7;
color: #f59e0b;
display: flex;
align-items: center;
justify-content: center;
}
.streaming-text {
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
}
.streaming-text .dots {
display: inline-block;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* 步骤通用 */
.step-item {
margin-bottom: 8px;
}
.step-item:last-child {
margin-bottom: 0;
}
/* 思考过程 */
.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: #a855f7;
}
.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(--accent-primary);
animation: pulse 1s ease-in-out infinite;
}
.tool_call.loading .step-header {
background: var(--bg-hover);
}
/* 步骤展开内容 */
.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;
margin-top: 8px;
padding: 8px 0 0;
border-top: 1px solid var(--border-light);
font-size: 12px;
color: var(--text-tertiary);
}
</style>