feat(chat): add execution-plan & tool-call detail viewer (closes #246)

This commit is contained in:
matevip 2026-06-02 20:17:53 +08:00
parent 48611a6f4d
commit bb2cf12f49
6 changed files with 490 additions and 15 deletions

View File

@ -0,0 +1,251 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import JsonView from './JsonView.vue'
/**
* Full-detail viewer for an execution step or a tool call.
* Shows the complete request payload and response output without truncation,
* so users can audit exactly what an agent ran and what came back.
*/
const props = defineProps<{
modelValue: boolean
title: string
status?: 'running' | 'completed' | 'error' | 'pending'
/** Raw request payload (typically JSON arguments). Optional — plan steps have none. */
request?: string
/** Raw response / output text. */
response?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
const { t } = useI18n()
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
const hasRequest = computed(() => !!(props.request || '').trim())
const hasResponse = computed(() => !!(props.response || '').trim())
const statusLabel = computed(() => t(`chat.detail.status.${props.status || 'pending'}`))
async function copy(text?: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success(t('chat.detail.copied'))
} catch {
ElMessage.error(t('chat.detail.copyFailed'))
}
}
</script>
<template>
<el-dialog
v-model="visible"
width="700px"
append-to-body
align-center
:show-close="false"
class="exec-detail-dialog"
modal-class="exec-detail-overlay"
>
<template #header>
<div class="exec-detail__head">
<span class="exec-detail__dot" :class="`is-${status || 'pending'}`" />
<span class="exec-detail__title">{{ title }}</span>
<span v-if="status" class="exec-detail__badge" :class="`is-${status}`">{{ statusLabel }}</span>
<button class="exec-detail__close" :aria-label="$t('common.close')" @click="visible = false">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
</template>
<div class="exec-detail__body">
<section v-if="hasRequest" class="exec-detail__section">
<div class="exec-detail__label">
<span>{{ $t('chat.detail.request') }}</span>
<button class="exec-detail__copy" :title="$t('chat.detail.copy')" @click="copy(request)">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg>
</button>
</div>
<JsonView :raw="request" />
</section>
<section class="exec-detail__section">
<div class="exec-detail__label">
<span>{{ $t('chat.detail.response') }}</span>
<button v-if="hasResponse" class="exec-detail__copy" :title="$t('chat.detail.copy')" @click="copy(response)">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg>
</button>
</div>
<JsonView v-if="hasResponse" :raw="response" />
<div v-else class="exec-detail__empty">{{ $t('chat.detail.empty') }}</div>
</section>
</div>
</el-dialog>
</template>
<style scoped>
.exec-detail__head {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.exec-detail__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--mc-text-quaternary, #c0bfbc);
}
.exec-detail__dot.is-completed { background: var(--mc-success, #67c23a); }
.exec-detail__dot.is-error { background: var(--mc-danger, #f56c6c); }
.exec-detail__dot.is-running { background: var(--mc-primary, #d96d46); }
.exec-detail__title {
font-weight: 600;
font-size: 15px;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exec-detail__badge {
flex-shrink: 0;
font-size: 11px;
line-height: 18px;
padding: 0 8px;
border-radius: 9px;
font-weight: 500;
color: var(--mc-text-tertiary);
background: var(--mc-bg-muted, #f1ece8);
}
.exec-detail__badge.is-completed {
color: var(--mc-success, #4f9a3f);
background: rgba(103, 194, 58, 0.12);
}
.exec-detail__badge.is-error {
color: var(--mc-danger, #d9533f);
background: rgba(245, 108, 108, 0.12);
}
.exec-detail__badge.is-running {
color: var(--mc-primary, #d96d46);
background: var(--mc-primary-bg, rgba(217, 109, 70, 0.1));
}
.exec-detail__close {
margin-left: auto;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--mc-text-tertiary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.exec-detail__close:hover {
background: var(--mc-bg-muted, #f1ece8);
color: var(--mc-text-primary);
}
.exec-detail__body {
display: flex;
flex-direction: column;
gap: 18px;
}
.exec-detail__section {
display: flex;
flex-direction: column;
gap: 8px;
}
.exec-detail__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--mc-text-tertiary);
}
.exec-detail__copy {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 5px;
background: transparent;
color: var(--mc-text-tertiary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.exec-detail__copy:hover {
background: var(--mc-bg-muted, #f1ece8);
color: var(--mc-primary);
}
.exec-detail__empty {
padding: 16px;
font-size: 13px;
color: var(--mc-text-tertiary);
text-align: center;
background: rgba(255, 255, 255, 0.3);
border: 1px dashed var(--mc-border-light);
border-radius: 10px;
}
</style>
<!-- Frosted-glass dialog shell. Non-scoped because el-dialog teleports to body,
out of this component's scoped style reach. -->
<style>
.exec-detail-overlay {
background: rgba(28, 20, 16, 0.28) !important;
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
}
.exec-detail-dialog.el-dialog {
background: rgba(255, 255, 255, 0.62);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.55);
border-radius: 18px;
box-shadow: 0 16px 56px rgba(28, 20, 16, 0.22);
overflow: hidden;
}
.exec-detail-dialog .el-dialog__header {
margin: 0;
padding: 16px 18px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.4);
}
.exec-detail-dialog .el-dialog__body {
padding: 14px 18px 20px;
}
html.dark .exec-detail-overlay {
background: rgba(0, 0, 0, 0.42) !important;
}
html.dark .exec-detail-dialog.el-dialog {
background: rgba(34, 27, 23, 0.6);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 16px 56px rgba(0, 0, 0, 0.55);
}
html.dark .exec-detail-dialog .el-dialog__header {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
</style>

View File

@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
/**
* Lightweight, dependency-free JSON viewer with syntax highlighting.
* Parses the raw string as JSON and pretty-prints it with token colors;
* when the input is not valid JSON (e.g. plain terminal output), it falls
* back to rendering the raw text verbatim without highlighting.
*/
const props = defineProps<{
raw?: string
}>()
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/** Wrap JSON tokens (keys, strings, numbers, booleans, null) in colored spans. */
function highlight(jsonStr: string): string {
return escapeHtml(jsonStr).replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|\bnull\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
(match) => {
let cls = 'jv-number'
if (/^"/.test(match)) {
cls = /:$/.test(match) ? 'jv-key' : 'jv-string'
} else if (/true|false/.test(match)) {
cls = 'jv-boolean'
} else if (/null/.test(match)) {
cls = 'jv-null'
}
return `<span class="${cls}">${match}</span>`
},
)
}
const parsed = computed(() => {
const s = (props.raw || '').trim()
if (!s) return { empty: true, isJson: false, html: '' }
try {
const pretty = JSON.stringify(JSON.parse(s), null, 2)
return { empty: false, isJson: true, html: highlight(pretty) }
} catch {
return { empty: false, isJson: false, html: escapeHtml(props.raw || '') }
}
})
</script>
<template>
<pre class="json-view" :class="{ 'is-plain': !parsed.isJson }"><code v-html="parsed.html" /></pre>
</template>
<style scoped>
.json-view {
margin: 0;
padding: 12px 14px;
/* Translucent surface so the dialog's frosted-glass blur shows through */
background: rgba(255, 255, 255, 0.42);
border: 1px solid var(--mc-border-light);
border-radius: 10px;
font-family: var(--mc-font-mono, 'SF Mono', 'Menlo', 'Consolas', monospace);
font-size: 12.5px;
line-height: 1.65;
color: var(--mc-code-text, #1c1410);
max-height: 420px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
tab-size: 2;
}
.json-view code {
font-family: inherit;
}
</style>
<!-- Token palette lives in a non-scoped block: the colored spans are produced
via v-html, so they carry no scoped data-attribute. -->
<style>
.json-view .jv-key { color: #e45649; }
.json-view .jv-string { color: #50a14f; }
.json-view .jv-number { color: #c18401; }
.json-view .jv-boolean { color: #a626a4; }
.json-view .jv-null { color: #a626a4; }
html.dark .json-view {
background: rgba(255, 255, 255, 0.05);
}
html.dark .json-view .jv-key { color: #e06c75; }
html.dark .json-view .jv-string { color: #98c379; }
html.dark .json-view .jv-number { color: #d19a66; }
html.dark .json-view .jv-boolean { color: #c678dd; }
html.dark .json-view .jv-null { color: #c678dd; }
</style>

View File

@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { Loading, Select, ArrowDown } from '@element-plus/icons-vue'
import { Loading, Select, ArrowDown, View } from '@element-plus/icons-vue'
import type { PlanMeta } from '@/types'
import ExecutionDetailDialog from './ExecutionDetailDialog.vue'
const props = defineProps<{
plan: PlanMeta
@ -45,6 +46,27 @@ function truncateResult(text: string, max: number): string {
if (!text || text.length <= max) return text
return text.slice(0, max) + '...'
}
// Full step-result viewer the inline preview is capped at 500 chars; this opens
// the complete result so plan execution stays fully auditable.
const detailVisible = ref(false)
const detailIndex = ref(-1)
const detailResponse = computed(() => props.plan.stepResults?.[detailIndex.value]?.result || '')
const detailTitle = computed(() => {
const i = detailIndex.value
return i >= 0 ? `${i + 1}. ${props.plan.steps[i] || ''}` : ''
})
const detailStatus = computed<'completed' | 'error' | 'running'>(() => {
const st = props.plan.stepResults?.[detailIndex.value]?.status
if (st === 'failed' || st === 'error') return 'error'
if (st === 'completed') return 'completed'
return 'running'
})
function openDetail(index: number) {
detailIndex.value = index
detailVisible.value = true
}
</script>
<template>
@ -55,10 +77,8 @@ function truncateResult(text: string, max: number): string {
<el-icon v-if="isGenerating && !allDone" class="is-loading" :size="14"><Loading /></el-icon>
<el-icon v-else :size="14"><Select /></el-icon>
</span>
<span class="plan-panel__title">
Plan
</span>
<span class="plan-panel__progress">{{ completedCount }}/{{ plan.steps.length }}</span>
<span class="plan-panel__title">{{ $t('chat.executionPlan') }}</span>
<span class="plan-panel__progress">({{ completedCount }}/{{ plan.steps.length }} {{ $t('chat.planDone') }})</span>
<el-icon
class="plan-panel__arrow"
:class="{ 'is-open': !collapsed }"
@ -88,6 +108,13 @@ function truncateResult(text: string, max: number): string {
</span>
<span class="plan-step__index">{{ i + 1 }}.</span>
<span class="plan-step__text">{{ step }}</span>
<el-icon
v-if="plan.stepResults?.[i]?.result"
class="plan-step__detail"
:title="$t('chat.detail.viewDetail')"
:size="12"
@click.stop="openDetail(i)"
><View /></el-icon>
<el-icon
v-if="plan.stepResults?.[i]?.result"
class="plan-step__arrow"
@ -96,7 +123,7 @@ function truncateResult(text: string, max: number): string {
><ArrowDown /></el-icon>
</div>
<!-- 步骤结果可展开 -->
<!-- 步骤结果可展开预览完整内容见详情弹层 -->
<Transition name="plan-slide">
<div v-if="expandedSteps.has(i) && plan.stepResults?.[i]?.result" class="plan-step__result">
<pre>{{ truncateResult(plan.stepResults[i].result, 500) }}</pre>
@ -105,6 +132,13 @@ function truncateResult(text: string, max: number): string {
</div>
</div>
</Transition>
<ExecutionDetailDialog
v-model="detailVisible"
:title="detailTitle"
:status="detailStatus"
:response="detailResponse"
/>
</div>
</template>
@ -226,11 +260,21 @@ function truncateResult(text: string, max: number): string {
color: var(--mc-text-tertiary);
}
.plan-step__detail {
flex-shrink: 0;
margin-left: auto;
color: var(--mc-text-quaternary);
cursor: pointer;
transition: color 0.15s;
}
.plan-step__detail:hover {
color: var(--mc-primary);
}
.plan-step__arrow {
flex-shrink: 0;
color: var(--mc-text-quaternary);
transition: transform 0.2s;
margin-left: auto;
}
.plan-step__arrow.is-open {
transform: rotate(180deg);

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Loading, Select, CloseBold, ArrowDown, Document, Setting, Connection, WarningFilled, Clock } from '@element-plus/icons-vue'
import { Loading, Select, CloseBold, ArrowDown, Document, Setting, Connection, WarningFilled, Clock, View } from '@element-plus/icons-vue'
import { useToolLabel } from '@/composables/useToolLabel'
import type { MessageSegment } from '@/types'
import DelegationNodeView from './DelegationNodeView.vue'
import ExecutionDetailDialog from './ExecutionDetailDialog.vue'
const props = defineProps<{
segment: MessageSegment
@ -97,6 +98,19 @@ const childProgress = computed(() => {
const n = childTools.value.length
return n ? `${n} ${n === 1 ? 'tool' : 'tools'}` : ''
})
// Full request/response detail viewer. Available for regular tool calls (not
// delegation timelines) that carry arguments or a result lets users audit the
// complete payload that the inline card truncates.
const detailVisible = ref(false)
const canViewDetail = computed(() =>
!isDelegation.value && (!!props.segment.toolArgs || !!props.segment.toolResult)
)
const detailStatus = computed<'running' | 'completed' | 'error'>(() => {
if (isError.value) return 'error'
if (isRunning.value) return 'running'
return 'completed'
})
</script>
<template>
@ -117,12 +131,21 @@ const childProgress = computed(() => {
<span v-if="isDelegation && childProgress" class="seg-tool__badge">{{ childProgress }}</span>
<el-icon v-if="isStalled" class="seg-tool__stale" :title="$t('chat.subagentStalled')" :size="12"><WarningFilled /></el-icon>
<span v-if="truncatedArgs" class="seg-tool__args">{{ truncatedArgs }}</span>
<el-icon
v-if="hasBody"
class="seg-tool__arrow"
:class="{ 'is-open': expanded }"
:size="11"
><ArrowDown /></el-icon>
<span class="seg-tool__actions">
<el-icon
v-if="canViewDetail"
class="seg-tool__detail"
:title="$t('chat.detail.viewDetail')"
:size="13"
@click.stop="detailVisible = true"
><View /></el-icon>
<el-icon
v-if="hasBody"
class="seg-tool__arrow"
:class="{ 'is-open': expanded }"
:size="11"
><ArrowDown /></el-icon>
</span>
</div>
<Transition name="seg-slide">
<div v-if="expanded && hasBody" class="seg-tool__body">
@ -164,6 +187,15 @@ const childProgress = computed(() => {
<pre v-if="segment.toolResult">{{ resultPreview }}</pre>
</div>
</Transition>
<ExecutionDetailDialog
v-if="canViewDetail"
v-model="detailVisible"
:title="displayName"
:status="detailStatus"
:request="segment.toolArgs"
:response="segment.toolResult"
/>
</div>
</template>
@ -245,11 +277,30 @@ const childProgress = computed(() => {
border-radius: 3px;
}
/* Trailing controls pinned to the right edge as a single group, so the
detail icon and chevron stay together regardless of whether args render. */
.seg-tool__actions {
flex-shrink: 0;
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
}
.seg-tool__detail {
flex-shrink: 0;
color: var(--mc-text-tertiary);
cursor: pointer;
transition: color 0.15s;
}
.seg-tool__detail:hover {
color: var(--mc-primary);
}
.seg-tool__arrow {
flex-shrink: 0;
color: var(--mc-text-tertiary);
transition: transform 0.2s;
margin-left: auto;
}
.seg-tool__arrow.is-open {
transform: rotate(180deg);

View File

@ -95,6 +95,23 @@ export default {
interrupted: 'Interrupted',
subagentStalled: 'Subagent stalled — no progress',
subagentAsync: 'Running in background — result via task_output',
executionPlan: 'Execution Plan',
planDone: 'done',
detail: {
viewDetail: 'View details',
request: 'Request',
response: 'Response',
copy: 'Copy',
copied: 'Copied',
copyFailed: 'Copy failed',
empty: 'No content',
status: {
running: 'In progress',
completed: 'Completed',
error: 'Failed',
pending: 'Pending',
},
},
expandLines: 'Show more ({hidden} more lines)',
collapse: 'Show less',
failed: 'Generation failed',

View File

@ -95,6 +95,23 @@ export default {
interrupted: '已中断',
subagentStalled: '子 Agent 无进展',
subagentAsync: '后台运行中,结果稍后获取',
executionPlan: '执行计划',
planDone: '已完成',
detail: {
viewDetail: '查看详情',
request: '请求参数',
response: '响应输出',
copy: '复制',
copied: '已复制',
copyFailed: '复制失败',
empty: '无内容',
status: {
running: '进行中',
completed: '已完成',
error: '失败',
pending: '待处理',
},
},
expandLines: '展开(还有 {hidden} 行)',
collapse: '收起',
failed: '生成失败',