forked from casdoor/casdoor
feat: build OpenClaw session graphs from DB entries (#5382)
This commit is contained in:
@@ -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
|
||||
|
||||
804
object/openclaw_session_graph.go
Normal file
804
object/openclaw_session_graph.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
351
web/src/OpenClawSessionGraphUtils.js
Normal file
351
web/src/OpenClawSessionGraphUtils.js
Normal 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};
|
||||
}
|
||||
390
web/src/OpenClawSessionGraphViewer.js
Normal file
390
web/src/OpenClawSessionGraphViewer.js
Normal 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;
|
||||
@@ -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)}`, {
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user