Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1731,6 +1731,11 @@ private boolean isClientDisconnect(Throwable e) {
|| lower.contains("client abort") || lower.contains("closed");
}

/** Markdown link pointing at a generated-file download URL. Used by the
* StreamAccumulator to surface generated artifacts in the run-overview rail. */
private static final java.util.regex.Pattern GENERATED_FILE_LINK_PATTERN =
java.util.regex.Pattern.compile("\\[([^\\]]+)\\]\\(([^)]*?/api/v1/files/generated/[a-zA-Z0-9-]+)\\)");

/**
* 流式累积器 — 收集 StreamDelta 事件,持久化到 DB。
* <p>
Expand All @@ -1753,6 +1758,8 @@ private final class StreamAccumulator {
private final List<Map<String, Object>> planStepResults = new ArrayList<>();
/** RFC-052: tool names whose returnDirect output was folded into the assistant message */
private final List<String> directToolNames = new ArrayList<>();
/** Generated file artifacts extracted from tool results — surfaced in the run-overview rail. */
private final List<Map<String, Object>> generatedFiles = new ArrayList<>();
private int segCounter = 0;
private int promptTokens = 0;
private int completionTokens = 0;
Expand Down Expand Up @@ -2024,6 +2031,30 @@ private void accumulateToolEvent(String eventType, Map<String, Object> data, Str
break;
}
}
// Extract generated-file links from the tool result so the
// run-overview rail can surface artifacts without re-scanning
// segments on the frontend.
extractGeneratedFiles(String.valueOf(data.getOrDefault("result", "")), toolName);
}
}

/** Scan a tool result for markdown links pointing at generated-file
* download URLs and collect them into {@link #generatedFiles}.
* De-duplicates by URL so a link echoed in later tool results doesn't
* produce duplicate entries in the run-overview rail. */
private void extractGeneratedFiles(String result, String toolName) {
if (result == null || result.isBlank()) return;
java.util.regex.Matcher m = GENERATED_FILE_LINK_PATTERN.matcher(result);
while (m.find()) {
String url = m.group(2);
boolean dup = generatedFiles.stream()
.anyMatch(f -> url.equals(String.valueOf(f.get("url"))));
if (dup) continue;
Map<String, Object> file = new LinkedHashMap<>();
file.put("filename", m.group(1));
file.put("url", url);
file.put("toolName", toolName);
generatedFiles.add(file);
}
}

Expand Down Expand Up @@ -2147,6 +2178,9 @@ synchronized String toMetadataJson() {
// historical messages as "data returned directly by tool".
metadata.put("directToolNames", directToolNames);
}
if (!generatedFiles.isEmpty()) {
metadata.put("generatedFiles", generatedFiles);
}
if (!finishReason.isEmpty()) {
// Surface graph FinishReason so MemorySummarizationGate and
// any other downstream consumer can branch on a structured
Expand Down
84 changes: 82 additions & 2 deletions mateclaw-ui/src/components/chat/RunOverviewPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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 } from '@/types'
import type { Message, MessageSegment, DelegationNode, PlanMeta, GeneratedFile } from '@/types'
import PlanStepsPanel from './PlanStepsPanel.vue'
import DelegationNodeView from './DelegationNodeView.vue'

