This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/ai-history-and-enhancement in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 2df204c77fc3f4b7c443e168cf3f58ce89411b30 Author: Wu Sheng <[email protected]> AuthorDate: Wed Jul 8 19:19:38 2026 +0800 feat(ai): capture the network process-graph as a frozen block; honest fallbacks Closes a capture-replay gap the scan found: network profiling's result is a process-conversation graph — DATA we can read, not a reason to fall back to text. analyze_profiling(network) now reads getProcessTopology (one instance) and emits a frozen process-topology block that renders the stateless ProcessTopologyGraph from the captured nodes/calls — replays on reload with no re-query, stamped + replay-badged like every other captured block. Text is the last resort, only when there's nothing to capture: - No Rover/eBPF agent reporting → no block, the agent says network profiling is unavailable at this deployment. - A fired flame task that ran but produced no stacks → say nothing met the threshold; if it was created minutes ago and still empty, say profiling is unsupported here and STOP — don't retry indefinitely (only a just-created task gets the 2–4 min "let it collect" note). New 'process-topology' block: spec (BFF+UI lockstep) + SSE + emitter trio + append + dispatch + ChatProcessTopologyBlock.vue. skills.md states the rule: never point the user at a live tab (it drifts) — replay the snapshot or say it in text. Validated live: network analyze on the demo (no rover) emitted no block and the agent relayed the unavailable message. --- apps/bff/src/ai/context.ts | 3 + apps/bff/src/ai/figure-buffer.ts | 9 +++ apps/bff/src/ai/http/chat.ts | 2 + apps/bff/src/ai/resources/prompts/skills.md | 2 +- apps/bff/src/ai/skill/triggers/tools.test.ts | 1 + apps/bff/src/ai/skill/triggers/tools.ts | 27 ++++++--- apps/bff/src/ai/types.ts | 14 +++++ apps/bff/src/logic/oap/profiling.ts | 53 +++++++++-------- apps/ui/src/ai/ChatProcessTopologyBlock.vue | 89 ++++++++++++++++++++++++++++ apps/ui/src/ai/ChatTranscript.vue | 2 + apps/ui/src/ai/types.ts | 14 +++++ apps/ui/src/ai/useAiConversations.ts | 1 + apps/ui/src/i18n/locales/en.json | 2 + 13 files changed, 186 insertions(+), 33 deletions(-) diff --git a/apps/bff/src/ai/context.ts b/apps/bff/src/ai/context.ts index a0070c3..88f776b 100644 --- a/apps/bff/src/ai/context.ts +++ b/apps/bff/src/ai/context.ts @@ -37,6 +37,7 @@ import type { HierarchySpec, InstanceTopologySpec, PodLogSpec, + ProcessTopologySpec, ProfilingResultSpec, ProposalSpec, BrowserErrorsSpec, @@ -79,6 +80,8 @@ export interface AiRequestContext { emitProposal(spec: ProposalSpec): void; /** Emit a captured profiling-result flame block (read-only, frozen on reload). */ emitProfiling(spec: ProfilingResultSpec): void; + /** Emit a captured network process-conversation graph block (frozen on reload). */ + emitProcessTopology(spec: ProcessTopologySpec): void; /** Mount a LIVE-TAIL pod-log pane inline. Carries the resolved pod/container + * an initial snapshot; the UI pane re-polls OAP on an interval to fetch newer * lines (on-demand logs are a live tail, not a one-shot figure). Verb-gated diff --git a/apps/bff/src/ai/figure-buffer.ts b/apps/bff/src/ai/figure-buffer.ts index 47a9de4..af5a278 100644 --- a/apps/bff/src/ai/figure-buffer.ts +++ b/apps/bff/src/ai/figure-buffer.ts @@ -30,6 +30,7 @@ import type { HierarchySpec, InstanceTopologySpec, PodLogSpec, + ProcessTopologySpec, ProfilingResultSpec, ProposalSpec, SseEvent, @@ -50,6 +51,8 @@ export interface FigureBuffer { emitProposal(spec: ProposalSpec): void; /** Emit a captured profiling-result (flame) block. Same numbering; flushes first. */ emitProfiling(spec: ProfilingResultSpec): void; + /** Emit a captured network process-conversation graph block. Same numbering; flushes first. */ + emitProcessTopology(spec: ProcessTopologySpec): void; /** Emit a live-tail pod-log pane. Same numbering; flushes any pending group. */ emitPodLogs(spec: PodLogSpec): void; /** Emit a cross-layer hierarchy block. Same numbering; flushes any pending group. */ @@ -111,6 +114,11 @@ export function createFigureBuffer(send: (ev: SseEvent) => void): FigureBuffer { send({ type: 'profiling', n: ++figureN, spec }); }; + const emitProcessTopology = (spec: ProcessTopologySpec): void => { + flushFigures(); + send({ type: 'process-topology', n: ++figureN, spec }); + }; + const emitPodLogs = (spec: PodLogSpec): void => { flushFigures(); send({ type: 'podlogs', n: ++figureN, spec }); @@ -166,6 +174,7 @@ export function createFigureBuffer(send: (ev: SseEvent) => void): FigureBuffer { emitSubPage, emitProposal, emitProfiling, + emitProcessTopology, emitPodLogs, emitHierarchy, emitTopology, diff --git a/apps/bff/src/ai/http/chat.ts b/apps/bff/src/ai/http/chat.ts index 283a86e..49e91f1 100644 --- a/apps/bff/src/ai/http/chat.ts +++ b/apps/bff/src/ai/http/chat.ts @@ -165,6 +165,7 @@ export function registerAiRoutes(app: FastifyInstance, deps: AiChatRouteDeps): v emitSubPage, emitProposal, emitProfiling, + emitProcessTopology, emitPodLogs, emitHierarchy, emitTopology, @@ -190,6 +191,7 @@ export function registerAiRoutes(app: FastifyInstance, deps: AiChatRouteDeps): v emitSubPage, emitProposal, emitProfiling, + emitProcessTopology, emitPodLogs, emitHierarchy, emitTopology, diff --git a/apps/bff/src/ai/resources/prompts/skills.md b/apps/bff/src/ai/resources/prompts/skills.md index c24e70b..576ee12 100644 --- a/apps/bff/src/ai/resources/prompts/skills.md +++ b/apps/bff/src/ai/resources/prompts/skills.md @@ -72,4 +72,4 @@ SUB-PAGE TOOLS — mount an interactive feature view as a card (opens the full p TRIGGERS skill — gated profiling (propose_profiling) + analysis (analyze_profiling) - You are READ-ONLY except for propose_profiling. When metrics + pod logs cannot localise a cause and a PROFILE would confirm a specific hypothesis, call propose_profiling — it presents a decision card (your analysed cause, why profiling, what you expect); the USER approves it in a popout; it runs only after approval; you analyse the result in a LATER turn. Never assume it ran, and never fabricate its output. - Pick the profilingType by READING, not assuming: kb_layer_capabilities(layer).components says which of trace/async/pprof/ebpf/network the layer supports; async-profiler is JVM-only and pprof is Go-only (read the instance language via kb_resolve_scope_drill to choose), trace + eBPF are language-agnostic, network profiling yields a process-conversation graph. The tool re-validates the type against the layer and resolves target instances. -- analyze_profiling(layer, service, profilingType) is READ-only — call it in that later turn (or whenever the user asks to analyse an existing profile). trace/pprof/async/ebpf render a flame graph inline and the reply lists the hottest self-time frames (use them to name the cause); network has no flame, so it returns a TEXT read-out of the process-conversation graph — relay that. Never point the user at a live tab for a result: a tab re-queries and drifts from the data you captured — eit [...] +- analyze_profiling(layer, service, profilingType) is READ-only — call it in that later turn (or whenever the user asks to analyse an existing profile). trace/pprof/async/ebpf render a flame graph inline and the reply lists the hottest self-time frames (use them to name the cause); network CAPTURES + renders the process-conversation graph inline when processes report, and when no Rover/eBPF agent is present it says so in text — relay that. Never point the user at a live tab for a result: [...] diff --git a/apps/bff/src/ai/skill/triggers/tools.test.ts b/apps/bff/src/ai/skill/triggers/tools.test.ts index b8b91b8..59877de 100644 --- a/apps/bff/src/ai/skill/triggers/tools.test.ts +++ b/apps/bff/src/ai/skill/triggers/tools.test.ts @@ -29,6 +29,7 @@ vi.mock('../../../logic/oap/profiling.js', () => ({ { id: 'i-2', name: 'inst-2', language: 'java' }, ]), analyzeProfiling: vi.fn(), + analyzeNetworkProfiling: vi.fn(), })); import { triggerTools } from './tools.js'; diff --git a/apps/bff/src/ai/skill/triggers/tools.ts b/apps/bff/src/ai/skill/triggers/tools.ts index b832986..4732a33 100644 --- a/apps/bff/src/ai/skill/triggers/tools.ts +++ b/apps/bff/src/ai/skill/triggers/tools.ts @@ -165,15 +165,23 @@ export function triggerTools(ctx: AiRequestContext): StructuredToolInterface[] { const analyze = tool( async ({ layer, service, profilingType, taskId }): Promise<string> => { // Network profiling has no flame — its result is a process-conversation - // graph. Read it out as TEXT rather than hand off to a live tab (which would - // drift from what we read). + // graph. CAPTURE it and render a frozen block (never a live tab, which would + // drift). When no processes report — Rover/eBPF absent — say it out in text. if (profilingType === 'network') { - const s = await analyzeNetworkProfiling(ctx.opts, layer, service, ctx.window); - if (!s.reachable) return `Could not read the network-profiling result for ${service}: ${s.error ?? 'unreachable'}.`; - if (!s.processCount) { - return `No process-conversation data for ${service} — network profiling needs a Rover eBPF agent, and none is reporting here.`; + const r = await analyzeNetworkProfiling(ctx.opts, layer, service, ctx.window); + const topo = r.topology; + if (!topo.reachable) return `Could not read the network-profiling result for ${service}: ${topo.error ?? 'unreachable'}.`; + if (!topo.nodes.length) { + return `No process-conversation data for ${service} — network/eBPF profiling needs a Rover eBPF agent, and none is reporting at this deployment. Tell the user network profiling is unavailable here.`; } - return `Network profiling for ${service} (instance ${s.instanceName}): ${s.processCount} process(es) in ${s.conversationCount} conversation(s). Top: ${s.conversations.join('; ')}.`; + ctx.emitProcessTopology({ + title: `Network profiling — ${service}`, + layer: layer.toUpperCase(), + service, + instanceName: r.instanceName, + replayData: topo, + }); + return `Rendered the network process-conversation graph for ${service} (instance ${r.instanceName}): ${topo.nodes.length} process(es), ${topo.calls.length} conversation(s).`; } const a = await analyzeProfiling({ opts: ctx.opts, profilingType, layerKey: layer, service, taskId }); ctx.emitProfiling({ @@ -192,7 +200,10 @@ export function triggerTools(ctx: AiRequestContext): StructuredToolInterface[] { }); if (!a.reachable) return `Could not read the ${profilingType} profile for ${service}: ${a.error ?? 'unreachable'}.`; if (!a.trees.length) { - return `The ${profilingType} profiling task for ${service} has not collected analyzable data yet${a.error ? ` (${a.error})` : ''}. Ask the user to let it finish, then analyze again.`; + const collected = a.logs.length > 0 || (a.summary.segmentCount ?? 0) > 0; + return collected + ? `The ${profilingType} profiling task for ${service} ran but produced no analyzable stacks${a.error ? ` (${a.error})` : ''} — nothing met the sampling threshold. Tell the user; do not retry indefinitely.` + : `The ${profilingType} profiling task for ${service} returned no data${a.error ? ` (${a.error})` : ''}. If it was created a few minutes ago and still has nothing, ${profilingType} profiling is likely unsupported at this deployment (the agent may lack the profiling plugin) — tell the user that rather than retrying. If it was JUST created, give it 2–4 minutes to collect, then analyze once more.`; } return `Rendered the ${profilingType} profile for ${service}: ${a.summary.frameCount} stack frames${a.tip ? ` (partial — ${a.tip})` : ''}.${summarizeProfile(a)}`; }, diff --git a/apps/bff/src/ai/types.ts b/apps/bff/src/ai/types.ts index 428a61b..2f7362e 100644 --- a/apps/bff/src/ai/types.ts +++ b/apps/bff/src/ai/types.ts @@ -31,6 +31,7 @@ import type { EndpointDependencyResponse, InstanceTopologyResponse, LogsResponse, + ProcessTopologyResponse, ProfileAnalyzationTree, ServiceHierarchyResponse, TopologyResponse, @@ -104,6 +105,18 @@ export interface ProposalSpec { expectation: string; } +/** A captured, frozen network process-conversation graph (network profiling's + * result). ProcessTopologyGraph is a stateless renderer, so the block replays + * `replayData` directly — no re-query. Emitted only when processes exist; an + * absent Rover/eBPF agent is said out in text instead. */ +export interface ProcessTopologySpec { + title: string; + layer: string; + service: string; + instanceName: string | null; + replayData: ProcessTopologyResponse; +} + /** A captured, frozen profiling result — analyzed flame `trees` + task facts + * logs. Renders the Profiling-tab flame from `trees`; never re-queries on * reload (empty `trees` = nothing collected yet). */ @@ -327,6 +340,7 @@ export type SseEvent = | { type: 'subpage'; n: number; spec: SubPageSpec } | { type: 'proposal'; n: number; spec: ProposalSpec } | { type: 'profiling'; n: number; spec: ProfilingResultSpec } + | { type: 'process-topology'; n: number; spec: ProcessTopologySpec } | { type: 'podlogs'; n: number; spec: PodLogSpec } | { type: 'hierarchy'; n: number; spec: HierarchySpec } | { type: 'topology'; n: number; spec: TopologySpec } diff --git a/apps/bff/src/logic/oap/profiling.ts b/apps/bff/src/logic/oap/profiling.ts index 264cca9..cec3f7a 100644 --- a/apps/bff/src/logic/oap/profiling.ts +++ b/apps/bff/src/logic/oap/profiling.ts @@ -25,7 +25,13 @@ * no-data states, not a reason to retry. */ -import type { ProfileAnalyzationElement, ProfileAnalyzationTree } from '@skywalking-horizon-ui/api-client'; +import type { + ProcessCall, + ProcessNode, + ProcessTopologyResponse, + ProfileAnalyzationElement, + ProfileAnalyzationTree, +} from '@skywalking-horizon-ui/api-client'; import type { GraphqlOptions } from '../../client/graphql.js'; import { graphqlPost } from '../../client/graphql.js'; @@ -224,50 +230,49 @@ export async function listServiceInstances( const GET_PROCESS_TOPOLOGY = /* GraphQL */ ` query AiProcessTopology($serviceInstanceId: ID!, $duration: Duration!) { topology: getProcessTopology(serviceInstanceId: $serviceInstanceId, duration: $duration) { - nodes { id name } - calls { source target } + nodes { id name isReal serviceName serviceId serviceInstanceId serviceInstanceName } + calls { id source target detectPoints sourceComponents targetComponents } } } `; -export interface NetworkProfilingSummary { +export interface NetworkProfilingResult { instanceName: string | null; - processCount: number; - conversationCount: number; - /** "procA → procB" for the first conversations, for the agent to read out. */ - conversations: string[]; - reachable: boolean; - error?: string; + /** The captured process-conversation graph — frozen render input for + * ProcessTopologyGraph, so the block replays without re-querying. */ + topology: ProcessTopologyResponse; } -/** Network profiling's result is a process-conversation graph, not a flame. It is - * summarised to TEXT (never handed to a live tab, which would drift from what the - * agent read). Empty ⇒ no Rover eBPF agent is reporting. */ +/** Network profiling's result is a process-conversation graph. Read it (one + * instance's process topology) and return it as CAPTURED render data — the block + * freezes + replays it, never a live tab. Empty ⇒ no Rover eBPF agent reporting. */ export async function analyzeNetworkProfiling( opts: GraphqlOptions, layerKey: string, service: string, window: { start: string; end: string; step: string }, -): Promise<NetworkProfilingSummary> { - const empty: NetworkProfilingSummary = { instanceName: null, processCount: 0, conversationCount: 0, conversations: [], reachable: true }; +): Promise<NetworkProfilingResult> { + const empty = (reachable: boolean, error?: string): NetworkProfilingResult => ({ + instanceName: null, + topology: { nodes: [], calls: [], reachable, ...(error ? { error } : {}) }, + }); try { const serviceId = await resolveServiceId(opts, layerKey, service); - if (!serviceId) return { ...empty, reachable: false, error: `Unknown service "${service}" in layer ${layerKey}.` }; + if (!serviceId) return empty(false, `Unknown service "${service}" in layer ${layerKey}.`); const insts = await listServiceInstances(opts, serviceId, window); - if (!insts.length) return empty; + if (!insts.length) return empty(true); const inst = insts[0]; - const data = await graphqlPost<{ topology: { nodes: Array<{ id: string; name: string }>; calls: Array<{ source: string; target: string }> } | null }>( + const data = await graphqlPost<{ topology: { nodes: ProcessNode[]; calls: ProcessCall[] } | null }>( opts, GET_PROCESS_TOPOLOGY, { serviceInstanceId: inst.id, duration: { start: window.start, end: window.end, step: window.step } }, ); - const nodes = data.topology?.nodes ?? []; - const calls = data.topology?.calls ?? []; - const nameById = new Map(nodes.map((n) => [n.id, n.name])); - const conversations = calls.slice(0, 12).map((c) => `${nameById.get(c.source) ?? c.source} → ${nameById.get(c.target) ?? c.target}`); - return { instanceName: inst.name, processCount: nodes.length, conversationCount: calls.length, conversations, reachable: true }; + return { + instanceName: inst.name, + topology: { nodes: data.topology?.nodes ?? [], calls: data.topology?.calls ?? [], reachable: true }, + }; } catch (err) { - return { ...empty, reachable: false, error: err instanceof Error ? err.message : String(err) }; + return empty(false, err instanceof Error ? err.message : String(err)); } } diff --git a/apps/ui/src/ai/ChatProcessTopologyBlock.vue b/apps/ui/src/ai/ChatProcessTopologyBlock.vue new file mode 100644 index 0000000..82d688d --- /dev/null +++ b/apps/ui/src/ai/ChatProcessTopologyBlock.vue @@ -0,0 +1,89 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<!-- Captured network process-conversation graph (network profiling's result). + ProcessTopologyGraph is a stateless renderer (nodes/calls in), so the block + replays the captured graph directly — no query, frozen on reload. --> +<script setup lang="ts"> +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import ProcessTopologyGraph from '@/layer/profiling/ProcessTopologyGraph.vue'; +import ChatCapturedTag from './ChatCapturedTag.vue'; +import type { ProcessTopologySpec } from './types'; + +const props = defineProps<{ n: number; spec: ProcessTopologySpec; capturedAt?: number }>(); +const { t } = useI18n({ useScope: 'global' }); + +const hasData = computed<boolean>(() => props.spec.replayData.reachable && props.spec.replayData.nodes.length > 0); +</script> + +<template> + <div class="cpt"> + <div class="cpt__cap"> + {{ t('Figure {n}', { n }) }} · {{ spec.title }}<ChatCapturedTag :at="capturedAt" /> + </div> + <div class="cpt__facts"> + <span v-if="spec.instanceName" class="cpt__fact">{{ spec.instanceName }}</span> + <span v-if="hasData" class="cpt__fact">{{ t('{n} processes', { n: spec.replayData.nodes.length }) }}</span> + </div> + <div v-if="hasData" class="cpt__graph"> + <ProcessTopologyGraph :nodes="spec.replayData.nodes" :calls="spec.replayData.calls" /> + </div> + <div v-else class="cpt__empty">{{ t('No process-conversation data was captured.') }}</div> + </div> +</template> + +<style scoped> +.cpt { + border: 1px solid var(--sw-line, #2a2d36); + border-radius: 8px; + background: var(--sw-bg-1, #1b1d24); + overflow: hidden; + margin: 8px 0; +} +.cpt__cap { + padding: 8px 12px; + font-size: 12px; + color: var(--sw-fg-2, #9aa0ac); + border-bottom: 1px solid var(--sw-line, #2a2d36); +} +.cpt__facts { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 12px 0; + font-size: 11px; + color: var(--sw-fg-2, #9aa0ac); +} +.cpt__fact { + font-variant-numeric: tabular-nums; +} +.cpt__fact + .cpt__fact::before { + content: '·'; + margin-right: 6px; + color: var(--sw-fg-3, #6b6f7a); +} +.cpt__graph { + height: 420px; + margin-top: 8px; +} +.cpt__empty { + padding: 24px 12px; + text-align: center; + font-size: 12px; + color: var(--sw-fg-3, #6b6f7a); +} +</style> diff --git a/apps/ui/src/ai/ChatTranscript.vue b/apps/ui/src/ai/ChatTranscript.vue index 9ec511a..19e6b2e 100644 --- a/apps/ui/src/ai/ChatTranscript.vue +++ b/apps/ui/src/ai/ChatTranscript.vue @@ -23,6 +23,7 @@ import ChatFigureBlock from './ChatFigureBlock.vue'; import ChatSubPageBlock from './ChatSubPageBlock.vue'; import ChatProposalBlock from './ChatProposalBlock.vue'; import ChatProfilingBlock from './ChatProfilingBlock.vue'; +import ChatProcessTopologyBlock from './ChatProcessTopologyBlock.vue'; import ChatPodLogsBlock from './ChatPodLogsBlock.vue'; import ChatHierarchyBlock from './ChatHierarchyBlock.vue'; import ChatTopologyBlock from './ChatTopologyBlock.vue'; @@ -96,6 +97,7 @@ function fmtTime(at: number): string { <ChatSubPageBlock v-else-if="b.kind === 'subpage'" :n="b.n" :spec="b.spec" /> <ChatProposalBlock v-else-if="b.kind === 'proposal'" :block="b" /> <ChatProfilingBlock v-else-if="b.kind === 'profiling'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> + <ChatProcessTopologyBlock v-else-if="b.kind === 'process-topology'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> <ChatPodLogsBlock v-else-if="b.kind === 'podlogs'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> <ChatHierarchyBlock v-else-if="b.kind === 'hierarchy'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> <ChatTopologyBlock v-else-if="b.kind === 'topology'" :n="b.n" :spec="b.spec" :captured-at="b.capturedAt" /> diff --git a/apps/ui/src/ai/types.ts b/apps/ui/src/ai/types.ts index def09cf..c2b97a0 100644 --- a/apps/ui/src/ai/types.ts +++ b/apps/ui/src/ai/types.ts @@ -30,6 +30,7 @@ import type { ZipkinTraceListResponse, LogsResponse, BrowserErrorsResponse, + ProcessTopologyResponse, } from '@skywalking-horizon-ui/api-client'; export type FigureLayout = 'single' | 'tabs' | 'stack' | 'grid'; @@ -117,6 +118,17 @@ export interface ProfilingResultSpec { error?: string | null; } +// A captured network process-conversation graph (network profiling's result). +// ProcessTopologyGraph is a stateless renderer, so the block replays replayData +// directly — no re-query. Mirror of the BFF spec. +export interface ProcessTopologySpec { + title: string; + layer: string; + service: string; + instanceName: string | null; + replayData: ProcessTopologyResponse; +} + // One line of on-demand pod-log output. `timestamp` is epoch-ms (or null). export interface PodLogLine { content: string; @@ -293,6 +305,7 @@ export type SseEvent = | { type: 'subpage'; n: number; spec: SubPageSpec } | { type: 'proposal'; n: number; spec: ProposalSpec } | { type: 'profiling'; n: number; spec: ProfilingResultSpec } + | { type: 'process-topology'; n: number; spec: ProcessTopologySpec } | { type: 'podlogs'; n: number; spec: PodLogSpec } | { type: 'hierarchy'; n: number; spec: HierarchySpec } | { type: 'topology'; n: number; spec: TopologySpec } @@ -314,6 +327,7 @@ export type Block = | { kind: 'subpage'; n: number; spec: SubPageSpec } | { kind: 'proposal'; n: number; spec: ProposalSpec; status: ProposalStatus; taskId?: string; error?: string } | { kind: 'profiling'; n: number; spec: ProfilingResultSpec; capturedAt?: number } + | { kind: 'process-topology'; n: number; spec: ProcessTopologySpec; capturedAt?: number } | { kind: 'podlogs'; n: number; spec: PodLogSpec; capturedAt?: number } | { kind: 'hierarchy'; n: number; spec: HierarchySpec; capturedAt?: number } | { kind: 'topology'; n: number; spec: TopologySpec; capturedAt?: number } diff --git a/apps/ui/src/ai/useAiConversations.ts b/apps/ui/src/ai/useAiConversations.ts index a14d8d6..c095e4f 100644 --- a/apps/ui/src/ai/useAiConversations.ts +++ b/apps/ui/src/ai/useAiConversations.ts @@ -242,6 +242,7 @@ async function send(text: string): Promise<void> { else if (ev.type === 'subpage') assistant.blocks.push({ kind: 'subpage', n: ev.n, spec: ev.spec }); else if (ev.type === 'proposal') assistant.blocks.push({ kind: 'proposal', n: ev.n, spec: ev.spec, status: 'pending' }); else if (ev.type === 'profiling') assistant.blocks.push({ kind: 'profiling', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); + else if (ev.type === 'process-topology') assistant.blocks.push({ kind: 'process-topology', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); else if (ev.type === 'podlogs') assistant.blocks.push({ kind: 'podlogs', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); else if (ev.type === 'hierarchy') assistant.blocks.push({ kind: 'hierarchy', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); else if (ev.type === 'topology') assistant.blocks.push({ kind: 'topology', n: ev.n, spec: ev.spec, capturedAt: Date.now() }); diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index b684690..08cace9 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1645,6 +1645,8 @@ "Captured snapshot — replayed from history, not live data": "Captured snapshot — replayed from history, not live data", "{n} segments": "{n} segments", "{n} frames": "{n} frames", + "{n} processes": "{n} processes", + "No process-conversation data was captured.": "No process-conversation data was captured.", "all endpoints": "all endpoints", "Could not read the profile.": "Could not read the profile.", "No profile data was collected in this task yet.": "No profile data was collected in this task yet.",
