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:
jack 2026-06-28 16:06:06 +08:00 committed by GitHub
parent 2d04ce92ef
commit 3c6c765e51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 158 additions and 4 deletions

View File

@ -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

View File

@ -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>

View File

@ -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.

View File

@ -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',

View File

@ -105,6 +105,8 @@ export default {
title: '运行总览',
plan: '计划进度',
subagents: '子 Agent',
files: '生成文件',
noFiles: '暂无生成文件',
noPlan: '暂无执行计划',
noSubagents: '暂无子 Agent',
collapse: '收起总览',

View File

@ -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