feat: build OpenClaw session graphs from DB entries (#5382)

This commit is contained in:
Paperlz
2026-04-11 00:02:04 +08:00
committed by GitHub
parent a5079cd0c5
commit 12bbecb69d
10 changed files with 1628 additions and 38 deletions

View File

@@ -88,6 +88,25 @@ func (c *ApiController) GetEntry() {
c.ResponseOk(entry)
}
// GetOpenClawSessionGraph
// @Title GetOpenClawSessionGraph
// @Tag Entry API
// @Description get OpenClaw session graph
// @Param id query string true "The id ( owner/name ) of the entry"
// @Success 200 {object} object.OpenClawSessionGraph The Response object
// @router /get-openclaw-session-graph [get]
func (c *ApiController) GetOpenClawSessionGraph() {
id := c.Ctx.Input.Query("id")
graph, err := object.GetOpenClawSessionGraph(id)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(graph)
}
// UpdateEntry
// @Title UpdateEntry
// @Tag Entry API

View File

@@ -0,0 +1,804 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed 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.
package object
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/casdoor/casdoor/util"
)
type OpenClawSessionGraph struct {
Nodes []*OpenClawSessionGraphNode `json:"nodes"`
Edges []*OpenClawSessionGraphEdge `json:"edges"`
Stats OpenClawSessionGraphStats `json:"stats"`
}
type OpenClawSessionGraphNode struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
OriginalParentID string `json:"originalParentId,omitempty"`
EntryID string `json:"entryId,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
Kind string `json:"kind"`
Timestamp string `json:"timestamp"`
Summary string `json:"summary"`
Tool string `json:"tool,omitempty"`
Query string `json:"query,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
OK *bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
Text string `json:"text,omitempty"`
IsAnchor bool `json:"isAnchor"`
}
type OpenClawSessionGraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
}
type OpenClawSessionGraphStats struct {
TotalNodes int `json:"totalNodes"`
TaskCount int `json:"taskCount"`
ToolCallCount int `json:"toolCallCount"`
ToolResultCount int `json:"toolResultCount"`
FinalCount int `json:"finalCount"`
FailedCount int `json:"failedCount"`
}
type openClawSessionGraphBuilder struct {
graph *OpenClawSessionGraph
nodes map[string]*OpenClawSessionGraphNode
}
type openClawSessionGraphRecord struct {
Entry *Entry
Payload openClawBehaviorPayload
}
type openClawAssistantStepGroup struct {
ParentID string
Timestamp string
ToolNames []string
Text string
}
func GetOpenClawSessionGraph(id string) (*OpenClawSessionGraph, error) {
entry, err := GetEntry(id)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
if strings.TrimSpace(entry.Type) != "session" {
return nil, fmt.Errorf("entry %s is not an OpenClaw session entry", id)
}
provider, err := GetProvider(util.GetId(entry.Owner, entry.Provider))
if err != nil {
return nil, err
}
if provider != nil && !isOpenClawLogProvider(provider) {
return nil, fmt.Errorf("entry %s is not an OpenClaw session entry", id)
}
anchorPayload, err := parseOpenClawSessionGraphPayload(entry)
if err != nil {
return nil, fmt.Errorf("failed to parse anchor entry %s: %w", entry.Name, err)
}
records, err := collectOpenClawSessionGraphRecords(entry, anchorPayload)
if err != nil {
return nil, fmt.Errorf("failed to load OpenClaw session entries from database: %w", err)
}
return buildOpenClawSessionGraphFromEntries(anchorPayload, entry.Name, records), nil
}
func parseOpenClawSessionGraphPayload(entry *Entry) (openClawBehaviorPayload, error) {
if entry == nil {
return openClawBehaviorPayload{}, fmt.Errorf("entry is nil")
}
message := strings.TrimSpace(entry.Message)
if message == "" {
return openClawBehaviorPayload{}, fmt.Errorf("message is empty")
}
var payload openClawBehaviorPayload
if err := json.Unmarshal([]byte(message), &payload); err != nil {
return openClawBehaviorPayload{}, err
}
payload.SessionID = strings.TrimSpace(payload.SessionID)
payload.EntryID = strings.TrimSpace(payload.EntryID)
payload.ToolCallID = strings.TrimSpace(payload.ToolCallID)
payload.ParentID = strings.TrimSpace(payload.ParentID)
payload.Kind = strings.TrimSpace(payload.Kind)
payload.Summary = strings.TrimSpace(payload.Summary)
payload.Tool = strings.TrimSpace(payload.Tool)
payload.Query = strings.TrimSpace(payload.Query)
payload.URL = strings.TrimSpace(payload.URL)
payload.Path = strings.TrimSpace(payload.Path)
payload.Error = strings.TrimSpace(payload.Error)
payload.AssistantText = strings.TrimSpace(payload.AssistantText)
payload.Text = strings.TrimSpace(payload.Text)
payload.Timestamp = strings.TrimSpace(firstNonEmpty(payload.Timestamp, entry.CreatedTime))
if payload.SessionID == "" {
return openClawBehaviorPayload{}, fmt.Errorf("sessionId is empty")
}
if payload.EntryID == "" {
return openClawBehaviorPayload{}, fmt.Errorf("entryId is empty")
}
if payload.Kind == "" {
return openClawBehaviorPayload{}, fmt.Errorf("kind is empty")
}
return payload, nil
}
func collectOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload) ([]openClawSessionGraphRecord, error) {
if anchorEntry == nil {
return nil, fmt.Errorf("anchor entry is nil")
}
entries := []*Entry{}
query := ormer.Engine.Where("owner = ? and type = ?", anchorEntry.Owner, "session")
if providerName := strings.TrimSpace(anchorEntry.Provider); providerName != "" {
query = query.And("provider = ?", providerName)
}
if err := query.
Asc("created_time").
Asc("name").
Find(&entries); err != nil {
return nil, err
}
return filterOpenClawSessionGraphRecords(anchorEntry, anchorPayload, entries), nil
}
func filterOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload, entries []*Entry) []openClawSessionGraphRecord {
targetSessionID := strings.TrimSpace(anchorPayload.SessionID)
records := make([]openClawSessionGraphRecord, 0, len(entries)+1)
hasAnchor := false
for _, candidate := range entries {
if candidate == nil {
continue
}
payload, err := parseOpenClawSessionGraphPayload(candidate)
if err != nil {
continue
}
if payload.SessionID != targetSessionID {
continue
}
records = append(records, openClawSessionGraphRecord{
Entry: candidate,
Payload: payload,
})
if candidate.Owner == anchorEntry.Owner && candidate.Name == anchorEntry.Name {
hasAnchor = true
}
}
if !hasAnchor && anchorEntry != nil {
records = append(records, openClawSessionGraphRecord{
Entry: anchorEntry,
Payload: anchorPayload,
})
}
sort.SliceStable(records, func(i, j int) bool {
leftPayload := records[i].Payload
rightPayload := records[j].Payload
leftTimestamp := strings.TrimSpace(firstNonEmpty(leftPayload.Timestamp, records[i].Entry.CreatedTime))
rightTimestamp := strings.TrimSpace(firstNonEmpty(rightPayload.Timestamp, records[j].Entry.CreatedTime))
if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 {
return timestampOrder < 0
}
return records[i].Entry.Name < records[j].Entry.Name
})
return records
}
func buildOpenClawSessionGraphFromEntries(anchorPayload openClawBehaviorPayload, anchorEntryName string, records []openClawSessionGraphRecord) *OpenClawSessionGraph {
builder := newOpenClawSessionGraphBuilder()
nodeIDsByEntryName := map[string][]string{}
assistantGroups := map[string]*openClawAssistantStepGroup{}
toolCallNodesByAssistant := map[string][]*OpenClawSessionGraphNode{}
toolCallNodeIDByToolCallID := map[string]string{}
allToolCallNodes := []*OpenClawSessionGraphNode{}
toolResultRecords := []openClawSessionGraphRecord{}
for _, record := range records {
payload := record.Payload
switch payload.Kind {
case "task":
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: payload.ParentID,
EntryID: payload.EntryID,
Kind: "task",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
case "tool_call":
nodeID := buildStoredToolCallNodeID(record.Entry, payload)
builder.addNode(&OpenClawSessionGraphNode{
ID: nodeID,
ParentID: payload.EntryID,
EntryID: payload.EntryID,
ToolCallID: payload.ToolCallID,
Kind: "tool_call",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Tool: payload.Tool,
Query: payload.Query,
URL: payload.URL,
Path: payload.Path,
Text: payload.Text,
})
storedNode := builder.nodes[nodeID]
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, nodeID)
if storedNode != nil {
toolCallNodesByAssistant[payload.EntryID] = append(toolCallNodesByAssistant[payload.EntryID], storedNode)
allToolCallNodes = append(allToolCallNodes, storedNode)
}
if payload.ToolCallID != "" && toolCallNodeIDByToolCallID[payload.ToolCallID] == "" {
toolCallNodeIDByToolCallID[payload.ToolCallID] = nodeID
}
group := assistantGroups[payload.EntryID]
if group == nil {
group = &openClawAssistantStepGroup{
ParentID: payload.ParentID,
Timestamp: payload.Timestamp,
}
assistantGroups[payload.EntryID] = group
}
group.ParentID = firstNonEmpty(group.ParentID, payload.ParentID)
group.Timestamp = chooseEarlierTimestamp(group.Timestamp, payload.Timestamp)
group.ToolNames = append(group.ToolNames, payload.Tool)
group.Text = firstNonEmpty(group.Text, payload.AssistantText)
case "tool_result":
toolResultRecords = append(toolResultRecords, record)
case "final":
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: payload.ParentID,
EntryID: payload.EntryID,
Kind: "final",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
}
}
assistantIDs := make([]string, 0, len(assistantGroups))
for entryID := range assistantGroups {
assistantIDs = append(assistantIDs, entryID)
}
sort.Strings(assistantIDs)
for _, assistantID := range assistantIDs {
group := assistantGroups[assistantID]
builder.addNode(&OpenClawSessionGraphNode{
ID: assistantID,
ParentID: strings.TrimSpace(group.ParentID),
EntryID: assistantID,
Kind: "assistant_step",
Timestamp: strings.TrimSpace(group.Timestamp),
Summary: buildAssistantStepSummary(group.ToolNames),
Text: strings.TrimSpace(group.Text),
})
}
for _, record := range toolResultRecords {
payload := record.Payload
parentID := strings.TrimSpace(payload.ParentID)
originalParentID := ""
if payload.ToolCallID != "" {
if matchedNodeID := strings.TrimSpace(toolCallNodeIDByToolCallID[payload.ToolCallID]); matchedNodeID != "" {
originalParentID = parentID
parentID = matchedNodeID
}
}
if parentID == strings.TrimSpace(payload.ParentID) {
if matchedNodeID := matchToolResultToolCallNodeID(payload, toolCallNodesByAssistant[payload.ParentID], allToolCallNodes); matchedNodeID != "" && matchedNodeID != parentID {
originalParentID = parentID
parentID = matchedNodeID
}
}
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: parentID,
OriginalParentID: originalParentID,
EntryID: payload.EntryID,
ToolCallID: payload.ToolCallID,
Kind: "tool_result",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Tool: payload.Tool,
Query: payload.Query,
URL: payload.URL,
Path: payload.Path,
OK: cloneBoolPointer(payload.OK),
Error: payload.Error,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
}
markStoredGraphAnchor(builder, anchorPayload, anchorEntryName, nodeIDsByEntryName)
return builder.finalize()
}
func appendGraphNodeEntryName(index map[string][]string, entry *Entry, nodeID string) {
if index == nil || entry == nil {
return
}
entryName := strings.TrimSpace(entry.Name)
nodeID = strings.TrimSpace(nodeID)
if entryName == "" || nodeID == "" {
return
}
for _, existingNodeID := range index[entryName] {
if existingNodeID == nodeID {
return
}
}
index[entryName] = append(index[entryName], nodeID)
}
func matchToolResultToolCallNodeID(payload openClawBehaviorPayload, assistantToolCalls []*OpenClawSessionGraphNode, allToolCalls []*OpenClawSessionGraphNode) string {
if matchedNodeID := chooseMatchingToolCallNodeID(payload, assistantToolCalls); matchedNodeID != "" {
return matchedNodeID
}
if len(assistantToolCalls) != len(allToolCalls) {
return chooseMatchingToolCallNodeID(payload, allToolCalls)
}
return ""
}
func chooseMatchingToolCallNodeID(payload openClawBehaviorPayload, candidates []*OpenClawSessionGraphNode) string {
filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates))
seenNodeIDs := map[string]struct{}{}
for _, candidate := range candidates {
if candidate == nil || candidate.Kind != "tool_call" {
continue
}
if _, ok := seenNodeIDs[candidate.ID]; ok {
continue
}
seenNodeIDs[candidate.ID] = struct{}{}
filtered = append(filtered, candidate)
}
if len(filtered) == 0 {
return ""
}
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Query, func(node *OpenClawSessionGraphNode) string { return node.Query })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.URL, func(node *OpenClawSessionGraphNode) string { return node.URL })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Path, func(node *OpenClawSessionGraphNode) string { return node.Path })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Tool, func(node *OpenClawSessionGraphNode) string { return node.Tool })
if len(filtered) == 1 {
return filtered[0].ID
}
return ""
}
func refineToolCallCandidates(candidates []*OpenClawSessionGraphNode, expected string, selector func(node *OpenClawSessionGraphNode) string) []*OpenClawSessionGraphNode {
expected = strings.TrimSpace(expected)
if expected == "" {
return candidates
}
filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates))
for _, candidate := range candidates {
if strings.TrimSpace(selector(candidate)) == expected {
filtered = append(filtered, candidate)
}
}
if len(filtered) == 0 {
return candidates
}
return filtered
}
func markStoredGraphAnchor(builder *openClawSessionGraphBuilder, anchorPayload openClawBehaviorPayload, anchorEntryName string, nodeIDsByEntryName map[string][]string) {
anchorNodeID := ""
if nodeIDs := nodeIDsByEntryName[strings.TrimSpace(anchorEntryName)]; len(nodeIDs) == 1 {
anchorNodeID = nodeIDs[0]
}
if anchorNodeID == "" {
switch anchorPayload.Kind {
case "tool_call":
candidates := []string{}
for _, node := range builder.nodes {
if !toolCallPayloadMatchesNode(anchorPayload, node) {
continue
}
candidates = append(candidates, node.ID)
}
switch len(candidates) {
case 1:
anchorNodeID = candidates[0]
default:
anchorNodeID = anchorPayload.EntryID
}
default:
if node := builder.nodes[anchorPayload.EntryID]; node != nil && node.Kind == anchorPayload.Kind {
anchorNodeID = node.ID
}
}
}
if anchorNode := builder.nodes[anchorNodeID]; anchorNode != nil {
anchorNode.IsAnchor = true
}
}
func buildStoredToolCallNodeID(entry *Entry, payload openClawBehaviorPayload) string {
if payload.ToolCallID != "" {
return fmt.Sprintf("tool_call:%s", payload.ToolCallID)
}
if entry != nil && strings.TrimSpace(entry.Name) != "" {
return fmt.Sprintf("tool_call_row:%s", strings.TrimSpace(entry.Name))
}
return fmt.Sprintf("tool_call:%s", strings.TrimSpace(payload.EntryID))
}
func newOpenClawSessionGraphBuilder() *openClawSessionGraphBuilder {
return &openClawSessionGraphBuilder{
graph: &OpenClawSessionGraph{
Nodes: []*OpenClawSessionGraphNode{},
Edges: []*OpenClawSessionGraphEdge{},
},
nodes: map[string]*OpenClawSessionGraphNode{},
}
}
func (b *openClawSessionGraphBuilder) addNode(node *OpenClawSessionGraphNode) {
if b == nil || node == nil {
return
}
node.ID = strings.TrimSpace(node.ID)
if node.ID == "" {
return
}
if existing := b.nodes[node.ID]; existing != nil {
mergeOpenClawGraphNode(existing, node)
return
}
cloned := *node
cloned.ParentID = strings.TrimSpace(cloned.ParentID)
cloned.OriginalParentID = strings.TrimSpace(cloned.OriginalParentID)
cloned.EntryID = strings.TrimSpace(cloned.EntryID)
cloned.ToolCallID = strings.TrimSpace(cloned.ToolCallID)
cloned.Kind = strings.TrimSpace(cloned.Kind)
cloned.Timestamp = strings.TrimSpace(cloned.Timestamp)
cloned.Summary = strings.TrimSpace(cloned.Summary)
cloned.Tool = strings.TrimSpace(cloned.Tool)
cloned.Query = strings.TrimSpace(cloned.Query)
cloned.URL = strings.TrimSpace(cloned.URL)
cloned.Path = strings.TrimSpace(cloned.Path)
cloned.Error = strings.TrimSpace(cloned.Error)
cloned.Text = strings.TrimSpace(cloned.Text)
cloned.OK = cloneBoolPointer(cloned.OK)
b.nodes[cloned.ID] = &cloned
}
func (b *openClawSessionGraphBuilder) finalize() *OpenClawSessionGraph {
if b == nil || b.graph == nil {
return nil
}
nodeIDs := make([]string, 0, len(b.nodes))
for id := range b.nodes {
nodeIDs = append(nodeIDs, id)
}
sort.Slice(nodeIDs, func(i, j int) bool {
left := b.nodes[nodeIDs[i]]
right := b.nodes[nodeIDs[j]]
return compareGraphNodes(left, right) < 0
})
b.graph.Nodes = make([]*OpenClawSessionGraphNode, 0, len(nodeIDs))
b.graph.Stats = OpenClawSessionGraphStats{}
for _, id := range nodeIDs {
node := b.nodes[id]
b.graph.Nodes = append(b.graph.Nodes, node)
updateOpenClawSessionGraphStats(&b.graph.Stats, node)
}
edgeKeys := map[string]struct{}{}
b.graph.Edges = []*OpenClawSessionGraphEdge{}
for _, node := range b.graph.Nodes {
if node.ParentID == "" || b.nodes[node.ParentID] == nil {
continue
}
key := fmt.Sprintf("%s->%s", node.ParentID, node.ID)
if _, ok := edgeKeys[key]; ok {
continue
}
edgeKeys[key] = struct{}{}
b.graph.Edges = append(b.graph.Edges, &OpenClawSessionGraphEdge{
Source: node.ParentID,
Target: node.ID,
})
}
sort.Slice(b.graph.Edges, func(i, j int) bool {
left := b.graph.Edges[i]
right := b.graph.Edges[j]
if left.Source != right.Source {
return left.Source < right.Source
}
return left.Target < right.Target
})
return b.graph
}
func mergeOpenClawGraphNode(current, next *OpenClawSessionGraphNode) {
if current == nil || next == nil {
return
}
current.ParentID = firstNonEmpty(current.ParentID, next.ParentID)
current.OriginalParentID = firstNonEmpty(current.OriginalParentID, next.OriginalParentID)
current.EntryID = firstNonEmpty(current.EntryID, next.EntryID)
current.ToolCallID = firstNonEmpty(current.ToolCallID, next.ToolCallID)
current.Kind = firstNonEmpty(current.Kind, next.Kind)
current.Timestamp = chooseEarlierTimestamp(current.Timestamp, next.Timestamp)
current.Summary = firstNonEmpty(current.Summary, next.Summary)
current.Tool = firstNonEmpty(current.Tool, next.Tool)
current.Query = firstNonEmpty(current.Query, next.Query)
current.URL = firstNonEmpty(current.URL, next.URL)
current.Path = firstNonEmpty(current.Path, next.Path)
current.Error = firstNonEmpty(current.Error, next.Error)
current.Text = firstNonEmpty(current.Text, next.Text)
current.OK = mergeBoolPointers(current.OK, next.OK)
current.IsAnchor = current.IsAnchor || next.IsAnchor
}
func updateOpenClawSessionGraphStats(stats *OpenClawSessionGraphStats, node *OpenClawSessionGraphNode) {
if stats == nil || node == nil {
return
}
stats.TotalNodes++
switch node.Kind {
case "task":
stats.TaskCount++
case "tool_call":
stats.ToolCallCount++
case "tool_result":
stats.ToolResultCount++
if node.OK != nil && !*node.OK {
stats.FailedCount++
}
case "final":
stats.FinalCount++
}
}
func buildAssistantStepSummary(toolNames []string) string {
deduped := []string{}
seen := map[string]struct{}{}
for _, toolName := range toolNames {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
continue
}
if _, ok := seen[toolName]; ok {
continue
}
seen[toolName] = struct{}{}
deduped = append(deduped, toolName)
}
if len(toolNames) == 0 {
return "assistant step"
}
if len(deduped) == 0 {
return fmt.Sprintf("%d tool calls", len(toolNames))
}
if len(deduped) <= 3 {
return fmt.Sprintf("%d tool calls: %s", len(toolNames), strings.Join(deduped, ", "))
}
return fmt.Sprintf("%d tool calls: %s, ...", len(toolNames), strings.Join(deduped[:3], ", "))
}
func toolCallPayloadMatchesNode(payload openClawBehaviorPayload, node *OpenClawSessionGraphNode) bool {
if node == nil || node.Kind != "tool_call" {
return false
}
if payload.ToolCallID != "" {
return strings.TrimSpace(node.ToolCallID) == strings.TrimSpace(payload.ToolCallID)
}
if strings.TrimSpace(node.EntryID) != strings.TrimSpace(payload.EntryID) {
return false
}
fields := []struct {
payload string
node string
}{
{payload.Tool, node.Tool},
{payload.Query, node.Query},
{payload.URL, node.URL},
{payload.Path, node.Path},
{payload.Text, node.Text},
}
matchedField := false
for _, field := range fields {
left := strings.TrimSpace(field.payload)
if left == "" {
continue
}
matchedField = true
if left != strings.TrimSpace(field.node) {
return false
}
}
return matchedField
}
func compareGraphNodes(left, right *OpenClawSessionGraphNode) int {
leftTimestamp := ""
rightTimestamp := ""
leftID := ""
rightID := ""
if left != nil {
leftTimestamp = left.Timestamp
leftID = left.ID
}
if right != nil {
rightTimestamp = right.Timestamp
rightID = right.ID
}
if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 {
return timestampOrder
}
if leftID < rightID {
return -1
}
if leftID > rightID {
return 1
}
return 0
}
func chooseEarlierTimestamp(current, next string) string {
current = strings.TrimSpace(current)
next = strings.TrimSpace(next)
if current == "" {
return next
}
if next == "" {
return current
}
if compareOpenClawGraphTimestamps(next, current) < 0 {
return next
}
return current
}
func compareOpenClawGraphTimestamps(left, right string) int {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
leftUnixNano, leftOK := parseOpenClawGraphTimestamp(left)
rightUnixNano, rightOK := parseOpenClawGraphTimestamp(right)
if leftOK && rightOK {
if leftUnixNano < rightUnixNano {
return -1
}
if leftUnixNano > rightUnixNano {
return 1
}
return 0
}
if left < right {
return -1
}
if left > right {
return 1
}
return 0
}
func parseOpenClawGraphTimestamp(timestamp string) (_ int64, ok bool) {
timestamp = strings.TrimSpace(timestamp)
if timestamp == "" {
return 0, false
}
defer func() {
if recover() != nil {
ok = false
}
}()
return util.String2Time(timestamp).UnixNano(), true
}
func mergeBoolPointers(current, next *bool) *bool {
if next == nil {
return current
}
if current == nil {
return cloneBoolPointer(next)
}
value := *current && *next
return &value
}
func cloneBoolPointer(value *bool) *bool {
if value == nil {
return nil
}
cloned := *value
return &cloned
}