Expand Down Expand Up @@ -94,14 +94,16 @@ const subagentNodes = computed<DelegationNode[]>(() =>

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)
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')
Expand All @@ -115,6 +117,12 @@ const planning = computed(() => !currentPlan.value && props.isGenerating && expe

/** 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>
Expand All @@ -139,6 +147,9 @@ const showBackdrop = computed(() => isNarrow.value && !collapsed.value && showPa
<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>

Expand Down Expand Up @@ -191,6 +202,28 @@ const showBackdrop = computed(() => isNarrow.value && !collapsed.value && showPa
</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>
Expand Down Expand Up @@ -260,6 +293,9 @@ const showBackdrop = computed(() => isNarrow.value && !collapsed.value && showPa
.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);
}
Expand Down Expand Up @@ -344,4 +380,48 @@ const showBackdrop = computed(() => isNarrow.value && !collapsed.value && showPa
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>
31 changes: 29 additions & 2 deletions mateclaw-ui/src/composables/chat/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useMessageQueue } from './useMessageQueue'
import { useGoalStore } from '@/stores/useGoalStore'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
import { storeToRefs } from 'pinia'
import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData, DelegationNode, DelegationToolEntry, PlanMeta } from '@/types'
import type { Message, MessageContentPart, MessageSegment, StreamPhase, HeartbeatData, QueuedMessage, PhaseEventData, DelegationNode, DelegationToolEntry, PlanMeta, GeneratedFile } from '@/types'
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
import { http } from '@/api'

Expand Down Expand Up @@ -417,6 +417,21 @@ export function useChat(options: UseChatOptions): UseChatReturn {
return null
}

/** Markdown link pointing at a generated-file download URL. */
const GENERATED_FILE_LINK_RE = /\[([^\]]+)\]\(([^)]*?\/api\/v1\/files\/generated\/[a-zA-Z0-9-]+)\)/g

/** Extract generated-file artifacts from a tool result string. */
function extractGeneratedFiles(result: unknown, toolName: string): GeneratedFile[] {
if (typeof result !== 'string' || !result) return []
const files: GeneratedFile[] = []
let m: RegExpExecArray | null
GENERATED_FILE_LINK_RE.lastIndex = 0
while ((m = GENERATED_FILE_LINK_RE.exec(result)) !== null) {
files.push({ filename: m[1], url: m[2], toolName })
}
return files
}

// ===== SSE event handlers =====

stream.on('content_delta', (data) => {
Expand Down Expand Up @@ -860,9 +875,21 @@ export function useChat(options: UseChatOptions): UseChatReturn {
status: 'completed'
}
}
// Extract generated-file links from the tool result for the run-overview rail.
// De-duplicate by URL so a link echoed in later tool results doesn't
// produce duplicate entries.
const newFiles = extractGeneratedFiles(data.result, data.toolName)
const existingFiles = (metadata?.generatedFiles || []) as GeneratedFile[]
const existingUrls = new Set(existingFiles.map(f => f.url))
const dedupedNew = newFiles.filter(f => !existingUrls.has(f.url))
const generatedFiles = dedupedNew.length
? [...existingFiles, ...dedupedNew]
: existingFiles.length
? existingFiles
: undefined
updateMessage(currentAssistantId.value, {
...msg,
metadata: { ...metadata, toolCalls, runningToolName: undefined }
metadata: { ...metadata, toolCalls, runningToolName: undefined, generatedFiles }
} as any)
}
// Segments: prefer toolCallId match, fall back to first-running by toolName.
Expand Down
2 changes: 2 additions & 0 deletions mateclaw-ui/src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export default {
title: 'Run Overview',
plan: 'Plan Progress',
subagents: 'Sub-agents',
files: 'Generated Files',
noFiles: 'No generated files yet',
noPlan: 'No execution plan yet',
noSubagents: 'No sub-agents yet',
collapse: 'Collapse',
Expand Down
2 changes: 2 additions & 0 deletions mateclaw-ui/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export default {
title: '运行总览',
plan: '计划进度',
subagents: '子 Agent',
files: '生成文件',
noFiles: '暂无生成文件',
noPlan: '暂无执行计划',
noSubagents: '暂无子 Agent',
collapse: '收起总览',
Expand Down
9 changes: 9 additions & 0 deletions mateclaw-ui/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,13 @@ export interface MessageSegment {
supersededReason?: string
}

/** A file artifact generated by a tool during the turn, surfaced in the run-overview rail. */
export interface GeneratedFile {
filename: string
url: string
toolName?: string
}

export interface MessageMetadata {
currentPhase?: string
toolCalls?: ToolCallMeta[]
Expand All @@ -268,6 +275,8 @@ export interface MessageMetadata {
warnings?: string[]
/** 分段式展示数据(新版渲染用) */
segments?: MessageSegment[]
/** 运行总览展示的已生成文件列表 */
generatedFiles?: GeneratedFile[]
/** 浏览器执行操作记录 */
browserActions?: Array<{
action: string
Expand Down