mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(chat): surface generated-file artifacts in the run-overview rail
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.
This commit is contained in:
parent
2d04ce92ef
commit
3c6c765e51
@ -1731,6 +1731,11 @@ public class ChatController {
|
||||
|| 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>
|
||||
@ -1753,6 +1758,8 @@ public class ChatController {
|
||||
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;
|
||||
@ -2024,6 +2031,30 @@ public class ChatController {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2147,6 +2178,9 @@ public class ChatController {
|
||||
// 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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -94,6 +94,8 @@ 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 ''
|
||||
@ -101,7 +103,7 @@ const planProgress = computed(() => {
|
||||
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')
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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) => {
|
||||
@ -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.
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -105,6 +105,8 @@ export default {
|
||||
title: '运行总览',
|
||||
plan: '计划进度',
|
||||
subagents: '子 Agent',
|
||||
files: '生成文件',
|
||||
noFiles: '暂无生成文件',
|
||||
noPlan: '暂无执行计划',
|
||||
noSubagents: '暂无子 Agent',
|
||||
collapse: '收起总览',
|
||||
|
||||
@ -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[]
|
||||
@ -268,6 +275,8 @@ export interface MessageMetadata {
|
||||
warnings?: string[]
|
||||
/** 分段式展示数据(新版渲染用) */
|
||||
segments?: MessageSegment[]
|
||||
/** 运行总览展示的已生成文件列表 */
|
||||
generatedFiles?: GeneratedFile[]
|
||||
/** 浏览器执行操作记录 */
|
||||
browserActions?: Array<{
|
||||
action: string
|
||||
|
||||
Loading…
Reference in New Issue
Block a user