Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions dev/src/main/java/com/google/adk/web/controller/AppGraph.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,15 @@
import com.google.adk.workflow.Start;
import com.google.adk.workflow.Workflow;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.jspecify.annotations.Nullable;

Expand Down Expand Up @@ -99,6 +104,63 @@ record GraphNode(
GraphNode(String name, String type) {
this(name, type, /* subAgents= */ null, /* tools= */ null, /* graph= */ null);
}

/**
* Returns the node at the {@code /}-separated {@code path}, or empty if no child matches.
* Accepts a path that starts with this node's name, and ignores an {@code @<run id>} suffix on
* any segment.
*/
Optional<GraphNode> find(String path) {
List<String> names =
Arrays.stream(path.split("/", -1))
.map(segment -> segment.split("@", -1)[0])
.filter(name -> !name.isEmpty())
.toList();
List<String> below =
!names.isEmpty() && names.get(0).equals(name) ? names.subList(1, names.size()) : names;
Optional<GraphNode> node = Optional.of(this);
for (String childName : below) {
node =
node.flatMap(
parent ->
parent.children().stream()
.filter(child -> child.name().equals(childName))
.findFirst());
}
return node;
}

/**
* Returns every node at or below this one that has a graph, keyed by its {@code /}-separated
* path starting at {@code path}, in depth-first order.
*/
ImmutableMap<String, GraphNode> workflowsByPath(String path) {
Map<String, GraphNode> workflows = new LinkedHashMap<>();
collectWorkflows(this, path, workflows);
return ImmutableMap.copyOf(workflows);
}

private static void collectWorkflows(
GraphNode node, String path, Map<String, GraphNode> workflows) {
if (node.graph() != null) {
workflows.put(path, node);
}
for (GraphNode child : node.children()) {
collectWorkflows(
child, path.isEmpty() ? child.name() : path + "/" + child.name(), workflows);
}
}

private ImmutableList<GraphNode> children() {
ImmutableList.Builder<GraphNode> children = ImmutableList.builder();
if (graph != null) {
children.addAll(graph.nodes());
}
if (subAgents != null) {
children.addAll(subAgents);
}
return children.build();
}
}

record GraphTool(String name, String type) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,36 @@

package com.google.adk.web.controller;

import com.google.adk.web.controller.AppGraph.GraphNode;
import com.google.adk.web.service.RunnerService;
import com.google.adk.workflow.Node;
import com.google.common.collect.ImmutableSet;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

/** Serves the app structure that the Dev UI navigates and draws its graph views from. */
/** Serves the app structure and graph images that the Dev UI draws its graph views from. */
@RestController
class AppGraphController {

private static final Logger log = LoggerFactory.getLogger(AppGraphController.class);

private static final ImmutableSet<String> TRUE_QUERY_VALUES =
ImmutableSet.of("1", "on", "t", "true", "y", "yes");

private static final ImmutableSet<String> FALSE_QUERY_VALUES =
ImmutableSet.of("0", "off", "f", "false", "n", "no");

private final RunnerService runnerService;

AppGraphController(RunnerService runnerService) {
Expand All @@ -44,6 +57,57 @@ AppGraph buildGraph(@PathVariable String appName) {
return AppGraph.of(appName, loadRoot(appName));
}

/**
* Returns DOT source for the root and every workflow below it, keyed by node path, or a single
* {@link DotGraph} for the subtree a non-empty {@code node} names.
*/
@GetMapping("/dev/apps/{appName}/build_graph_image")
Object buildGraphImage(
@PathVariable String appName,
@RequestParam(name = "dark_mode", required = false) @Nullable String darkModeValue,
@RequestParam(name = "node", defaultValue = "") String nodePath) {
// Not a defaultValue, which Spring would also apply to an empty value.
Boolean darkMode = darkModeValue == null ? Boolean.FALSE : parseQueryBoolean(darkModeValue);
if (darkMode == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dark_mode must be true or false.");
}
GraphNode target =
AppGraph.graphNodeOf(loadRoot(appName))
.find(nodePath)
.orElseThrow(
() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Node not found."));
if (!nodePath.isEmpty()) {
// The Dev UI's drill-in dialog reads a bare DotGraph.
return new DotGraph(GraphPlotter.plot(target, darkMode));
}
// Keys below the root start with its name: the Dev UI drops each key's first segment.
Map<String, DotGraph> graphs = new LinkedHashMap<>();
graphs.put("", new DotGraph(GraphPlotter.plot(target, darkMode)));
target
.workflowsByPath(target.name())
.forEach(
(path, workflow) -> {
if (!path.equals(target.name())) {
graphs.put(path, new DotGraph(GraphPlotter.plot(workflow, darkMode)));
}
});
return graphs;
}

/** DOT source for one graph, under the {@code dotSrc} key the Dev UI reads. */
record DotGraph(String dotSrc) {}

private static @Nullable Boolean parseQueryBoolean(String value) {
String lowercase = value.toLowerCase(Locale.ROOT);
if (TRUE_QUERY_VALUES.contains(lowercase)) {
return true;
}
if (FALSE_QUERY_VALUES.contains(lowercase)) {
return false;
}
return null;
}

/** Returns the root of the app {@code appName}, or throws the error response to send. */
private Node loadRoot(String appName) {
Optional<Node> root;
Expand Down
Loading
Loading