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 5dc6333cf8cccec43386fd252bac5f01eb2a4bda
Author: Wu Sheng <[email protected]>
AuthorDate: Wed Jul 8 16:43:44 2026 +0800

    feat(ai): profiling result block + analyze_profiling tool (result spine)
    
    The propose→approve→fire path already created profiling tasks, but nothing
    read them back — the taskId was captured and dropped. This adds the missing
    half: analyze_profiling reads a completed task (trace / pprof / async / 
eBPF),
    maps every flavor into the one ProfileAnalyzationTree[] shape, and emits a
    frozen profiling-result block that renders the same flame graph the 
Profiling
    tab draws — captured, zero-query on reload, with an honest no-data state 
when
    nothing collected yet. The reply also lists the hottest self-time frames so
    the agent can name the cause.
    
    - logic/oap/profiling.ts: shared analyze orchestration (find task →
      segments/schedules → analyze → map), never-throws.
    - new 'profiling' block: spec (BFF+UI lockstep) + SSE type + emitter trio
      (figure-buffer/context/chat) + capture append + transcript dispatch +
      ChatProfilingBlock.vue rendering ProfileFlameGraph.
    - triggers skill gains analyze_profiling (read-only); prompt externalized to
      triggers.yaml; skills.md updated.
    
    Validated live on the demo: analyze_profiling on agent::songs rendered a
    139-frame trace flame (endpoint GET:/songs).
---
 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   |   5 +-
 apps/bff/src/ai/resources/tools/triggers.yaml |  13 +
 apps/bff/src/ai/skill/triggers/tools.ts       |  54 ++-
 apps/bff/src/ai/types.ts                      |  23 ++
 apps/bff/src/logic/oap/profiling.ts           | 474 ++++++++++++++++++++++++++
 apps/ui/src/ai/ChatProfilingBlock.vue         | 129 +++++++
 apps/ui/src/ai/ChatTranscript.vue             |   2 +
 apps/ui/src/ai/types.ts                       |  37 ++
 apps/ui/src/ai/useAiConversations.ts          |   1 +
 12 files changed, 749 insertions(+), 3 deletions(-)

