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.",

Reply via email to