Luxx/dashboard/src/components/ProcessBlock.vue

473 lines
14 KiB
Vue

<template>
<div ref="processRef" class="process-block" :class="{ 'is-streaming': streaming }">
<!-- Render all steps in order: thinking, text, tool_call, tool_result interleaved -->
<template v-if="processItems.length > 0">
<div v-for="item in processItems" :key="item.key">
<!-- Thinking block -->
<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>
<!-- Tool call block -->
<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>{{ expandedResultKeys[item.key] ? item.result : item.resultPreview }}</pre>
<button v-if="item.resultTruncated" class="btn-expand-result" @click.stop="toggleResultExpand(item.key)">
{{ expandedResultKeys[item.key] ? '收起' : `展开全部 (${item.resultLength} 字符)` }}
</button>
</div>
</div>
</div>
<!-- Text content -->
<div v-else-if="item.type === 'text'" class="step-item text-content" v-html="item.rendered"></div>
<!-- Tool result block -->
<div v-else-if="item.type === 'tool_result'" class="step-item tool_result">
<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.name }}</span>
<span v-if="item.resultSummary" class="step-badge" :class="{ success: item.isSuccess, error: !item.isSuccess }">{{ item.resultSummary }}</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="tool-detail">
<span class="detail-label">返回结果:</span>
<pre>{{ expandedResultKeys[item.key] ? item.result : item.resultPreview }}</pre>
<button v-if="item.resultTruncated" class="btn-expand-result" @click.stop="toggleResultExpand(item.key)">
{{ expandedResultKeys[item.key] ? '收起' : `展开全部 (${item.resultLength} 字符)` }}
</button>
</div>
</div>
</div>
</div>
</template>
<!-- Active streaming indicator -->
<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>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const RESULT_PREVIEW_LIMIT = 500
function formatJson(str) {
try {
const obj = typeof str === 'string' ? JSON.parse(str) : str
return JSON.stringify(obj, null, 2)
} catch {
return String(str)
}
}
function truncate(str, maxLen = 80) {
const s = String(str)
if (s.length <= maxLen) return s
return s.substring(0, maxLen) + '...'
}
function buildResultFields(rawContent) {
const formatted = formatJson(rawContent)
const len = formatted.length
const truncated = len > RESULT_PREVIEW_LIMIT
return {
result: formatted,
resultPreview: truncated ? formatted.slice(0, RESULT_PREVIEW_LIMIT) + '\n...' : formatted,
resultTruncated: truncated,
resultLength: len,
}
}
const props = defineProps({
toolCalls: { type: Array, default: () => [] },
processSteps: { type: Array, default: () => [] },
streaming: { type: Boolean, default: false }
})
const expandedKeys = ref({})
const expandedResultKeys = ref({})
// Auto-collapse all items when a new stream starts
watch(() => props.streaming, (v) => {
if (v) {
expandedKeys.value = {}
expandedResultKeys.value = {}
}
})
const processRef = ref(null)
function toggleItem(key) {
expandedKeys.value[key] = !expandedKeys.value[key]
}
function toggleResultExpand(key) {
expandedResultKeys.value[key] = !expandedResultKeys.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 }
}
}
function renderMarkdown(text) {
if (!text) return ''
// Simple markdown rendering
return text
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
}
// Build ordered process items from all available data (thinking, tool calls, text).
const processItems = computed(() => {
const items = []
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: step.id || `thinking-${step.index}`,
})
} else if (step.type === 'tool_call') {
const toolId = step.id_ref || step.id
items.push({
type: 'tool_call',
toolName: step.name || '未知工具',
arguments: formatJson(step.arguments),
summary: truncate(step.arguments),
id: toolId,
key: step.id || `tool_call-${toolId || step.index}`,
loading: false,
result: null,
})
} else if (step.type === 'tool_result') {
// 直接添加 tool_result 作为独立项
const summary = getResultSummary(step.content)
items.push({
type: 'tool_result',
id: step.id_ref || step.id,
name: step.name || 'unknown',
content: step.content,
resultSummary: summary.text,
isSuccess: summary.success,
key: step.id || `tool_result-${step.id_ref || step.index}`,
...buildResultFields(step.content)
})
} else if (step.type === 'text') {
items.push({
type: 'text',
content: step.content,
rendered: renderMarkdown(step.content) || '<span class="placeholder">...</span>',
key: step.id || `text-${step.index}`,
})
}
}
// Mark the last tool_call as loading if it has no result yet (still executing)
if (props.streaming && items.length > 0) {
const last = items[items.length - 1]
if (last.type === 'tool_call' && !last.result) {
last.loading = true
}
}
} else {
// Fallback: legacy mode for old messages without processSteps stored in DB
if (props.toolCalls && props.toolCalls.length > 0) {
props.toolCalls.forEach((call, i) => {
const toolName = call.function?.name || '未知工具'
const resultSummary = call.result ? getResultSummary(call.result) : null
const resultFields = call.result ? buildResultFields(call.result) : { result: null, resultPreview: null, resultTruncated: false, resultLength: 0 }
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,
...resultFields,
resultSummary: resultSummary ? resultSummary.text : null,
isSuccess: resultSummary ? resultSummary.success : undefined,
})
})
}
}
return items
})
</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(--code-bg);
border: 1px solid var(--border);
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: #10b981;
}
.step-label {
font-weight: 500;
color: var(--text);
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-secondary);
flex-shrink: 0;
}
.step-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.step-badge.success {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.step-badge.error {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.step-brief {
font-size: 11px;
color: var(--text-secondary);
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: #10b981;
animation: pulse 1s ease-in-out infinite;
}
.tool_call.loading .step-header {
background: var(--bg-hover);
}
/* Tool result styling */
.tool_result .step-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.tool_result .step-header:hover {
background: var(--bg-hover);
}
.tool_result .step-header svg:first-child {
color: #10b981;
}
/* Expandable step content panel */
.step-content {
padding: 12px;
margin-top: 4px;
background: var(--bg);
border: 1px solid var(--border);
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-secondary);
font-size: 11px;
font-weight: 600;
display: block;
margin-bottom: 4px;
}
.tool-detail pre {
padding: 8px;
background: var(--code-bg);
border-radius: 4px;
border: 1px solid var(--border);
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;
}
.btn-expand-result {
display: inline-block;
margin-top: 6px;
padding: 3px 10px;
font-size: 11px;
color: #10b981;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.btn-expand-result:hover {
background: rgba(16, 185, 129, 0.2);
}
/* Text content */
.text-content {
padding: 0;
font-size: 15px;
line-height: 1.7;
color: var(--text);
word-break: break-word;
white-space: pre-wrap;
}
.text-content :deep(.placeholder) {
color: var(--text-secondary);
}
/* Streaming cursor indicator */
.streaming-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
}
/* 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);
}
</style>