mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
Extract generated-file download links from tool results — on the backend (persisted to message metadata for history) and on the frontend (live during SSE) — de-duplicate by URL, and render them as a Generated Files section with file-type icons and a rail badge.
428 lines
13 KiB
Vue
428 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
import { Connection, Loading, Expand, Fold } from '@element-plus/icons-vue'
|
|
import { useToolLabel } from '@/composables/useToolLabel'
|
|
import type { Message, MessageSegment, DelegationNode, PlanMeta, GeneratedFile } from '@/types'
|
|
import PlanStepsPanel from './PlanStepsPanel.vue'
|
|
import DelegationNodeView from './DelegationNodeView.vue'
|
|
|
|
/**
|
|
* Always-on "run overview" rail mounted beside the chat thread. It surfaces the
|
|
* data the agent already streams — the execution plan and the live sub-agent
|
|
* delegation tree — so long-running tasks can be tracked without scrolling back
|
|
* through the message history.
|
|
*
|
|
* Purely derived from the reactive chat messages: the plan lives on the latest
|
|
* assistant message's metadata, and each delegation timeline segment becomes a
|
|
* top-level node in the sub-agent tree. No extra API calls — the sub-agent
|
|
* REST surface is admin-gated, but the same state arrives over the chat stream.
|
|
*
|
|
* Visibility: shown whenever there is something to show, and additionally kept
|
|
* mounted with a "planning…" placeholder while a plan-capable agent is running
|
|
* so the rail does not pop in and out at the start of every turn. For non-plan
|
|
* agents it stays hidden until an actual plan or delegation appears. On narrow
|
|
* viewports the expanded rail floats over the chat as a drawer instead of
|
|
* squeezing the conversation column.
|
|
*/
|
|
const props = defineProps<{
|
|
messages: Message[]
|
|
isGenerating: boolean
|
|
/** Active agent type; "plan_execute" agents get the planning placeholder. */
|
|
agentType?: string
|
|
}>()
|
|
|
|
const { getToolLabel } = useToolLabel()
|
|
|
|
const collapsed = ref(false)
|
|
|
|
// Narrow-viewport handling: below this width the expanded rail overlays the chat
|
|
// as a drawer (rather than display:none, which hid the feature entirely).
|
|
const isNarrow = ref(false)
|
|
let mql: MediaQueryList | null = null
|
|
function onMqlChange(e: MediaQueryListEvent | MediaQueryList) {
|
|
isNarrow.value = e.matches
|
|
// Default to collapsed on narrow screens so the drawer never covers the chat
|
|
// unprompted; the user expands it deliberately.
|
|
if (e.matches) collapsed.value = true
|
|
}
|
|
onMounted(() => {
|
|
mql = window.matchMedia('(max-width: 1280px)')
|
|
onMqlChange(mql)
|
|
mql.addEventListener('change', onMqlChange)
|
|
})
|
|
onBeforeUnmount(() => {
|
|
mql?.removeEventListener('change', onMqlChange)
|
|
})
|
|
|
|
/** The most recent assistant message — the turn whose progress we track. */
|
|
const latestAssistant = computed<Message | undefined>(() => {
|
|
for (let i = props.messages.length - 1; i >= 0; i--) {
|
|
if (props.messages[i].role === 'assistant') return props.messages[i]
|
|
}
|
|
return undefined
|
|
})
|
|
|
|
const currentPlan = computed<PlanMeta | undefined>(() => latestAssistant.value?.metadata?.plan)
|
|
|
|
/** Delegation segments carry a "→" tool-name prefix injected upstream. */
|
|
function isDelegationSegment(seg: MessageSegment): boolean {
|
|
return seg.type === 'tool_call' && (seg.toolName || '').startsWith('→')
|
|
}
|
|
|
|
function toNode(seg: MessageSegment): DelegationNode {
|
|
const raw = (seg.toolName || '').replace(/^→\s*/, '').trim()
|
|
const status: DelegationNode['status'] =
|
|
seg.status === 'error' ? 'error' : seg.status === 'completed' ? 'completed' : 'running'
|
|
return {
|
|
subagentId: seg.subagentId || seg.id,
|
|
agentName: getToolLabel(raw) || raw,
|
|
status,
|
|
depth: 1,
|
|
task: seg.toolArgs,
|
|
plan: seg.childTimeline?.plan,
|
|
tools: seg.childTimeline?.tools,
|
|
result: seg.toolResult,
|
|
stale: seg.delegationStale,
|
|
async: seg.delegationAsync,
|
|
children: seg.childTimeline?.children || [],
|
|
}
|
|
}
|
|
|
|
const subagentNodes = computed<DelegationNode[]>(() =>
|
|
(latestAssistant.value?.metadata?.segments || []).filter(isDelegationSegment).map(toNode)
|
|
)
|
|
|
|
const runningSubagents = computed(() => subagentNodes.value.filter(n => n.status === 'running').length)
|
|
|
|
const generatedFiles = computed<GeneratedFile[]>(() => latestAssistant.value?.metadata?.generatedFiles || [])
|
|
|
|
const planProgress = computed(() => {
|
|
const p = currentPlan.value
|
|
if (!p?.steps?.length) return ''
|
|
const done = p.stepResults?.filter(r => r?.status === 'completed').length || 0
|
|
return `${done}/${p.steps.length}`
|
|
})
|
|
|
|
const hasContent = computed(() => !!currentPlan.value || subagentNodes.value.length > 0 || generatedFiles.value.length > 0)
|
|
|
|
/** Plan-capable agents earn the placeholder so the rail stays stable mid-run. */
|
|
const expectsPlan = computed(() => props.agentType === 'plan_execute')
|
|
|
|
/** Keep the rail mounted while a plan-capable agent runs, even before the plan
|
|
* has streamed in — avoids the rail flickering in once per turn. */
|
|
const showPanel = computed(() => hasContent.value || (props.isGenerating && expectsPlan.value))
|
|
|
|
/** Show the "planning…" placeholder only when a plan is genuinely expected. */
|
|
const planning = computed(() => !currentPlan.value && props.isGenerating && expectsPlan.value)
|
|
|
|
/** The floating-drawer backdrop is only relevant on narrow, expanded state. */
|
|
const showBackdrop = computed(() => isNarrow.value && !collapsed.value && showPanel.value)
|
|
|
|
/** Derive a CSS class from the filename extension for a lightweight file-type icon. */
|
|
function fileIconClass(filename: string): string {
|
|
const ext = (filename.split('.').pop() || '').toLowerCase()
|
|
return `is-${ext}`
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<teleport to="body" :disabled="!showBackdrop">
|
|
<div v-if="showBackdrop" class="run-overview__backdrop" @click="collapsed = true"></div>
|
|
</teleport>
|
|
|
|
<aside
|
|
v-if="showPanel"
|
|
class="run-overview"
|
|
:class="{ 'is-collapsed': collapsed, 'is-narrow': isNarrow }"
|
|
>
|
|
<!-- Collapsed rail: badges only -->
|
|
<button
|
|
v-if="collapsed"
|
|
class="run-overview__rail"
|
|
:title="$t('chat.runOverview.expand')"
|
|
@click="collapsed = false"
|
|
>
|
|
<el-icon :size="16"><Expand /></el-icon>
|
|
<span v-if="planProgress" class="run-overview__rail-badge">{{ planProgress }}</span>
|
|
<span v-if="subagentNodes.length" class="run-overview__rail-badge is-sub">
|
|
<el-icon :size="12"><Connection /></el-icon>{{ subagentNodes.length }}
|
|
</span>
|
|
<span v-if="generatedFiles.length" class="run-overview__rail-badge is-file">
|
|
{{ generatedFiles.length }}
|
|
</span>
|
|
<el-icon v-if="isGenerating" class="run-overview__rail-live is-loading" :size="13"><Loading /></el-icon>
|
|
</button>
|
|
|
|
<template v-else>
|
|
<header class="run-overview__header">
|
|
<span class="run-overview__title">{{ $t('chat.runOverview.title') }}</span>
|
|
<el-icon
|
|
v-if="isGenerating"
|
|
class="run-overview__live is-loading"
|
|
:title="$t('chat.runOverview.running')"
|
|
:size="13"
|
|
><Loading /></el-icon>
|
|
<button
|
|
class="run-overview__collapse"
|
|
:title="$t('chat.runOverview.collapse')"
|
|
@click="collapsed = true"
|
|
><el-icon :size="15"><Fold /></el-icon></button>
|
|
</header>
|
|
|
|
<div class="run-overview__body">
|
|
<!-- Plan progress -->
|
|
<section class="run-overview__section">
|
|
<div class="run-overview__section-title">{{ $t('chat.runOverview.plan') }}</div>
|
|
<PlanStepsPanel
|
|
v-if="currentPlan"
|
|
:plan="currentPlan"
|
|
:is-generating="isGenerating"
|
|
/>
|
|
<p v-else-if="planning" class="run-overview__empty is-planning">
|
|
<el-icon class="is-loading" :size="13"><Loading /></el-icon>
|
|
{{ $t('chat.runOverview.planning') }}
|
|
</p>
|
|
<p v-else class="run-overview__empty">{{ $t('chat.runOverview.noPlan') }}</p>
|
|
</section>
|
|
|
|
<!-- Sub-agents -->
|
|
<section class="run-overview__section">
|
|
<div class="run-overview__section-title">
|
|
{{ $t('chat.runOverview.subagents') }}
|
|
<span v-if="runningSubagents" class="run-overview__count">
|
|
{{ runningSubagents }} {{ $t('chat.runOverview.running') }}
|
|
</span>
|
|
</div>
|
|
<div v-if="subagentNodes.length" class="run-overview__subagents">
|
|
<DelegationNodeView
|
|
v-for="node in subagentNodes"
|
|
:key="node.subagentId"
|
|
:node="node"
|
|
/>
|
|
</div>
|
|
<p v-else class="run-overview__empty">{{ $t('chat.runOverview.noSubagents') }}</p>
|
|
</section>
|
|
|
|
<!-- Generated files -->
|
|
<section v-if="generatedFiles.length" class="run-overview__section">
|
|
<div class="run-overview__section-title">
|
|
{{ $t('chat.runOverview.files') }}
|
|
<span class="run-overview__count">{{ generatedFiles.length }}</span>
|
|
</div>
|
|
<div class="run-overview__files">
|
|
<a
|
|
v-for="(file, idx) in generatedFiles"
|
|
:key="idx"
|
|
:href="file.url"
|
|
target="_blank"
|
|
rel="noopener"
|
|
class="run-overview__file"
|
|
:title="file.filename"
|
|
>
|
|
<span class="run-overview__file-icon" :class="fileIconClass(file.filename)"></span>
|
|
<span class="run-overview__file-name">{{ file.filename }}</span>
|
|
</a>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
</aside>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.run-overview {
|
|
width: 320px;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-left: 1px solid var(--mc-border-light);
|
|
background: var(--mc-bg-muted, #f9f7f5);
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
transition: width 0.2s ease;
|
|
}
|
|
.run-overview.is-collapsed {
|
|
width: 44px;
|
|
}
|
|
|
|
/* Narrow viewports: the expanded rail floats over the chat as a drawer instead
|
|
of squeezing the conversation column. The collapsed rail (44px) stays in flow. */
|
|
.run-overview.is-narrow:not(.is-collapsed) {
|
|
position: fixed;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: min(340px, 88vw);
|
|
z-index: 2000;
|
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.18);
|
|
}
|
|
.run-overview__backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1999;
|
|
background: rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.run-overview__rail {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 0;
|
|
width: 100%;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
color: var(--mc-text-tertiary);
|
|
}
|
|
.run-overview__rail:hover {
|
|
color: var(--mc-primary);
|
|
}
|
|
.run-overview__rail-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--mc-text-secondary);
|
|
background: var(--mc-bg-sunken, #f3f0ed);
|
|
border: 1px solid var(--mc-border-light);
|
|
border-radius: 10px;
|
|
padding: 1px 6px;
|
|
}
|
|
.run-overview__rail-badge.is-sub {
|
|
color: var(--mc-primary);
|
|
}
|
|
.run-overview__rail-badge.is-file {
|
|
color: var(--mc-success, #67c23a);
|
|
}
|
|
.run-overview__rail-live {
|
|
color: var(--mc-primary);
|
|
}
|
|
|
|
.run-overview__header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--mc-border-light);
|
|
flex-shrink: 0;
|
|
}
|
|
.run-overview__title {
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
color: var(--mc-text-primary);
|
|
}
|
|
.run-overview__live {
|
|
color: var(--mc-primary);
|
|
}
|
|
.run-overview__collapse {
|
|
margin-left: auto;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
color: var(--mc-text-tertiary);
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 2px;
|
|
border-radius: 4px;
|
|
}
|
|
.run-overview__collapse:hover {
|
|
color: var(--mc-text-primary);
|
|
background: var(--mc-bg-hover, #f0ece8);
|
|
}
|
|
|
|
.run-overview__body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px 12px 16px;
|
|
min-height: 0;
|
|
}
|
|
|
|
.run-overview__section + .run-overview__section {
|
|
margin-top: 14px;
|
|
}
|
|
.run-overview__section-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--mc-text-tertiary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
margin-bottom: 6px;
|
|
}
|
|
.run-overview__count {
|
|
font-weight: 500;
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
color: var(--mc-primary);
|
|
}
|
|
|
|
.run-overview__empty {
|
|
margin: 0;
|
|
padding: 10px 0;
|
|
font-size: 12px;
|
|
color: var(--mc-text-quaternary, #c0bfbc);
|
|
text-align: center;
|
|
}
|
|
.run-overview__empty.is-planning {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
color: var(--mc-primary);
|
|
}
|
|
|
|
.run-overview__subagents {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.run-overview__files {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.run-overview__file {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 5px 8px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--mc-border-light);
|
|
background: var(--mc-bg-sunken, #f3f0ed);
|
|
text-decoration: none;
|
|
font-size: 12px;
|
|
color: var(--mc-text-primary);
|
|
transition: border-color 0.15s, background 0.15s;
|
|
}
|
|
.run-overview__file:hover {
|
|
border-color: var(--mc-primary);
|
|
background: var(--mc-bg-hover, #f0ece8);
|
|
}
|
|
.run-overview__file-icon {
|
|
flex-shrink: 0;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 3px;
|
|
background: var(--mc-text-quaternary, #c0bfbc);
|
|
}
|
|
.run-overview__file-icon.is-docx { background: #2b579a; }
|
|
.run-overview__file-icon.is-xlsx { background: #217346; }
|
|
.run-overview__file-icon.is-pptx { background: #d24726; }
|
|
.run-overview__file-icon.is-pdf { background: #db4437; }
|
|
.run-overview__file-icon.is-png,
|
|
.run-overview__file-icon.is-jpg,
|
|
.run-overview__file-icon.is-jpeg,
|
|
.run-overview__file-icon.is-gif,
|
|
.run-overview__file-icon.is-svg { background: #e8a33d; }
|
|
.run-overview__file-name {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|