diff --git a/apps/bff/src/ai/context.ts b/apps/bff/src/ai/context.ts
index 6741616..a0070c3 100644
--- a/apps/bff/src/ai/context.ts
+++ b/apps/bff/src/ai/context.ts
@@ -37,6 +37,7 @@ import type {
   HierarchySpec,
   InstanceTopologySpec,
   PodLogSpec,
+  ProfilingResultSpec,
   ProposalSpec,
   BrowserErrorsSpec,
   LogsSpec,
@@ -76,6 +77,8 @@ export interface AiRequestContext {
    *  fires it; the user approves/dismisses in the UI, which then calls the
    *  existing verb-gated create route. */
   emitProposal(spec: ProposalSpec): void;
+  /** Emit a captured profiling-result flame block (read-only, frozen on 
reload). */
+  emitProfiling(spec: ProfilingResultSpec): 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 a0a6ede..47a9de4 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,
+  ProfilingResultSpec,
   ProposalSpec,
   SseEvent,
   BrowserErrorsSpec,
@@ -47,6 +48,8 @@ export interface FigureBuffer {
   emitSubPage(spec: SubPageSpec): void;
   /** Emit a proposed-action decision card. Same numbering; flushes first. */
   emitProposal(spec: ProposalSpec): void;
+  /** Emit a captured profiling-result (flame) block. Same numbering; flushes 
first. */
+  emitProfiling(spec: ProfilingResultSpec): 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. */
@@ -103,6 +106,11 @@ export function createFigureBuffer(send: (ev: SseEvent) => 
void): FigureBuffer {
     send({ type: 'proposal', n: ++figureN, spec });
   };
 
+  const emitProfiling = (spec: ProfilingResultSpec): void => {
+    flushFigures();
+    send({ type: 'profiling', n: ++figureN, spec });
+  };
+
   const emitPodLogs = (spec: PodLogSpec): void => {
     flushFigures();
     send({ type: 'podlogs', n: ++figureN, spec });
@@ -157,6 +165,7 @@ export function createFigureBuffer(send: (ev: SseEvent) => 
void): FigureBuffer {
     emitFigure,
     emitSubPage,
     emitProposal,
+    emitProfiling,
     emitPodLogs,
     emitHierarchy,
     emitTopology,
diff --git a/apps/bff/src/ai/http/chat.ts b/apps/bff/src/ai/http/chat.ts
index b3211ff..283a86e 100644
--- a/apps/bff/src/ai/http/chat.ts
+++ b/apps/bff/src/ai/http/chat.ts
@@ -164,6 +164,7 @@ export function registerAiRoutes(app: FastifyInstance, 
deps: AiChatRouteDeps): v
       emitFigure,
       emitSubPage,
       emitProposal,
+      emitProfiling,
       emitPodLogs,
       emitHierarchy,
       emitTopology,
@@ -188,6 +189,7 @@ export function registerAiRoutes(app: FastifyInstance, 
deps: AiChatRouteDeps): v
       emitFigure,
       emitSubPage,
       emitProposal,
+      emitProfiling,
       emitPodLogs,
       emitHierarchy,
       emitTopology,
diff --git a/apps/bff/src/ai/resources/prompts/skills.md 
b/apps/bff/src/ai/resources/prompts/skills.md
index 3ad6382..5ec74b9 100644
--- a/apps/bff/src/ai/resources/prompts/skills.md
+++ b/apps/bff/src/ai/resources/prompts/skills.md
@@ -69,5 +69,6 @@ INLINE VIEW TOOLS — mount a REAL feature view inline 
(read-only, focused on th
 SUB-PAGE TOOLS — mount an interactive feature view as a card (opens the full 
page in a new tab):
 - show_service_list(layer) — the services in a layer with their key metrics.
 
-TRIGGERS skill — gated profiling (propose_profiling)
-- You are READ-ONLY except for this one action. 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.
+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.
+- analyze_profiling(layer, service, profilingType) is READ-only — call it in 
that later turn (or whenever the user asks to analyse an existing profile) to 
render the flame graph of the most recent completed task. The reply lists the 
hottest self-time frames; use them to name the cause. If it says no data was 
collected yet, tell the user to let the task finish — do NOT retry immediately.
diff --git a/apps/bff/src/ai/resources/tools/triggers.yaml 
b/apps/bff/src/ai/resources/tools/triggers.yaml
index 639713c..915ac00 100644
--- a/apps/bff/src/ai/resources/tools/triggers.yaml
+++ b/apps/bff/src/ai/resources/tools/triggers.yaml
@@ -40,3 +40,16 @@ propose_profiling:
       why profiling is the right next step
     expectation: >-
       what the profile is expected to reveal or confirm
+
+analyze_profiling:
+  description: >-
+    Read a COMPLETED profiling task and render its flame graph. Call this in a 
LATER turn, after a task the user approved has finished collecting (or when the 
user asks you to analyze an existing profile). It fetches the most recent task 
of the given type for the service, analyses it, and shows the flame graph 
inline; the reply also lists the hottest frames by self time so you can name 
the cause. If it reports no data collected yet, tell the user to wait for the 
task to finish, then anal [...]
+  params:
+    layer: >-
+      OAP layer key, e.g. GENERAL
+    service: >-
+      service NAME to analyze the profile for
+    profilingType: >-
+      which profiling result to read — trace (in-process sampling), pprof 
(Go), async (Java async-profiler), or ebpf (on/off-CPU)
+    taskId: >-
+      a specific task id to analyze; omit to use the most recent task of this 
type
diff --git a/apps/bff/src/ai/skill/triggers/tools.ts 
b/apps/bff/src/ai/skill/triggers/tools.ts
index fed83dc..e816d69 100644
--- a/apps/bff/src/ai/skill/triggers/tools.ts
+++ b/apps/bff/src/ai/skill/triggers/tools.ts
@@ -29,6 +29,22 @@ import { z } from 'zod';
 import type { StructuredToolInterface } from '@langchain/core/tools';
 import type { AiRequestContext } from '../../context.js';
 import { toolPrompt } from '../../resources/loader.js';
+import { analyzeProfiling } from '../../../logic/oap/profiling.js';
+import type { ProfilingAnalysis } from '../../../logic/oap/profiling.js';
+
+// Top self-heavy frames as text so the agent can reason about the hot path
+// (the flame itself is rendered for the user, not readable by the model).
+function summarizeProfile(a: ProfilingAnalysis): string {
+  const all = a.trees.flatMap((t) => t.elements);
+  if (!all.length) return '';
+  const total = Math.max(...all.map((e) => e.count), 1);
+  const top = [...all]
+    .filter((e) => e.durationChildExcluded > 0)
+    .sort((x, y) => y.durationChildExcluded - x.durationChildExcluded)
+    .slice(0, 8)
+    .map((e) => `${e.codeSignature} (${Math.round((e.durationChildExcluded / 
total) * 100)}% self)`);
+  return top.length ? ` Hottest frames by self time: ${top.join('; ')}.` : '';
+}
 
 export function triggerTools(ctx: AiRequestContext): StructuredToolInterface[] 
{
   const t = toolPrompt('triggers', 'propose_profiling');
@@ -67,5 +83,41 @@ export function triggerTools(ctx: AiRequestContext): 
StructuredToolInterface[] {
     },
   );
 
-  return [propose];
+  const at = toolPrompt('triggers', 'analyze_profiling');
+  const analyze = tool(
+    async ({ layer, service, profilingType, taskId }): Promise<string> => {
+      const a = await analyzeProfiling({ opts: ctx.opts, profilingType, 
layerKey: layer, service, taskId });
+      ctx.emitProfiling({
+        title: `Profiling — ${service} (${profilingType})`,
+        profilingType: a.profilingType,
+        layer: layer.toUpperCase(),
+        service,
+        taskId: a.taskId,
+        trees: a.trees,
+        metricKey: a.metricKey,
+        tip: a.tip,
+        logs: a.logs,
+        summary: a.summary,
+        reachable: a.reachable,
+        error: a.error ?? null,
+      });
+      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.`;
+      }
+      return `Rendered the ${profilingType} profile for ${service}: 
${a.summary.frameCount} stack frames${a.tip ? ` (partial — ${a.tip})` : 
''}.${summarizeProfile(a)}`;
+    },
+    {
+      name: 'analyze_profiling',
+      description: at.description,
+      schema: z.object({
+        layer: z.string().describe(at.p('layer')),
+        service: z.string().describe(at.p('service')),
+        profilingType: z.enum(['trace', 'pprof', 'async', 
'ebpf']).describe(at.p('profilingType')),
+        taskId: z.string().optional().describe(at.p('taskId')),
+      }),
+    },
+  );
+
+  return [propose, analyze];
 }
diff --git a/apps/bff/src/ai/types.ts b/apps/bff/src/ai/types.ts
index 1a906c9..7cf16f4 100644
--- a/apps/bff/src/ai/types.ts
+++ b/apps/bff/src/ai/types.ts
@@ -29,9 +29,11 @@ import type {
   DeploymentResponse,
   EndpointDependencyResponse,
   InstanceTopologyResponse,
+  ProfileAnalyzationTree,
   ServiceHierarchyResponse,
   TopologyResponse,
 } from '@skywalking-horizon-ui/api-client';
+import type { ProfilingLogLine, ProfilingSummary, ProfilingType } from 
'../logic/oap/profiling.js';
 
 export type FigureLayout = 'single' | 'tabs' | 'stack' | 'grid';
 
@@ -84,6 +86,26 @@ export interface ProposalSpec {
   expectation: string;
 }
 
+/** 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). */
+export interface ProfilingResultSpec {
+  title: string;
+  profilingType: ProfilingType;
+  layer: string;
+  service: string;
+  taskId: string | null;
+  /** The flame input — every profiling flavor mapped to the one render shape. 
*/
+  trees: ProfileAnalyzationTree[];
+  metricKey: 'count' | 'duration';
+  /** OAP's partial-data notice, when the snapshot was only partly analyzed. */
+  tip?: string | null;
+  logs: ProfilingLogLine[];
+  summary: ProfilingSummary;
+  reachable: boolean;
+  error?: string | null;
+}
+
 /** One line of on-demand pod-log output. `timestamp` is epoch-ms (OAP reports
  *  seconds; the BFF normalises), or null when OAP omitted it. */
 export interface PodLogLine {
@@ -277,6 +299,7 @@ export type SseEvent =
   | { type: 'figure'; n: number; title?: string; layout: FigureLayout; 
figures: ChatFigure[] }
   | { type: 'subpage'; n: number; spec: SubPageSpec }
   | { type: 'proposal'; n: number; spec: ProposalSpec }
+  | { type: 'profiling'; n: number; spec: ProfilingResultSpec }
   | { 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
new file mode 100644
index 0000000..69d6b6c
--- /dev/null
+++ b/apps/bff/src/logic/oap/profiling.ts
@@ -0,0 +1,474 @@
+/*
+ * 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.
+ */
+
+/**
+ * `analyzeProfiling` — find a completed profiling task and map its result into
+ * the single `ProfileAnalyzationTree[]` shape `ProfileFlameGraph` renders,
+ * hiding each flavor's different fetch (trace needs segments, eBPF needs
+ * schedules) and stack-element shape. Network profiling is a topology, not a
+ * flame, and is out of scope here. Never throws: a bad round-trip degrades to
+ * `reachable:false`, a not-yet-collected task to empty `trees` — the honest
+ * no-data states, not a reason to retry.
+ */
+
+import type { ProfileAnalyzationElement, ProfileAnalyzationTree } from 
'@skywalking-horizon-ui/api-client';
+import type { GraphqlOptions } from '../../client/graphql.js';
+import { graphqlPost } from '../../client/graphql.js';
+
+export type ProfilingType = 'trace' | 'pprof' | 'async' | 'ebpf';
+
+export interface ProfilingLogLine {
+  instanceName: string;
+  operationType: string;
+  operationTime: number;
+}
+
+/** Structured task facts for the result-block header (the flame carries no 
context). */
+export interface ProfilingSummary {
+  service: string;
+  endpoint?: string | null;
+  instances?: string[];
+  events?: string[];
+  durationLabel?: string | null;
+  startTime?: number | null;
+  segmentCount?: number | null;
+  /** Total stack frames across all trees — 0 ⇒ nothing collected yet. */
+  frameCount: number;
+}
+
+export interface ProfilingAnalysis {
+  profilingType: ProfilingType;
+  taskId: string | null;
+  /** Common flame input; empty when the task collected no data (or 
unreachable). */
+  trees: ProfileAnalyzationTree[];
+  metricKey: 'count' | 'duration';
+  /** OAP's partial-data notice (a large snapshot only partly analyzed). */
+  tip: string | null;
+  logs: ProfilingLogLine[];
+  summary: ProfilingSummary;
+  reachable: boolean;
+  error?: string;
+}
+
+// async-profiler events fold into one JFR tree type; pick it to select which
+// tree the analyze returns. Inlined (not imported from the http route) to keep
+// the logic→client direction clean.
+const ASYNC_EVENT_TO_JFR: Record<string, string> = {
+  CPU: 'EXECUTION_SAMPLE',
+  WALL: 'EXECUTION_SAMPLE',
+  CTIMER: 'EXECUTION_SAMPLE',
+  ITIMER: 'EXECUTION_SAMPLE',
+  LOCK: 'LOCK',
+  ALLOC: 'OBJECT_ALLOCATION_IN_NEW_TLAB',
+};
+
+// Cap the trace analyze fan-out: each profiled span becomes one analyze query,
+// and OAP snapshots the request (returns `tip` when it only analyzes part). We
+// take the slowest segments first so the busiest call paths dominate the 
flame.
+const MAX_TRACE_ANALYZE_QUERIES = 100;
+
+const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
+  query ListServicesForProfilingResolve($layer: String!) {
+    services: listServices(layer: $layer) { id name normal }
+  }
+`;
+
+const GET_PROFILE_TASK_LIST = /* GraphQL */ `
+  query AiGetProfileTaskList($serviceId: ID) {
+    taskList: getProfileTaskList(serviceId: $serviceId) {
+      id serviceId endpointName startTime duration
+    }
+  }
+`;
+
+const GET_PROFILE_TASK_SEGMENTS = /* GraphQL */ `
+  query AiGetProfileTaskSegments($taskID: ID!) {
+    segmentList: getProfileTaskSegments(taskID: $taskID) {
+      traceId duration
+      spans { segmentId startTime endTime endpointName profiled }
+    }
+  }
+`;
+
+const GET_PROFILE_TASK_LOGS = /* GraphQL */ `
+  query AiGetProfileTaskLogs($taskID: String) {
+    taskLogs: getProfileTaskLogs(taskID: $taskID) {
+      instanceName operationType operationTime
+    }
+  }
+`;
+
+const GET_PROFILE_ANALYZE = /* GraphQL */ `
+  query AiGetProfileAnalyze($queries: [SegmentProfileAnalyzeQuery!]!) {
+    analyze: getSegmentsProfileAnalyze(queries: $queries) {
+      tip
+      trees { elements { id parentId codeSignature duration 
durationChildExcluded count } }
+    }
+  }
+`;
+
+const GET_PPROF_TASK_LIST = /* GraphQL */ `
+  query AiGetPprofTaskList($request: PprofTaskListRequest!) {
+    pprofTaskList: queryPprofTaskList(request: $request) {
+      tasks { id serviceInstanceIds createTime events duration }
+    }
+  }
+`;
+
+const GET_PPROF_PROGRESS = /* GraphQL */ `
+  query AiGetPprofProgress($taskId: String!) {
+    taskProgress: queryPprofTaskProgress(taskId: $taskId) {
+      logs { instanceName operationType operationTime }
+    }
+  }
+`;
+
+const GET_PPROF_ANALYZE = /* GraphQL */ `
+  query AiGetPprofAnalyze($request: PprofAnalyzationRequest!) {
+    analysisResult: queryPprofAnalyze(request: $request) {
+      tree { elements { id parentId symbol: codeSignature dumpCount: total 
self } }
+    }
+  }
+`;
+
+const GET_ASYNC_TASK_LIST = /* GraphQL */ `
+  query AiGetAsyncTaskList($request: AsyncProfilerTaskListRequest!) {
+    asyncTaskList: queryAsyncProfilerTaskList(request: $request) {
+      tasks { id serviceInstanceIds createTime events duration }
+    }
+  }
+`;
+
+const GET_ASYNC_PROGRESS = /* GraphQL */ `
+  query AiGetAsyncProgress($taskId: String!) {
+    taskProgress: queryAsyncProfilerTaskProgress(taskId: $taskId) {
+      logs { instanceName operationType operationTime }
+    }
+  }
+`;
+
+const GET_ASYNC_ANALYZE = /* GraphQL */ `
+  query AiGetAsyncAnalyze($request: AsyncProfilerAnalyzationRequest!) {
+    analysisResult: queryAsyncProfilerAnalyze(request: $request) {
+      tree { elements { id parentId symbol: codeSignature dumpCount: total 
self } }
+    }
+  }
+`;
+
+const QUERY_EBPF_TASKS = /* GraphQL */ `
+  query AiQueryEBPFTasks($serviceId: ID, $targets: [EBPFProfilingTargetType!], 
$triggerType: EBPFProfilingTriggerType) {
+    tasks: queryEBPFProfilingTasks(serviceId: $serviceId, targets: $targets, 
triggerType: $triggerType) {
+      taskId targetType taskStartTime fixedTriggerDuration
+    }
+  }
+`;
+
+const QUERY_EBPF_SCHEDULES = /* GraphQL */ `
+  query AiQueryEBPFSchedules($taskId: ID!) {
+    schedules: queryEBPFProfilingSchedules(taskId: $taskId) {
+      scheduleId startTime endTime
+    }
+  }
+`;
+
+const ANALYSIS_EBPF_RESULT = /* GraphQL */ `
+  query AiAnalysisEBPF($scheduleIdList: [ID!]!, $timeRanges: 
[EBPFProfilingAnalyzeTimeRange!]!, $aggregateType: 
EBPFProfilingAnalyzeAggregateType) {
+    result: analysisEBPFProfilingResult(scheduleIdList: $scheduleIdList, 
timeRanges: $timeRanges, aggregateType: $aggregateType) {
+      tip
+      trees { elements { id parentId symbol stackType dumpCount } }
+    }
+  }
+`;
+
+const ENCODED_ID = /^[A-Za-z0-9+/=]+\.\d+$/;
+
+async function resolveServiceId(opts: GraphqlOptions, layerKey: string, 
serviceArg: string): Promise<string | null> {
+  if (ENCODED_ID.test(serviceArg)) return serviceArg;
+  const data = await graphqlPost<{ services: Array<{ id: string; name: string 
}> }>(
+    opts,
+    LIST_SERVICES_FOR_RESOLVE,
+    { layer: layerKey.toUpperCase() },
+  );
+  return data.services.find((s) => s.name === serviceArg)?.id ?? 
data.services.find((s) => s.id === serviceArg)?.id ?? null;
+}
+
+function frameCount(trees: ProfileAnalyzationTree[]): number {
+  return trees.reduce((n, t) => n + t.elements.length, 0);
+}
+
+type WireStack = { id: string; parentId: string; symbol: string; dumpCount: 
number; self: number };
+type EbpfStack = { id: string; parentId: string; symbol: string; stackType: 
string; dumpCount: number };
+
+// pprof / async share the symbol/dumpCount/self element; map dumpCount→count
+// (drives flame width) and self→durationChildExcluded (drives the self-time
+// highlight), matching the existing per-type composables.
+function mapWireTree(elements: WireStack[]): ProfileAnalyzationTree {
+  return {
+    elements: elements.map(
+      (e): ProfileAnalyzationElement => ({
+        id: e.id,
+        parentId: e.parentId,
+        codeSignature: e.symbol,
+        count: e.dumpCount,
+        duration: e.dumpCount,
+        durationChildExcluded: e.self,
+      }),
+    ),
+  };
+}
+
+// eBPF has no self-time; every metric is the dump count.
+function mapEbpfTree(elements: EbpfStack[]): ProfileAnalyzationTree {
+  return {
+    elements: elements.map(
+      (e): ProfileAnalyzationElement => ({
+        id: e.id,
+        parentId: e.parentId,
+        codeSignature: e.symbol,
+        count: e.dumpCount,
+        duration: e.dumpCount,
+        durationChildExcluded: e.dumpCount,
+      }),
+    ),
+  };
+}
+
+function durationMinLabel(minutes: number | null | undefined): string | null {
+  return minutes == null ? null : `${minutes} min`;
+}
+function durationSecLabel(seconds: number | null | undefined): string | null {
+  return seconds == null ? null : `${seconds} s`;
+}
+
+export interface AnalyzeProfilingInput {
+  opts: GraphqlOptions;
+  profilingType: ProfilingType;
+  layerKey: string;
+  service: string;
+  /** Target a specific task; when absent, the most recent task of this type. 
*/
+  taskId?: string;
+}
+
+export async function analyzeProfiling(input: AnalyzeProfilingInput): 
Promise<ProfilingAnalysis> {
+  const { opts, profilingType, layerKey, service } = input;
+  const base: ProfilingAnalysis = {
+    profilingType,
+    taskId: input.taskId ?? null,
+    trees: [],
+    metricKey: 'count',
+    tip: null,
+    logs: [],
+    summary: { service, frameCount: 0 },
+    reachable: true,
+  };
+  try {
+    const serviceId = await resolveServiceId(opts, layerKey, service);
+    if (!serviceId) {
+      base.reachable = false;
+      base.error = `Unknown service "${service}" in layer ${layerKey}.`;
+      return base;
+    }
+    switch (profilingType) {
+      case 'trace':
+        return await analyzeTrace(opts, serviceId, service, input.taskId, 
base);
+      case 'pprof':
+        return await analyzeStackList(opts, GET_PPROF_TASK_LIST, 
GET_PPROF_ANALYZE, GET_PPROF_PROGRESS, serviceId, input.taskId, base, false);
+      case 'async':
+        return await analyzeStackList(opts, GET_ASYNC_TASK_LIST, 
GET_ASYNC_ANALYZE, GET_ASYNC_PROGRESS, serviceId, input.taskId, base, true);
+      case 'ebpf':
+        return await analyzeEbpf(opts, serviceId, input.taskId, base);
+    }
+  } catch (err) {
+    base.reachable = false;
+    base.error = err instanceof Error ? err.message : String(err);
+    return base;
+  }
+}
+
+async function analyzeTrace(
+  opts: GraphqlOptions,
+  serviceId: string,
+  service: string,
+  wantTaskId: string | undefined,
+  base: ProfilingAnalysis,
+): Promise<ProfilingAnalysis> {
+  const tl = await graphqlPost<{
+    taskList: Array<{ id: string; endpointName: string; startTime: number; 
duration: number }>;
+  }>(opts, GET_PROFILE_TASK_LIST, { serviceId });
+  const tasks = tl.taskList ?? [];
+  const task = wantTaskId ? tasks.find((t) => t.id === wantTaskId) : tasks[0];
+  if (!task) {
+    base.error = 'No trace-profiling task found for this service.';
+    return base;
+  }
+  base.taskId = task.id;
+  base.summary = {
+    service,
+    endpoint: task.endpointName || null,
+    durationLabel: durationMinLabel(task.duration),
+    startTime: task.startTime || null,
+    segmentCount: 0,
+    frameCount: 0,
+  };
+
+  const segs = await graphqlPost<{
+    segmentList: Array<{ duration: number; spans: Array<{ segmentId: string; 
startTime: number; endTime: number; profiled: boolean }> }>;
+  }>(opts, GET_PROFILE_TASK_SEGMENTS, { taskID: task.id });
+  const segments = segs.segmentList ?? [];
+  base.summary.segmentCount = segments.length;
+
+  // Slowest segments first, then one analyze query per profiled span, capped.
+  const queries: Array<{ segmentId: string; timeRange: { start: number; end: 
number } }> = [];
+  for (const seg of [...segments].sort((a, b) => (b.duration ?? 0) - 
(a.duration ?? 0))) {
+    for (const span of seg.spans ?? []) {
+      if (!span.profiled) continue;
+      queries.push({ segmentId: span.segmentId, timeRange: { start: 
span.startTime, end: span.endTime } });
+      if (queries.length >= MAX_TRACE_ANALYZE_QUERIES) break;
+    }
+    if (queries.length >= MAX_TRACE_ANALYZE_QUERIES) break;
+  }
+
+  base.logs = await fetchTraceLogs(opts, task.id);
+  if (!queries.length) return base; // task exists but nothing profiled yet
+
+  const an = await graphqlPost<{ analyze: { tip: string | null; trees: 
ProfileAnalyzationTree[] } | null }>(
+    opts,
+    GET_PROFILE_ANALYZE,
+    { queries },
+  );
+  base.tip = an.analyze?.tip ?? null;
+  base.trees = an.analyze?.trees ?? [];
+  base.summary.frameCount = frameCount(base.trees);
+  return base;
+}
+
+async function fetchTraceLogs(opts: GraphqlOptions, taskId: string): 
Promise<ProfilingLogLine[]> {
+  try {
+    const l = await graphqlPost<{ taskLogs: ProfilingLogLine[] }>(opts, 
GET_PROFILE_TASK_LOGS, { taskID: taskId });
+    return l.taskLogs ?? [];
+  } catch {
+    return [];
+  }
+}
+
+// pprof + async: one task-list query, one analyze query (with instanceIds and,
+// for async, a JFR eventType), one progress query for the logs.
+async function analyzeStackList(
+  opts: GraphqlOptions,
+  listQuery: string,
+  analyzeQuery: string,
+  progressQuery: string,
+  serviceId: string,
+  wantTaskId: string | undefined,
+  base: ProfilingAnalysis,
+  isAsync: boolean,
+): Promise<ProfilingAnalysis> {
+  const key = isAsync ? 'asyncTaskList' : 'pprofTaskList';
+  const tl = await graphqlPost<Record<string, { tasks: Array<{ id: string; 
serviceInstanceIds: string[]; createTime: number; events: string | string[]; 
duration: number }> } | null>>(
+    opts,
+    listQuery,
+    { request: { serviceId, limit: 500 } },
+  );
+  const tasks = tl[key]?.tasks ?? [];
+  const task = wantTaskId ? tasks.find((t) => t.id === wantTaskId) : tasks[0];
+  if (!task) {
+    base.error = `No ${base.profilingType}-profiling task found for this 
service.`;
+    return base;
+  }
+  const events = Array.isArray(task.events) ? task.events : task.events ? 
[task.events] : [];
+  base.taskId = task.id;
+  base.summary = {
+    service: base.summary.service,
+    instances: task.serviceInstanceIds ?? [],
+    events,
+    durationLabel: durationSecLabel(task.duration),
+    startTime: task.createTime || null,
+    frameCount: 0,
+  };
+
+  const instanceIds = task.serviceInstanceIds ?? [];
+  base.logs = await fetchProgressLogs(opts, progressQuery, task.id);
+  if (!instanceIds.length) {
+    base.error = 'The task targets no instances — nothing to analyze.';
+    return base;
+  }
+  const request = isAsync
+    ? { taskId: task.id, instanceIds, eventType: ASYNC_EVENT_TO_JFR[events[0]] 
?? 'EXECUTION_SAMPLE' }
+    : { taskId: task.id, instanceIds };
+  const an = await graphqlPost<{ analysisResult: { tree: { elements: 
WireStack[] } | null } | null }>(
+    opts,
+    analyzeQuery,
+    { request },
+  );
+  const tree = an.analysisResult?.tree;
+  base.trees = tree && tree.elements.length ? [mapWireTree(tree.elements)] : 
[];
+  base.summary.frameCount = frameCount(base.trees);
+  return base;
+}
+
+async function fetchProgressLogs(opts: GraphqlOptions, progressQuery: string, 
taskId: string): Promise<ProfilingLogLine[]> {
+  try {
+    const p = await graphqlPost<{ taskProgress: { logs: ProfilingLogLine[] } | 
null }>(opts, progressQuery, { taskId });
+    return p.taskProgress?.logs ?? [];
+  } catch {
+    return [];
+  }
+}
+
+async function analyzeEbpf(
+  opts: GraphqlOptions,
+  serviceId: string,
+  wantTaskId: string | undefined,
+  base: ProfilingAnalysis,
+): Promise<ProfilingAnalysis> {
+  const tl = await graphqlPost<{
+    tasks: Array<{ taskId: string; targetType: string; taskStartTime: number; 
fixedTriggerDuration: number | null }>;
+  }>(opts, QUERY_EBPF_TASKS, { serviceId, targets: ['ON_CPU', 'OFF_CPU'], 
triggerType: 'FIXED_TIME' });
+  const tasks = tl.tasks ?? [];
+  const task = wantTaskId ? tasks.find((t) => t.taskId === wantTaskId) : 
tasks[0];
+  if (!task) {
+    base.error = 'No eBPF-profiling task found for this service.';
+    return base;
+  }
+  base.taskId = task.taskId;
+  base.summary = {
+    service: base.summary.service,
+    events: [task.targetType],
+    durationLabel: durationSecLabel(task.fixedTriggerDuration),
+    startTime: task.taskStartTime || null,
+    frameCount: 0,
+  };
+
+  const sc = await graphqlPost<{ schedules: Array<{ scheduleId: string; 
startTime: number; endTime: number }> }>(
+    opts,
+    QUERY_EBPF_SCHEDULES,
+    { taskId: task.taskId },
+  );
+  const schedules = sc.schedules ?? [];
+  if (!schedules.length) return base; // task exists but no schedules 
collected yet
+  const scheduleIdList = schedules.map((s) => s.scheduleId);
+  const timeRanges = schedules.map((s) => ({ start: s.startTime, end: 
s.endTime }));
+  const an = await graphqlPost<{ result: { tip: string | null; trees: Array<{ 
elements: EbpfStack[] }> } | null }>(
+    opts,
+    ANALYSIS_EBPF_RESULT,
+    { scheduleIdList, timeRanges, aggregateType: 'COUNT' },
+  );
+  base.tip = an.result?.tip ?? null;
+  base.trees = (an.result?.trees ?? []).map((t) => mapEbpfTree(t.elements));
+  base.summary.frameCount = frameCount(base.trees);
+  return base;
+}
diff --git a/apps/ui/src/ai/ChatProfilingBlock.vue 
b/apps/ui/src/ai/ChatProfilingBlock.vue
new file mode 100644
index 0000000..3dce609
--- /dev/null
+++ b/apps/ui/src/ai/ChatProfilingBlock.vue
@@ -0,0 +1,129 @@
+<!--
+  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.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import ProfileFlameGraph from '@/layer/profiling/ProfileFlameGraph.vue';
+import type { ProfilingResultSpec } from './types';
+
+const props = defineProps<{ n: number; spec: ProfilingResultSpec; capturedAt?: 
number }>();
+const { t } = useI18n({ useScope: 'global' });
+
+const captured = computed<string>(() =>
+  props.capturedAt
+    ? new Date(props.capturedAt).toLocaleString([], { month: 'short', day: 
'numeric', hour: '2-digit', minute: '2-digit' })
+    : '',
+);
+
+const hasData = computed<boolean>(() => props.spec.reachable && 
props.spec.trees.some((tr) => tr.elements.length));
+
+// Compact task facts — the flame carries no context of its own.
+const facts = computed<string[]>(() => {
+  const s = props.spec.summary;
+  const out: string[] = [];
+  if (props.spec.profilingType === 'trace') {
+    out.push(s.endpoint ? `endpoint ${s.endpoint}` : t('all endpoints'));
+    if (s.segmentCount != null) out.push(t('{n} segments', { n: s.segmentCount 
}));
+  } else {
+    if (s.events?.length) out.push(s.events.join(' / '));
+    if (s.instances?.length) out.push(t('{n} instances', { n: 
s.instances.length }));
+  }
+  if (s.durationLabel) out.push(s.durationLabel);
+  if (s.frameCount) out.push(t('{n} frames', { n: s.frameCount }));
+  return out;
+});
+</script>
+
+<template>
+  <div class="cpf">
+    <div class="cpf__cap">
+      {{ t('Figure {n}', { n }) }} · {{ spec.title }}<span v-if="captured" 
class="cpf__ts"> · captured {{ captured }}</span>
+    </div>
+    <div class="cpf__facts">
+      <span class="cpf__type">{{ spec.profilingType }}</span>
+      <span v-for="(f, i) in facts" :key="i" class="cpf__fact">{{ f }}</span>
+    </div>
+    <p v-if="spec.tip" class="cpf__tip">{{ spec.tip }}</p>
+    <div v-if="hasData" class="cpf__flame">
+      <ProfileFlameGraph :trees="spec.trees" :metric-key="spec.metricKey" />
+    </div>
+    <div v-else class="cpf__empty">
+      <template v-if="!spec.reachable">{{ t('Could not read the profile.') 
}}<span v-if="spec.error"> — {{ spec.error }}</span></template>
+      <template v-else>{{ t('No profile data was collected in this task yet.') 
}}</template>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.cpf {
+  border: 1px solid var(--sw-line, #2a2d36);
+  border-radius: 8px;
+  background: var(--sw-bg-1, #1b1d24);
+  overflow: hidden;
+  margin: 8px 0;
+}
+.cpf__cap {
+  padding: 8px 12px;
+  font-size: 12px;
+  color: var(--sw-fg-2, #9aa0ac);
+  border-bottom: 1px solid var(--sw-line, #2a2d36);
+}
+.cpf__ts {
+  color: var(--sw-fg-3, #6b6f7a);
+}
+.cpf__facts {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 6px;
+  padding: 8px 12px 0;
+  font-size: 11px;
+  color: var(--sw-fg-2, #9aa0ac);
+}
+.cpf__type {
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  font-weight: 600;
+  color: var(--sw-fg-0, #f5f7fb);
+  background: var(--sw-bg-2, #22252e);
+  border-radius: 4px;
+  padding: 1px 6px;
+}
+.cpf__fact {
+  font-variant-numeric: tabular-nums;
+}
+.cpf__fact::before {
+  content: '·';
+  margin-right: 6px;
+  color: var(--sw-fg-3, #6b6f7a);
+}
+.cpf__tip {
+  margin: 8px 12px 0;
+  font-size: 11px;
+  color: var(--sw-warn, #d9a441);
+}
+.cpf__flame {
+  height: 420px;
+  margin-top: 8px;
+}
+.cpf__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 18d25d9..db7a26d 100644
--- a/apps/ui/src/ai/ChatTranscript.vue
+++ b/apps/ui/src/ai/ChatTranscript.vue
@@ -22,6 +22,7 @@ import { useI18n } from 'vue-i18n';
 import ChatFigureBlock from './ChatFigureBlock.vue';
 import ChatSubPageBlock from './ChatSubPageBlock.vue';
 import ChatProposalBlock from './ChatProposalBlock.vue';
+import ChatProfilingBlock from './ChatProfilingBlock.vue';
 import ChatPodLogsBlock from './ChatPodLogsBlock.vue';
 import ChatHierarchyBlock from './ChatHierarchyBlock.vue';
 import ChatTopologyBlock from './ChatTopologyBlock.vue';
@@ -94,6 +95,7 @@ function fmtTime(at: number): string {
           <ChatFigureBlock v-else-if="b.kind === 'figure'" :block="b" />
           <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" />
           <ChatPodLogsBlock v-else-if="b.kind === 'podlogs'" :n="b.n" 
:spec="b.spec" />
           <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 c17ea50..672073e 100644
--- a/apps/ui/src/ai/types.ts
+++ b/apps/ui/src/ai/types.ts
@@ -23,6 +23,7 @@ import type {
   DeploymentResponse,
   EndpointDependencyResponse,
   InstanceTopologyResponse,
+  ProfileAnalyzationTree,
   ServiceHierarchyResponse,
   TopologyResponse,
 } from '@skywalking-horizon-ui/api-client';
@@ -71,6 +72,40 @@ export interface ProposalSpec {
   expectation: string;
 }
 
+// Mirror of the BFF profiling analysis shapes 
(apps/bff/.../logic/oap/profiling.ts).
+export type ProfilingType = 'trace' | 'pprof' | 'async' | 'ebpf';
+export interface ProfilingLogLine {
+  instanceName: string;
+  operationType: string;
+  operationTime: number;
+}
+export interface ProfilingSummary {
+  service: string;
+  endpoint?: string | null;
+  instances?: string[];
+  events?: string[];
+  durationLabel?: string | null;
+  startTime?: number | null;
+  segmentCount?: number | null;
+  frameCount: number;
+}
+// A captured, frozen profiling result — the flame trees + task facts; renders
+// statically from `trees` and never re-queries (an empty trees is "no data 
yet").
+export interface ProfilingResultSpec {
+  title: string;
+  profilingType: ProfilingType;
+  layer: string;
+  service: string;
+  taskId: string | null;
+  trees: ProfileAnalyzationTree[];
+  metricKey: 'count' | 'duration';
+  tip?: string | null;
+  logs: ProfilingLogLine[];
+  summary: ProfilingSummary;
+  reachable: boolean;
+  error?: string | null;
+}
+
 // One line of on-demand pod-log output. `timestamp` is epoch-ms (or null).
 export interface PodLogLine {
   content: string;
@@ -242,6 +277,7 @@ export type SseEvent =
   | { type: 'figure'; n: number; title?: string; layout: FigureLayout; 
figures: ChatFigure[] }
   | { type: 'subpage'; n: number; spec: SubPageSpec }
   | { type: 'proposal'; n: number; spec: ProposalSpec }
+  | { type: 'profiling'; n: number; spec: ProfilingResultSpec }
   | { type: 'podlogs'; n: number; spec: PodLogSpec }
   | { type: 'hierarchy'; n: number; spec: HierarchySpec }
   | { type: 'topology'; n: number; spec: TopologySpec }
@@ -262,6 +298,7 @@ export type Block =
   | { kind: 'figure'; n: number; title?: string; layout: FigureLayout; 
figures: ChatFigure[]; capturedAt?: number }
   | { 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: 'podlogs'; n: number; spec: PodLogSpec }
   | { 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 dd0e0e8..6629755 100644
--- a/apps/ui/src/ai/useAiConversations.ts
+++ b/apps/ui/src/ai/useAiConversations.ts
@@ -241,6 +241,7 @@ async function send(text: string): Promise<void> {
       else if (ev.type === 'figure') assistant.blocks.push({ kind: 'figure', 
n: ev.n, title: ev.title, layout: ev.layout, figures: ev.figures, capturedAt: 
Date.now() });
       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 === 'podlogs') assistant.blocks.push({ kind: 'podlogs', 
n: ev.n, spec: ev.spec });
       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() });


Reply via email to