View File

@@ -65,19 +65,21 @@ type openClawContentItem struct {
}
type openClawBehaviorPayload struct {
Summary string `json:"summary"`
Kind string `json:"kind"`
SessionID string `json:"sessionId"`
EntryID string `json:"entryId"`
ParentID string `json:"parentId,omitempty"`
Timestamp string `json:"timestamp"`
Tool string `json:"tool,omitempty"`
Query string `json:"query,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
OK *bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
Text string `json:"text,omitempty"`
Summary string `json:"summary"`
Kind string `json:"kind"`
SessionID string `json:"sessionId"`
EntryID string `json:"entryId"`
ToolCallID string `json:"toolCallId,omitempty"`
ParentID string `json:"parentId,omitempty"`
Timestamp string `json:"timestamp"`
Tool string `json:"tool,omitempty"`
Query string `json:"query,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
OK *bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
AssistantText string `json:"assistantText,omitempty"`
Text string `json:"text,omitempty"`
}
type openClawToolContext struct {
@@ -425,7 +427,9 @@ func buildOpenClawTranscriptEntries(provider *Provider, sessionID string, entry
})}
case "assistant":
items := parseContentItems(message.Content)
assistantText := truncateText(extractMessageText(message.Content), 2000)
toolEntries := []*Entry{}
storedAssistantText := false
for _, item := range items {
if item.Type != "toolCall" {
continue
@@ -433,17 +437,23 @@ func buildOpenClawTranscriptEntries(provider *Provider, sessionID string, entry
context := extractOpenClawToolContext(item)
toolContexts[item.ID] = context
payload := openClawBehaviorPayload{
Summary: truncateText(buildToolCallSummary(context), 100),
Kind: "tool_call",
SessionID: sessionID,
EntryID: entry.ID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Tool: context.Tool,
Query: context.Query,
URL: context.URL,
Path: context.Path,
Text: truncateText(context.Command, 500),
Summary: truncateText(buildToolCallSummary(context), 100),
Kind: "tool_call",
SessionID: sessionID,
EntryID: entry.ID,
ToolCallID: item.ID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Tool: context.Tool,
Query: context.Query,
URL: context.URL,
Path: context.Path,
Text: truncateText(context.Command, 500),
}
if !storedAssistantText {
// Avoid duplicating the same assistant text on every tool-call row.
payload.AssistantText = assistantText
storedAssistantText = true
}
identity := fmt.Sprintf("%s/%s", entry.ID, item.ID)
toolEntries = append(toolEntries, newOpenClawTranscriptEntry(provider, sessionID, "tool_call", identity, payload))
@@ -493,19 +503,20 @@ func buildToolResultPayload(sessionID string, entry openClawTranscriptEntry, too
}
return openClawBehaviorPayload{
Summary: truncateText(buildToolResultSummary(toolName, toolContext, okValue, errorText, text), 100),
Kind: "tool_result",
SessionID: sessionID,
EntryID: entry.ID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Tool: toolName,
Query: toolContext.Query,
URL: toolContext.URL,
Path: firstNonEmpty(toolContext.Path, extractWriteSuccessPath(text)),
OK: &okValue,
Error: truncateText(errorText, 500),
Text: truncateText(text, 2000),
Summary: truncateText(buildToolResultSummary(toolName, toolContext, okValue, errorText, text), 100),
Kind: "tool_result",
SessionID: sessionID,
EntryID: entry.ID,
ToolCallID: message.ToolCallID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Tool: toolName,
Query: toolContext.Query,
URL: toolContext.URL,
Path: firstNonEmpty(toolContext.Path, extractWriteSuccessPath(text)),
OK: &okValue,
Error: truncateText(errorText, 500),
Text: truncateText(text, 2000),
}, true
}

View File

@@ -145,6 +145,7 @@ func InitAPI() {
web.Router("/api/get-entries", &controllers.ApiController{}, "GET:GetEntries")
web.Router("/api/get-entry", &controllers.ApiController{}, "GET:GetEntry")
web.Router("/api/get-openclaw-session-graph", &controllers.ApiController{}, "GET:GetOpenClawSessionGraph")
web.Router("/api/update-entry", &controllers.ApiController{}, "POST:UpdateEntry")
web.Router("/api/add-entry", &controllers.ApiController{}, "POST:AddEntry")
web.Router("/api/delete-entry", &controllers.ApiController{}, "POST:DeleteEntry")

View File

@@ -54,6 +54,7 @@
"react-highlight-words": "^0.18.0",
"react-i18next": "^11.8.7",
"react-metamask-avatar": "^1.2.1",
"reactflow": "^11.11.4",
"react-router-dom": "^5.3.3",
"react-scripts": "5.0.1",
"react-social-login-buttons": "^3.4.0",

View File

@@ -19,6 +19,8 @@ import i18next from "i18next";
import Editor from "./common/Editor";
import SELinuxEntryViewer from "./SELinuxEntryViewer";
import * as ProviderBackend from "./backend/ProviderBackend";
import OpenClawSessionGraphViewer from "./OpenClawSessionGraphViewer";
import {isOpenClawSessionEntry} from "./OpenClawSessionGraphUtils";
class EntryMessageViewer extends React.Component {
constructor(props) {
@@ -491,6 +493,7 @@ class EntryMessageViewer extends React.Component {
}
renderSpecializedViewer() {
const provider = this.props.provider ?? this.state.provider;
switch (this.getProviderViewerType()) {
case "selinux":
return <SELinuxEntryViewer entry={this.props.entry} labelSpan={this.getLabelSpan()} contentSpan={this.getContentSpan()} />;
@@ -498,6 +501,9 @@ class EntryMessageViewer extends React.Component {
if (this.shouldRenderTraceViewer()) {
return this.renderTraceSpans();
}
if (isOpenClawSessionEntry(this.props.entry, provider)) {
return <OpenClawSessionGraphViewer entry={this.props.entry} provider={provider} labelSpan={this.getLabelSpan()} contentSpan={this.getContentSpan()} />;
}
return null;
}
}

View File

@@ -0,0 +1,351 @@
const openClawPayloadKinds = new Set(["task", "tool_call", "tool_result", "final"]);
export function isOpenClawSessionEntry(entry, provider) {
if (!entry || `${entry.type ?? ""}`.trim().toLowerCase() !== "session") {
return false;
}
if (provider?.category === "Log" && provider?.type === "Agent" && provider?.subType === "OpenClaw") {
return true;
}
if (provider) {
return false;
}
const payload = parseOpenClawBehaviorPayload(entry.message);
return Boolean(payload?.sessionId && payload?.entryId && payload?.kind);
}
function parseOpenClawBehaviorPayload(message) {
if (!message) {
return null;
}
const source = typeof message === "string" ? message : JSON.stringify(message);
if (!source) {
return null;
}
try {
const payload = JSON.parse(source);
const kind = `${payload?.kind ?? ""}`.trim();
const sessionId = `${payload?.sessionId ?? ""}`.trim();
const entryId = `${payload?.entryId ?? ""}`.trim();
if (!kind || !sessionId || !entryId || !openClawPayloadKinds.has(kind)) {
return null;
}
return payload;
} catch (e) {
return null;
}
}
export function getOpenClawNodeTarget(node) {
return node?.query || node?.url || node?.path || node?.tool || "";
}
export function getOpenClawNodeColor(node) {
switch (node?.kind) {
case "task":
return "#4c6ef5";
case "assistant_step":
return "#0f766e";
case "tool_call":
return "#f08c00";
case "tool_result":
return node?.ok === false ? "#e03131" : "#2f9e44";
case "final":
return "#6c5ce7";
default:
return "#868e96";
}
}
function normalizeText(value) {
return `${value ?? ""}`.replace(/\s+/g, " ").trim();
}
function stripLeadingPrefix(text, prefix) {
const normalizedText = normalizeText(text);
const normalizedPrefix = normalizeText(prefix);
if (!normalizedText || !normalizedPrefix) {
return normalizedText;
}
if (normalizedText.toLowerCase().startsWith(normalizedPrefix.toLowerCase())) {
return normalizedText.slice(normalizedPrefix.length).trim();
}
return normalizedText;
}
function getAssistantStepTitle(node) {
const summary = normalizeText(node?.summary);
const match = summary.match(/^(\d+\s+tool calls?)(?:\s*:\s*.+)?$/i);
if (match) {
return match[1];
}
return summary || node?.id || "-";
}
function getToolCallTitle(node) {
const target = normalizeText(getOpenClawNodeTarget(node));
if (target) {
return target;
}
const prefix = node?.tool ? `${node.tool}:` : "";
return stripLeadingPrefix(node?.summary, prefix) || normalizeText(node?.summary) || node?.id || "-";
}
function getToolResultTitle(node) {
const target = normalizeText(getOpenClawNodeTarget(node));
if (target) {
return target;
}
if (node?.ok === false && node?.error) {
return normalizeText(node.error);
}
const prefix = node?.tool ? `${node.tool} ${node.ok === false ? "failed" : "ok"}:` : "";
return stripLeadingPrefix(node?.summary, prefix) || normalizeText(node?.summary) || node?.id || "-";
}
function getNodeTitle(node) {
switch (node?.kind) {
case "assistant_step":
return getAssistantStepTitle(node);
case "tool_call":
return getToolCallTitle(node);
case "tool_result":
return getToolResultTitle(node);
default:
return normalizeText(node?.summary) || node?.id || "-";
}
}
function compareNodes(left, right) {
const leftTimestamp = `${left?.timestamp ?? ""}`.trim();
const rightTimestamp = `${right?.timestamp ?? ""}`.trim();
const leftMillis = parseTimestampMillis(leftTimestamp);
const rightMillis = parseTimestampMillis(rightTimestamp);
if (leftMillis !== null && rightMillis !== null) {
if (leftMillis !== rightMillis) {
return leftMillis - rightMillis;
}
} else if (leftTimestamp !== rightTimestamp) {
return leftTimestamp.localeCompare(rightTimestamp);
}
return `${left?.id ?? ""}`.localeCompare(`${right?.id ?? ""}`);
}
function parseTimestampMillis(timestamp) {
if (!timestamp) {
return null;
}
const milliseconds = Date.parse(timestamp);
if (Number.isNaN(milliseconds)) {
return null;
}
return milliseconds;
}
function buildTreeIndexes(graph) {
const sourceNodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
const sourceEdges = Array.isArray(graph?.edges) ? graph.edges : [];
const nodeMap = Object.fromEntries(sourceNodes.map(node => [node.id, node]));
const childrenMap = new Map();
const incomingCount = new Map();
sourceNodes.forEach((node) => {
childrenMap.set(node.id, []);
incomingCount.set(node.id, 0);
});
sourceEdges.forEach((edge) => {
if (!nodeMap[edge.source] || !nodeMap[edge.target]) {
return;
}
if (!childrenMap.has(edge.source)) {
childrenMap.set(edge.source, []);
}
childrenMap.get(edge.source).push(edge.target);
incomingCount.set(edge.target, (incomingCount.get(edge.target) || 0) + 1);
});
childrenMap.forEach((childIds) => childIds.sort((left, right) => compareNodes(nodeMap[left], nodeMap[right])));
const roots = sourceNodes
.filter(node => !incomingCount.get(node.id))
.sort(compareNodes)
.map(node => node.id);
return {nodeMap, childrenMap, roots};
}
function computeTreeLayout(graph) {
const {nodeMap, childrenMap, roots} = buildTreeIndexes(graph);
const positions = new Map();
const visited = new Set();
const verticalGap = 160;
const horizontalGap = 320;
let cursor = 0;
function placeNode(nodeId, depth, stack) {
if (!nodeMap[nodeId]) {
return {top: cursor * verticalGap, bottom: cursor * verticalGap, center: cursor * verticalGap};
}
if (positions.has(nodeId)) {
const y = positions.get(nodeId).y;
return {top: y, bottom: y, center: y};
}
if (stack.has(nodeId)) {
const y = cursor * verticalGap;
cursor += 1;
positions.set(nodeId, {x: depth * horizontalGap, y});
visited.add(nodeId);
return {top: y, bottom: y, center: y};
}
stack.add(nodeId);
const childIds = (childrenMap.get(nodeId) || []).filter(childId => nodeMap[childId]);
if (childIds.length === 0) {
const y = cursor * verticalGap;
cursor += 1;
positions.set(nodeId, {x: depth * horizontalGap, y});
visited.add(nodeId);
stack.delete(nodeId);
return {top: y, bottom: y, center: y};
}
const childBoxes = childIds.map(childId => placeNode(childId, depth + 1, stack));
const top = childBoxes[0].top;
const bottom = childBoxes[childBoxes.length - 1].bottom;
const center = childBoxes.length === 1 ? childBoxes[0].center : (top + bottom) / 2;
positions.set(nodeId, {x: depth * horizontalGap, y: center});
visited.add(nodeId);
stack.delete(nodeId);
return {top, bottom, center};
}
roots.forEach(rootId => placeNode(rootId, 0, new Set()));
Object.values(nodeMap)
.filter(node => !visited.has(node.id))
.sort(compareNodes)
.forEach((node) => {
placeNode(node.id, 0, new Set());
});
return positions;
}
function getNodeSubtitle(node) {
switch (node?.kind) {
case "assistant_step": {
const summary = normalizeText(node?.summary);
const parts = summary.split(":");
const detail = parts.length > 1 ? parts.slice(1).join(":").trim() : "";
return detail || node?.timestamp || "-";
}
case "tool_call":
return normalizeText(node?.tool) || node?.timestamp || "-";
case "tool_result":
if (node?.ok === false) {
return normalizeText(node?.error) || `${normalizeText(node?.tool) || "tool"} failed`;
}
return `${normalizeText(node?.tool) || "tool"} ok`;
default:
return getOpenClawNodeTarget(node) || node?.timestamp || "-";
}
}
function getNodeBackground(node) {
switch (node?.kind) {
case "assistant_step":
return "#f0fdfa";
case "tool_call":
return "#fff7ed";
case "tool_result":
return node?.ok === false ? "#fff5f5" : "#f3faf4";
case "final":
return "#f5f3ff";
default:
return "#ffffff";
}
}
function getEdgeStyle(edge, nodeMap) {
const targetNode = nodeMap[edge.target];
if (targetNode?.kind === "tool_result" && targetNode?.ok === false) {
return {
stroke: "#e03131",
strokeWidth: 2.5,
};
}
if (targetNode?.originalParentId && targetNode.originalParentId !== targetNode.parentId) {
return {
stroke: "#0f766e",
strokeWidth: 2.5,
strokeDasharray: "6 4",
};
}
return {
stroke: "#94a3b8",
strokeWidth: 2,
};
}
export function buildOpenClawFlowElements(graph) {
const sourceNodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
const sourceEdges = Array.isArray(graph?.edges) ? graph.edges : [];
const nodeMap = Object.fromEntries(sourceNodes.map(node => [node.id, node]));
const positions = computeTreeLayout(graph);
const flowNodes = sourceNodes
.slice()
.sort(compareNodes)
.map((node) => {
const color = getOpenClawNodeColor(node);
const position = positions.get(node.id) || {x: 0, y: 0};
return {
id: node.id,
position,
data: {
title: getNodeTitle(node),
subtitle: getNodeSubtitle(node),
rawNode: node,
isAnchor: node.isAnchor,
},
draggable: false,
selectable: true,
style: {
width: 250,
minHeight: 76,
padding: "12px 14px",
borderRadius: 14,
border: node.isAnchor ? `3px solid ${color}` : `1px solid ${color}`,
boxShadow: node.isAnchor ? "0 8px 24px rgba(0, 0, 0, 0.12)" : "0 4px 14px rgba(0, 0, 0, 0.08)",
background: getNodeBackground(node),
color: "#1f2937",
},
};
});
const flowEdges = sourceEdges.map(edge => ({
id: `${edge.source}-${edge.target}`,
source: edge.source,
target: edge.target,
type: "smoothstep",
animated: false,
style: getEdgeStyle(edge, nodeMap),
}));
return {nodes: flowNodes, edges: flowEdges};
}

View File

@@ -0,0 +1,390 @@
import React from "react";
import {
Alert,
Col,
Descriptions,
Drawer,
Row,
Spin,
Tag,
Typography
} from "antd";
import i18next from "i18next";
import ReactFlow, {
Background,
Controls,
MiniMap,
ReactFlowProvider
} from "reactflow";
import "reactflow/dist/style.css";
import * as EntryBackend from "./backend/EntryBackend";
import * as Setting from "./Setting";
import {
buildOpenClawFlowElements,
getOpenClawNodeColor,
getOpenClawNodeTarget
} from "./OpenClawSessionGraphUtils";
const {Text} = Typography;
function OpenClawNodeLabel({title, subtitle}) {
return (
<div style={{display: "flex", flexDirection: "column", gap: "6px"}}>
<div style={{fontSize: 13, fontWeight: 600, lineHeight: 1.35}}>
{title || "-"}
</div>
<div style={{fontSize: 12, color: "#64748b", lineHeight: 1.35}}>
{subtitle || "-"}
</div>
</div>
);
}
function getStatusTag(node) {
if (
node?.kind !== "tool_result" ||
node?.ok === undefined ||
node?.ok === null
) {
return null;
}
return node.ok ? (
<Tag color="success">{i18next.t("general:OK")}</Tag>
) : (
<Tag color="error">{i18next.t("entry:Failed", {defaultValue: "Failed"})}</Tag>
);
}
function OpenClawSessionGraphCanvas(props) {
const {graph, onNodeSelect} = props;
const [reactFlowInstance, setReactFlowInstance] = React.useState(null);
const elements = React.useMemo(() => {
const flowElements = buildOpenClawFlowElements(graph);
return {
nodes: flowElements.nodes.map((node) => ({
...node,
data: {
...node.data,
label: (
<OpenClawNodeLabel
title={node.data.title}
subtitle={node.data.subtitle}
/>
),
},
})),
edges: flowElements.edges,
};
}, [graph]);
React.useEffect(() => {
if (!reactFlowInstance || elements.nodes.length === 0) {
return;
}
reactFlowInstance.fitView({padding: 0.2, duration: 0});
const anchorNode = elements.nodes.find((node) => node.data?.isAnchor);
if (!anchorNode) {
return;
}
window.setTimeout(() => {
reactFlowInstance.setCenter(
anchorNode.position.x + 125,
anchorNode.position.y + 38,
{zoom: 1.02, duration: 0}
);
}, 0);
}, [elements.nodes, reactFlowInstance]);
return (
<div
style={{
height: 460,
border: "1px solid #e5e7eb",
borderRadius: 16,
overflow: "hidden",
}}
>
<ReactFlow
nodes={elements.nodes}
edges={elements.edges}
fitView
nodesDraggable={false}
nodesConnectable={false}
onInit={setReactFlowInstance}
onNodeClick={(_, node) => onNodeSelect(node.data?.rawNode ?? null)}
>
<MiniMap
pannable
zoomable
nodeColor={(node) => getOpenClawNodeColor(node.data?.rawNode)}
/>
<Controls showInteractive={false} />
<Background color="#f1f5f9" gap={16} />
</ReactFlow>
</div>
);
}
class OpenClawSessionGraphViewer extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: false,
error: "",
graph: null,
selectedNode: null,
};
this.requestKey = "";
this.isUnmounted = false;
}
componentDidMount() {
this.isUnmounted = false;
this.loadGraph();
}
componentDidUpdate(prevProps) {
if (
prevProps.entry?.owner !== this.props.entry?.owner ||
prevProps.entry?.name !== this.props.entry?.name ||
prevProps.provider !== this.props.provider
) {
this.loadGraph();
}
}
componentWillUnmount() {
this.isUnmounted = true;
this.requestKey = "";
}
getLabelSpan() {
return this.props.labelSpan ?? (Setting.isMobile() ? 22 : 2);
}
getContentSpan() {
return this.props.contentSpan ?? 22;
}
loadGraph() {
if (!this.props.entry?.owner || !this.props.entry?.name) {
this.requestKey = "";
this.setState({
loading: false,
error: "",
graph: null,
selectedNode: null,
});
return;
}
const requestKey = `${this.props.entry.owner}/${this.props.entry.name}`;
this.requestKey = requestKey;
this.setState({loading: true, error: "", selectedNode: null});
EntryBackend.getOpenClawSessionGraph(
this.props.entry.owner,
this.props.entry.name
)
.then((res) => {
if (this.isUnmounted || this.requestKey !== requestKey) {
return;
}
if (res.status === "ok" && res.data) {
this.setState({
loading: false,
error: "",
graph: res.data,
});
} else if (res.status === "ok") {
this.setState({
loading: false,
error: "",
graph: null,
});
} else {
this.setState({
loading: false,
error: `${i18next.t("entry:Failed to load session graph", {defaultValue: "Failed to load session graph"})}: ${res.msg}`,
graph: null,
});
}
})
.catch((error) => {
if (this.isUnmounted || this.requestKey !== requestKey) {
return;
}
this.setState({
loading: false,
error: `${i18next.t("entry:Failed to load session graph", {defaultValue: "Failed to load session graph"})}: ${error}`,
graph: null,
});
});
}
renderStats() {
const stats = this.state.graph?.stats;
if (!stats) {
return null;
}
return (
<div
style={{display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12}}
>
<Tag color="default">{i18next.t("entry:Nodes", {defaultValue: "Nodes"})}: {stats.totalNodes}</Tag>
<Tag color="blue">{i18next.t("entry:Tasks", {defaultValue: "Tasks"})}: {stats.taskCount}</Tag>
<Tag color="orange">{i18next.t("entry:Tool calls", {defaultValue: "Tool calls"})}: {stats.toolCallCount}</Tag>
<Tag color="green">{i18next.t("entry:Results", {defaultValue: "Results"})}: {stats.toolResultCount}</Tag>
<Tag color="purple">{i18next.t("entry:Finals", {defaultValue: "Finals"})}: {stats.finalCount}</Tag>
{stats.failedCount > 0 ? (
<Tag color="red">{i18next.t("entry:Failed", {defaultValue: "Failed"})}: {stats.failedCount}</Tag>
) : null}
</div>
);
}
renderNodeText(value) {
if (!value) {
return "-";
}
return (
<div style={{whiteSpace: "pre-wrap", wordBreak: "break-word"}}>
{value}
</div>
);
}
renderNodeDrawer() {
const node = this.state.selectedNode;
return (
<Drawer
title={node?.summary || i18next.t("entry:Session graph node", {defaultValue: "Session graph node"})}
width={Setting.isMobile() ? "100%" : 720}
placement="right"
onClose={() => this.setState({selectedNode: null})}
open={this.state.selectedNode !== null}
destroyOnClose
>
{node ? (
<Descriptions
bordered
size="small"
column={1}
layout={Setting.isMobile() ? "vertical" : "horizontal"}
style={{padding: "12px", height: "100%", overflowY: "auto"}}
>
<Descriptions.Item label={i18next.t("general:Type")}>
<div style={{display: "flex", alignItems: "center", gap: 8}}>
<Text>{node.kind || "-"}</Text>
{getStatusTag(node)}
</div>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Summary", {defaultValue: "Summary"})}>
{node.summary || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:Timestamp")}>
{node.timestamp || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Entry ID", {defaultValue: "Entry ID"})}>
{node.entryId || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Tool Call ID", {defaultValue: "Tool Call ID"})}>
{node.toolCallId || "-"}
</Descriptions.Item>
<Descriptions.Item label={`${i18next.t("general:Parent")} ${i18next.t("general:ID")}`}>
{node.parentId || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Original Parent ID", {defaultValue: "Original Parent ID"})}>
{node.originalParentId || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Target", {defaultValue: "Target"})}>
{getOpenClawNodeTarget(node) || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Tool", {defaultValue: "Tool"})}>
{node.tool || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Query", {defaultValue: "Query"})}>
{this.renderNodeText(node.query)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:URL")}>
{this.renderNodeText(node.url)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Path", {defaultValue: "Path"})}>
{this.renderNodeText(node.path)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:Error")}>
{this.renderNodeText(node.error)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Text", {defaultValue: "Text"})}>
{this.renderNodeText(node.text)}
</Descriptions.Item>
</Descriptions>
) : null}
</Drawer>
);
}
renderContent() {
if (this.state.loading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
padding: "48px 0",
}}
>
<Spin />
</div>
);
}
if (this.state.error) {
return <Alert type="warning" showIcon message={this.state.error} />;
}
if (!this.state.graph) {
return null;
}
return (
<>
{this.renderStats()}
<ReactFlowProvider>
<OpenClawSessionGraphCanvas
graph={this.state.graph}
onNodeSelect={(selectedNode) => this.setState({selectedNode})}
/>
</ReactFlowProvider>
{this.renderNodeDrawer()}
</>
);
}
render() {
if (!this.state.loading && !this.state.error && !this.state.graph) {
return null;
}
return (
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
{i18next.t("entry:Session graph", {defaultValue: "Session Graph"})}:
</Col>
<Col span={this.getContentSpan()}>
<div data-testid="openclaw-session-graph">{this.renderContent()}</div>
</Col>
</Row>
);
}
}
export default OpenClawSessionGraphViewer;

View File

@@ -28,6 +28,13 @@ export function getEntry(owner, name) {
}).then(res => res.json());
}
export function getOpenClawSessionGraph(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-openclaw-session-graph?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
}).then(res => res.json());
}
export function updateEntry(owner, name, entry) {
const newEntry = Setting.deepCopy(entry);
return fetch(`${Setting.ServerUrl}/api/update-entry?id=${owner}/${encodeURIComponent(name)}`, {

View File

@@ -13540,7 +13540,7 @@ react@^18.2.0:
dependencies:
loose-envify "^1.1.0"
reactflow@^11.8.1:
reactflow@^11.11.4, reactflow@^11.8.1:
version "11.11.4"
resolved "https://registry.yarnpkg.com/reactflow/-/reactflow-11.11.4.tgz#e3593e313420542caed81aecbd73fb9bc6576653"
integrity sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==