From b6536eca389f4cf051dbc414ba219566c81b7018 Mon Sep 17 00:00:00 2001 From: elpresidank Date: Sun, 5 Apr 2026 22:44:45 -0500 Subject: [PATCH] init --- ts/deploy/.env.example | 10 + ts/deploy/docker-compose.dev.yml | 52 + ts/deploy/docker-compose.yml | 276 + ts/deploy/grafana/dashboards/llm-metrics.json | 317 ++ ts/deploy/grafana/dashboards/overview.json | 275 + .../grafana/dashboards/rag-pipeline.json | 404 ++ ts/deploy/grafana/provisioning/dashboards.yml | 14 + .../grafana/provisioning/datasources.yml | 49 + ts/deploy/loki/loki-config.yml | 52 + ts/deploy/otel-collector/config.yml | 41 + ts/deploy/prometheus/prometheus.yml | 36 + ts/deploy/tempo/tempo-config.yml | 49 + ts/packages/base/package.json | 1 + ts/packages/base/src/backend/nats.ts | 68 +- ts/packages/base/src/messaging/consumer.ts | 3 + ts/packages/base/src/processor/flow.ts | 4 +- .../base/src/services/embeddings-service.ts | 12 +- ts/packages/base/src/services/llm-service.ts | 31 +- ts/packages/base/tsconfig.json | 3 +- ts/packages/cli/package.json | 2 +- ts/packages/cli/src/commands/agent.ts | 34 +- ts/packages/cli/src/commands/config.ts | 66 +- ts/packages/cli/src/commands/embeddings.ts | 27 + ts/packages/cli/src/commands/flow.ts | 72 +- ts/packages/cli/src/commands/graph-rag.ts | 42 +- ts/packages/cli/src/commands/library.ts | 126 + ts/packages/cli/src/commands/triples.ts | 48 + ts/packages/cli/src/commands/util.ts | 38 +- ts/packages/cli/src/index.ts | 7 + ts/packages/cli/tsconfig.json | 5 +- ts/packages/client/package.json | 30 + .../client/src/__tests__/flows-api.test.ts | 221 + .../client/src/__tests__/messages.test.ts | 370 ++ .../src/__tests__/service-call-multi.test.ts | 285 + .../client/src/__tests__/service-call.test.ts | 239 + ts/packages/client/src/index.ts | 13 + ts/packages/client/src/models/Triple.ts | 40 + ts/packages/client/src/models/messages.ts | 496 ++ ts/packages/client/src/models/namespaces.ts | 42 + .../client/src/socket/service-call-multi.ts | 172 + ts/packages/client/src/socket/service-call.ts | 240 + .../client/src/socket/trustgraph-socket.ts | 2366 +++++++++ .../client/src/socket/websocket-adapter.ts | 133 + ts/packages/client/src/types.ts | 3 + ts/packages/client/tsconfig.json | 11 + ts/packages/flow/package.json | 2 +- ts/packages/flow/src/config/service.ts | 357 ++ ts/packages/flow/src/embeddings/ollama.ts | 76 + .../flow/src/gateway/dispatch/manager.ts | 196 +- .../flow/src/gateway/dispatch/serialize.ts | 272 + ts/packages/flow/src/gateway/index.ts | 8 + ts/packages/flow/src/gateway/server.ts | 99 +- ts/packages/flow/src/index.ts | 39 + ts/packages/flow/src/prompt/template.ts | 154 + .../flow/src/query/embeddings/qdrant-doc.ts | 80 + .../flow/src/query/embeddings/qdrant-graph.ts | 103 + .../flow/src/query/triples/falkordb.ts | 243 + ts/packages/flow/src/retrieval/graph-rag.ts | 164 +- .../flow/src/storage/embeddings/qdrant-doc.ts | 106 + .../src/storage/embeddings/qdrant-graph.ts | 127 + .../flow/src/storage/triples/falkordb.ts | 116 + ts/packages/flow/tsconfig.json | 3 +- ts/packages/mcp/package.json | 5 +- ts/packages/mcp/src/index.ts | 1 - ts/packages/mcp/src/server.ts | 341 +- ts/packages/mcp/src/socket-manager.ts | 147 - ts/packages/mcp/tsconfig.json | 6 +- ts/packages/workbench/index.html | 13 + ts/packages/workbench/package.json | 34 + ts/packages/workbench/src/App.tsx | 27 + .../src/components/layout/flow-selector.tsx | 25 + .../src/components/layout/root-layout.tsx | 45 + .../src/components/layout/sidebar.tsx | 168 + .../src/components/notification-toasts.tsx | 47 + .../workbench/src/components/ui/badge.tsx | 31 + .../workbench/src/components/ui/dialog.tsx | 85 + .../workbench/src/components/ui/tabs.tsx | 42 + .../workbench/src/components/ui/textarea.tsx | 48 + ts/packages/workbench/src/hooks/use-chat.ts | 215 + .../workbench/src/hooks/use-conversation.ts | 91 + ts/packages/workbench/src/hooks/use-flows.ts | 130 + .../workbench/src/hooks/use-library.ts | 134 + .../workbench/src/hooks/use-progress-store.ts | 39 + .../workbench/src/hooks/use-session-store.ts | 34 + ts/packages/workbench/src/index.css | 126 + ts/packages/workbench/src/lib/utils.ts | 6 + ts/packages/workbench/src/main.tsx | 36 + ts/packages/workbench/src/pages/chat.tsx | 305 ++ ts/packages/workbench/src/pages/flows.tsx | 490 ++ ts/packages/workbench/src/pages/graph.tsx | 586 +++ ts/packages/workbench/src/pages/library.tsx | 486 ++ ts/packages/workbench/src/pages/settings.tsx | 340 ++ .../src/providers/notification-provider.tsx | 92 + .../src/providers/settings-provider.tsx | 110 + .../src/providers/socket-provider.tsx | 125 + ts/packages/workbench/tsconfig.json | 18 + ts/packages/workbench/vite-env.d.ts | 1 + ts/packages/workbench/vite.config.ts | 25 + ts/pnpm-lock.yaml | 4632 +++++++++++++++++ ts/tsconfig.json | 1 + 100 files changed, 17680 insertions(+), 377 deletions(-) create mode 100644 ts/deploy/.env.example create mode 100644 ts/deploy/docker-compose.dev.yml create mode 100644 ts/deploy/docker-compose.yml create mode 100644 ts/deploy/grafana/dashboards/llm-metrics.json create mode 100644 ts/deploy/grafana/dashboards/overview.json create mode 100644 ts/deploy/grafana/dashboards/rag-pipeline.json create mode 100644 ts/deploy/grafana/provisioning/dashboards.yml create mode 100644 ts/deploy/grafana/provisioning/datasources.yml create mode 100644 ts/deploy/loki/loki-config.yml create mode 100644 ts/deploy/otel-collector/config.yml create mode 100644 ts/deploy/prometheus/prometheus.yml create mode 100644 ts/deploy/tempo/tempo-config.yml create mode 100644 ts/packages/cli/src/commands/embeddings.ts create mode 100644 ts/packages/cli/src/commands/library.ts create mode 100644 ts/packages/cli/src/commands/triples.ts create mode 100644 ts/packages/client/package.json create mode 100644 ts/packages/client/src/__tests__/flows-api.test.ts create mode 100644 ts/packages/client/src/__tests__/messages.test.ts create mode 100644 ts/packages/client/src/__tests__/service-call-multi.test.ts create mode 100644 ts/packages/client/src/__tests__/service-call.test.ts create mode 100644 ts/packages/client/src/index.ts create mode 100644 ts/packages/client/src/models/Triple.ts create mode 100644 ts/packages/client/src/models/messages.ts create mode 100644 ts/packages/client/src/models/namespaces.ts create mode 100644 ts/packages/client/src/socket/service-call-multi.ts create mode 100644 ts/packages/client/src/socket/service-call.ts create mode 100644 ts/packages/client/src/socket/trustgraph-socket.ts create mode 100644 ts/packages/client/src/socket/websocket-adapter.ts create mode 100644 ts/packages/client/src/types.ts create mode 100644 ts/packages/client/tsconfig.json create mode 100644 ts/packages/flow/src/config/service.ts create mode 100644 ts/packages/flow/src/embeddings/ollama.ts create mode 100644 ts/packages/flow/src/gateway/dispatch/serialize.ts create mode 100644 ts/packages/flow/src/prompt/template.ts create mode 100644 ts/packages/flow/src/query/embeddings/qdrant-doc.ts create mode 100644 ts/packages/flow/src/query/embeddings/qdrant-graph.ts create mode 100644 ts/packages/flow/src/query/triples/falkordb.ts create mode 100644 ts/packages/flow/src/storage/embeddings/qdrant-doc.ts create mode 100644 ts/packages/flow/src/storage/embeddings/qdrant-graph.ts create mode 100644 ts/packages/flow/src/storage/triples/falkordb.ts delete mode 100644 ts/packages/mcp/src/socket-manager.ts create mode 100644 ts/packages/workbench/index.html create mode 100644 ts/packages/workbench/package.json create mode 100644 ts/packages/workbench/src/App.tsx create mode 100644 ts/packages/workbench/src/components/layout/flow-selector.tsx create mode 100644 ts/packages/workbench/src/components/layout/root-layout.tsx create mode 100644 ts/packages/workbench/src/components/layout/sidebar.tsx create mode 100644 ts/packages/workbench/src/components/notification-toasts.tsx create mode 100644 ts/packages/workbench/src/components/ui/badge.tsx create mode 100644 ts/packages/workbench/src/components/ui/dialog.tsx create mode 100644 ts/packages/workbench/src/components/ui/tabs.tsx create mode 100644 ts/packages/workbench/src/components/ui/textarea.tsx create mode 100644 ts/packages/workbench/src/hooks/use-chat.ts create mode 100644 ts/packages/workbench/src/hooks/use-conversation.ts create mode 100644 ts/packages/workbench/src/hooks/use-flows.ts create mode 100644 ts/packages/workbench/src/hooks/use-library.ts create mode 100644 ts/packages/workbench/src/hooks/use-progress-store.ts create mode 100644 ts/packages/workbench/src/hooks/use-session-store.ts create mode 100644 ts/packages/workbench/src/index.css create mode 100644 ts/packages/workbench/src/lib/utils.ts create mode 100644 ts/packages/workbench/src/main.tsx create mode 100644 ts/packages/workbench/src/pages/chat.tsx create mode 100644 ts/packages/workbench/src/pages/flows.tsx create mode 100644 ts/packages/workbench/src/pages/graph.tsx create mode 100644 ts/packages/workbench/src/pages/library.tsx create mode 100644 ts/packages/workbench/src/pages/settings.tsx create mode 100644 ts/packages/workbench/src/providers/notification-provider.tsx create mode 100644 ts/packages/workbench/src/providers/settings-provider.tsx create mode 100644 ts/packages/workbench/src/providers/socket-provider.tsx create mode 100644 ts/packages/workbench/tsconfig.json create mode 100644 ts/packages/workbench/vite-env.d.ts create mode 100644 ts/packages/workbench/vite.config.ts create mode 100644 ts/pnpm-lock.yaml diff --git a/ts/deploy/.env.example b/ts/deploy/.env.example new file mode 100644 index 00000000..460c0ebc --- /dev/null +++ b/ts/deploy/.env.example @@ -0,0 +1,10 @@ +# LLM API Keys +OPENAI_TOKEN= +CLAUDE_KEY= + +# Gateway +GATEWAY_SECRET= +GATEWAY_PORT=8088 + +# Grafana +GF_SECURITY_ADMIN_PASSWORD=admin diff --git a/ts/deploy/docker-compose.dev.yml b/ts/deploy/docker-compose.dev.yml new file mode 100644 index 00000000..304c9aee --- /dev/null +++ b/ts/deploy/docker-compose.dev.yml @@ -0,0 +1,52 @@ +# TrustGraph TypeScript — Dev Overrides +# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d + +services: + # Live-edit dashboards without rebuilding + grafana: + volumes: + - ./grafana/provisioning/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro + - ./grafana/provisioning/dashboards.yml:/etc/grafana/provisioning/dashboards/dashboards.yml:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_DISABLE_LOGIN_FORM=true + - GF_USERS_DEFAULT_THEME=dark + - GF_EXPLORE_ENABLED=true + - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor tempoSearch tempoServiceGraph + + # Prometheus config live reload + prometheus: + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/prometheus + + # Loki config live reload + loki: + volumes: + - ./loki/loki-config.yml:/etc/loki/local-config.yaml + - loki-data:/tmp/loki + + # NATS CLI tools for debugging + nats-cli: + image: natsio/nats-box:latest + networks: + - trustgraph + environment: + - NATS_URL=nats://nats:4222 + entrypoint: ["/bin/sh", "-c", "echo 'NATS Box ready. Use: docker compose exec nats-cli nats ...' && sleep infinity"] + depends_on: + nats: + condition: service_healthy + profiles: + - debug + +volumes: + prometheus-data: + loki-data: + +networks: + trustgraph: + driver: bridge diff --git a/ts/deploy/docker-compose.yml b/ts/deploy/docker-compose.yml new file mode 100644 index 00000000..460be51e --- /dev/null +++ b/ts/deploy/docker-compose.yml @@ -0,0 +1,276 @@ +# TrustGraph TypeScript — Full Stack +# Usage: docker compose up -d +# Observability UI: http://localhost:3000 (Grafana) + +networks: + trustgraph: + driver: bridge + +volumes: + nats-data: + falkordb-data: + qdrant-data: + ollama-models: + prometheus-data: + loki-data: + tempo-data: + grafana-data: + +services: + # --------------------------------------------------------------------------- + # Infrastructure + # --------------------------------------------------------------------------- + + nats: + image: nats:2.10-alpine + command: ["--jetstream", "--http_port", "8222", "--store_dir", "/data"] + ports: + - "4222:4222" # Client connections + - "8222:8222" # Monitoring / metrics + volumes: + - nats-data:/data + networks: + - trustgraph + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + restart: unless-stopped + + falkordb: + image: falkordb/falkordb:latest + ports: + - "6379:6379" + volumes: + - falkordb-data:/data + networks: + - trustgraph + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + restart: unless-stopped + + qdrant: + image: qdrant/qdrant:latest + ports: + - "6333:6333" # REST API + - "6334:6334" # gRPC + volumes: + - qdrant-data:/qdrant/storage + networks: + - trustgraph + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:6333/healthz"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + restart: unless-stopped + + ollama: + image: ollama/ollama:latest + ports: + - "11434:11434" + volumes: + - ollama-models:/root/.ollama + networks: + - trustgraph + restart: unless-stopped + + # --------------------------------------------------------------------------- + # Observability + # --------------------------------------------------------------------------- + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--storage.tsdb.retention.time=7d" + - "--web.enable-remote-write-receiver" + - "--enable-feature=exemplar-storage" + networks: + - trustgraph + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + restart: unless-stopped + + loki: + image: grafana/loki:3.0.0 + ports: + - "3100:3100" + volumes: + - ./loki/loki-config.yml:/etc/loki/local-config.yaml:ro + - loki-data:/tmp/loki + command: ["-config.file=/etc/loki/local-config.yaml"] + networks: + - trustgraph + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3100/ready"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + restart: unless-stopped + + tempo: + image: grafana/tempo:latest + ports: + - "3200:3200" # Tempo API + volumes: + - ./tempo/tempo-config.yml:/etc/tempo/config.yml:ro + - tempo-data:/tmp/tempo + command: ["-config.file=/etc/tempo/config.yml"] + networks: + - trustgraph + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3200/ready"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + restart: unless-stopped + + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + ports: + - "4317:4317" # OTLP gRPC (apps send traces/metrics here) + - "4318:4318" # OTLP HTTP + - "8889:8889" # Prometheus exporter (scraped by Prometheus) + volumes: + - ./otel-collector/config.yml:/etc/otelcol-contrib/config.yaml:ro + depends_on: + tempo: + condition: service_healthy + networks: + - trustgraph + restart: unless-stopped + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + volumes: + - ./grafana/provisioning/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro + - ./grafana/provisioning/dashboards.yml:/etc/grafana/provisioning/dashboards/dashboards.yml:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana-data:/var/lib/grafana + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD:-admin} + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer + - GF_AUTH_DISABLE_LOGIN_FORM=false + - GF_USERS_DEFAULT_THEME=dark + - GF_EXPLORE_ENABLED=true + - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor tempoSearch tempoServiceGraph + depends_on: + prometheus: + condition: service_healthy + loki: + condition: service_healthy + tempo: + condition: service_healthy + networks: + - trustgraph + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + restart: unless-stopped + + # --------------------------------------------------------------------------- + # TrustGraph Services (placeholders — will be filled in later) + # --------------------------------------------------------------------------- + # + # gateway: + # build: + # context: ../ + # dockerfile: packages/base/Dockerfile + # target: gateway + # ports: + # - "${GATEWAY_PORT:-8088}:8000" + # environment: + # - NATS_URL=nats://nats:4222 + # - FALKORDB_URL=redis://falkordb:6379 + # - QDRANT_URL=http://qdrant:6333 + # - OPENAI_TOKEN=${OPENAI_TOKEN} + # - CLAUDE_KEY=${CLAUDE_KEY} + # - GATEWAY_SECRET=${GATEWAY_SECRET} + # - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 + # - OTEL_SERVICE_NAME=gateway + # depends_on: + # nats: + # condition: service_healthy + # falkordb: + # condition: service_healthy + # qdrant: + # condition: service_healthy + # networks: + # - trustgraph + # + # text-completion: + # build: + # context: ../ + # dockerfile: packages/base/Dockerfile + # target: text-completion + # environment: + # - NATS_URL=nats://nats:4222 + # - OPENAI_TOKEN=${OPENAI_TOKEN} + # - CLAUDE_KEY=${CLAUDE_KEY} + # - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 + # - OTEL_SERVICE_NAME=text-completion + # depends_on: + # nats: + # condition: service_healthy + # networks: + # - trustgraph + # + # graph-rag: + # build: + # context: ../ + # dockerfile: packages/base/Dockerfile + # target: graph-rag + # environment: + # - NATS_URL=nats://nats:4222 + # - FALKORDB_URL=redis://falkordb:6379 + # - QDRANT_URL=http://qdrant:6333 + # - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 + # - OTEL_SERVICE_NAME=graph-rag + # depends_on: + # nats: + # condition: service_healthy + # falkordb: + # condition: service_healthy + # qdrant: + # condition: service_healthy + # networks: + # - trustgraph + # + # workbench: + # build: + # context: ../ + # dockerfile: packages/workbench/Dockerfile + # ports: + # - "3001:3000" + # environment: + # - GATEWAY_URL=http://gateway:8000 + # depends_on: + # - gateway + # networks: + # - trustgraph diff --git a/ts/deploy/grafana/dashboards/llm-metrics.json b/ts/deploy/grafana/dashboards/llm-metrics.json new file mode 100644 index 00000000..9f87ed1b --- /dev/null +++ b/ts/deploy/grafana/dashboards/llm-metrics.json @@ -0,0 +1,317 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "title": "LLM Request Latency by Provider", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "id": 1, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\".*text-completion.*\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\".*text-completion.*\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p95", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.99, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\".*text-completion.*\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p99", + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "latency", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "scheme", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 30 } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + }, + { + "title": "Token Usage (Input vs Output)", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "id": 2, + "targets": [ + { + "expr": "sum(rate(tg_llm_input_tokens_total[5m])) by (job)", + "legendFormat": "{{job}} input tokens/s", + "refId": "A" + }, + { + "expr": "sum(rate(tg_llm_output_tokens_total[5m])) by (job)", + "legendFormat": "{{job}} output tokens/s", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "tokens/s", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "short", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + } + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*input.*" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byRegexp", "options": ".*output.*" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } } + ] + } + ] + }, + "options": { + "legend": { "calcs": ["mean", "sum"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + }, + { + "title": "Rate Limit Events", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "id": 3, + "targets": [ + { + "expr": "sum(rate(tg_consumer_rate_limit_total[5m])) by (job)", + "legendFormat": "{{job}} rate limits/s", + "refId": "A" + }, + { + "expr": "sum(increase(tg_consumer_rate_limit_total[1h])) by (job)", + "legendFormat": "{{job}} total (1h)", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "drawStyle": "bars", + "fillOpacity": 50, + "gradientMode": "none", + "lineInterpolation": "smooth", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "line" } + }, + "unit": "short", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + } + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*total.*" }, + "properties": [ + { "id": "custom.drawStyle", "value": "line" }, + { "id": "custom.axisPlacement", "value": "right" }, + { "id": "custom.fillOpacity", "value": 0 }, + { "id": "custom.lineWidth", "value": 2 } + ] + } + ] + }, + "options": { + "legend": { "calcs": ["sum", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + }, + { + "title": "Streaming Chunk Latency", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "id": 4, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(tg_llm_stream_chunk_duration_seconds_bucket[5m])) by (le, job))", + "legendFormat": "{{job}} chunk p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(tg_llm_stream_chunk_duration_seconds_bucket[5m])) by (le, job))", + "legendFormat": "{{job}} chunk p95", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.50, sum(rate(tg_llm_time_to_first_token_seconds_bucket[5m])) by (le, job))", + "legendFormat": "{{job}} TTFT p50", + "refId": "C" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(tg_llm_time_to_first_token_seconds_bucket[5m])) by (le, job))", + "legendFormat": "{{job}} TTFT p95", + "refId": "D" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "latency", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "none", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 2 } + ] + } + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*TTFT.*" }, + "properties": [ + { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [10, 10] } } + ] + } + ] + }, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + } + ], + "schemaVersion": 39, + "tags": ["trustgraph", "llm"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "TrustGraph - LLM Performance", + "uid": "tg-llm-metrics", + "version": 1 +} diff --git a/ts/deploy/grafana/dashboards/overview.json b/ts/deploy/grafana/dashboards/overview.json new file mode 100644 index 00000000..bd6f4f8d --- /dev/null +++ b/ts/deploy/grafana/dashboards/overview.json @@ -0,0 +1,275 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "title": "Service Health", + "type": "stat", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 6, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "targets": [ + { + "expr": "up", + "legendFormat": "{{job}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "0": { "text": "DOWN", "color": "red" } } }, + { "type": "value", "options": { "1": { "text": "UP", "color": "green" } } } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "green", "value": 1 } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto" + } + }, + { + "title": "NATS Message Throughput", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, + "id": 2, + "targets": [ + { + "expr": "rate(tg_producer_items_total[5m])", + "legendFormat": "{{job}} produced", + "refId": "A" + }, + { + "expr": "rate(tg_consumer_processing_total[5m])", + "legendFormat": "{{job}} consumed ({{status}})", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "msg/s", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "ops", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + }, + { + "title": "Request Latency (p50 / p95 / p99)", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, + "id": 3, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(tg_consumer_request_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(tg_consumer_request_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p95", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.99, sum(rate(tg_consumer_request_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p99", + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "latency", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 5, + "gradientMode": "scheme", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + }, + { + "title": "Error Rate", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 14 }, + "id": 4, + "targets": [ + { + "expr": "sum(rate(tg_consumer_processing_total{status=\"error\"}[5m])) by (job)", + "legendFormat": "{{job}} errors/s", + "refId": "A" + }, + { + "expr": "sum(rate(tg_consumer_processing_total{status=\"error\"}[5m])) / sum(rate(tg_consumer_processing_total[5m]))", + "legendFormat": "overall error ratio", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "none", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "line+area" } + }, + "unit": "ops", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "transparent", "value": null }, + { "color": "red", "value": 0.05 } + ] + } + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "overall error ratio" }, + "properties": [ + { "id": "unit", "value": "percentunit" }, + { "id": "custom.axisPlacement", "value": "right" }, + { "id": "custom.drawStyle", "value": "line" }, + { "id": "custom.lineWidth", "value": 3 }, + { "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } } + ] + } + ] + }, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + } + ], + "schemaVersion": 39, + "tags": ["trustgraph", "overview"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "TrustGraph - Service Health", + "uid": "tg-overview", + "version": 1 +} diff --git a/ts/deploy/grafana/dashboards/rag-pipeline.json b/ts/deploy/grafana/dashboards/rag-pipeline.json new file mode 100644 index 00000000..d0d5d972 --- /dev/null +++ b/ts/deploy/grafana/dashboards/rag-pipeline.json @@ -0,0 +1,404 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "title": "End-to-End RAG Query Latency", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"graph-rag|document-rag\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"graph-rag|document-rag\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p95", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.99, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"graph-rag|document-rag\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p99", + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "latency", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "scheme", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "line" } + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 15 } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { "calcs": ["mean", "max", "last"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + }, + { + "title": "Concept Extraction Time", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "id": 2, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"kg-extract.*\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"kg-extract.*\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p95", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "latency", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 3 }, + { "color": "red", "value": 10 } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + }, + { + "title": "Embedding Generation Time", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "id": 3, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"embeddings|document-embeddings|graph-embeddings\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"embeddings|document-embeddings|graph-embeddings\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p95", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "latency", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + }, + { + "title": "Graph Traversal Time", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "id": 4, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"query-triples|query-graph-embeddings|query-doc-embeddings\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"query-triples|query-graph-embeddings|query-doc-embeddings\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p95", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "latency", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "red", "value": 2 } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + }, + { + "title": "Synthesis Time (Text Completion / RAG)", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "id": 5, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"text-completion|text-completion-rag|prompt-rag\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(tg_consumer_request_duration_seconds_bucket{job=~\"text-completion|text-completion-rag|prompt-rag\"}[5m])) by (le, job))", + "legendFormat": "{{job}} p95", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "latency", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + }, + { + "title": "RAG Pipeline Throughput", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "tg-prometheus" + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 }, + "id": 6, + "targets": [ + { + "expr": "sum(rate(tg_consumer_processing_total{job=~\"graph-rag|document-rag\", status=\"success\"}[5m])) by (job)", + "legendFormat": "{{job}} success/s", + "refId": "A" + }, + { + "expr": "sum(rate(tg_consumer_processing_total{job=~\"graph-rag|document-rag\", status=\"error\"}[5m])) by (job)", + "legendFormat": "{{job}} errors/s", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "queries/s", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "none", + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "unit": "ops", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null } + ] + } + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*errors.*" }, + "properties": [ + { "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }, + { "id": "custom.fillOpacity", "value": 30 } + ] + } + ] + }, + "options": { + "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + } + } + ], + "schemaVersion": 39, + "tags": ["trustgraph", "rag", "pipeline"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "TrustGraph - RAG Pipeline", + "uid": "tg-rag-pipeline", + "version": 1 +} diff --git a/ts/deploy/grafana/provisioning/dashboards.yml b/ts/deploy/grafana/provisioning/dashboards.yml new file mode 100644 index 00000000..bcc8adcb --- /dev/null +++ b/ts/deploy/grafana/provisioning/dashboards.yml @@ -0,0 +1,14 @@ +apiVersion: 1 + +providers: + - name: "TrustGraph" + orgId: 1 + folder: "TrustGraph" + folderUid: "trustgraph-dashboards" + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/ts/deploy/grafana/provisioning/datasources.yml b/ts/deploy/grafana/provisioning/datasources.yml new file mode 100644 index 00000000..b8d0a724 --- /dev/null +++ b/ts/deploy/grafana/provisioning/datasources.yml @@ -0,0 +1,49 @@ +apiVersion: 1 + +prune: true + +datasources: + - name: Prometheus + type: prometheus + access: proxy + orgId: 1 + uid: "tg-prometheus" + url: http://prometheus:9090 + basicAuth: false + isDefault: true + editable: true + + - name: Loki + type: loki + access: proxy + orgId: 1 + uid: "tg-loki" + url: http://loki:3100 + basicAuth: false + editable: true + + - name: Tempo + type: tempo + access: proxy + orgId: 1 + uid: "tg-tempo" + url: http://tempo:3200 + basicAuth: false + editable: true + jsonData: + tracesToLogsV2: + datasourceUid: "tg-loki" + spanStartTimeShift: "-1h" + spanEndTimeShift: "1h" + filterByTraceID: true + filterBySpanID: false + tracesToMetrics: + datasourceUid: "tg-prometheus" + serviceMap: + datasourceUid: "tg-prometheus" + nodeGraph: + enabled: true + search: + hide: false + lokiSearch: + datasourceUid: "tg-loki" diff --git a/ts/deploy/loki/loki-config.yml b/ts/deploy/loki/loki-config.yml new file mode 100644 index 00000000..a61f1f0f --- /dev/null +++ b/ts/deploy/loki/loki-config.yml @@ -0,0 +1,52 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: warn + +common: + instance_addr: 127.0.0.1 + path_prefix: /tmp/loki + storage: + filesystem: + chunks_directory: /tmp/loki/chunks + rules_directory: /tmp/loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +limits_config: + metric_aggregation_enabled: true + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +pattern_ingester: + enabled: true + metric_aggregation: + loki_address: localhost:3100 + +ruler: + alertmanager_url: http://localhost:9093 + +frontend: + encoding: protobuf + +analytics: + reporting_enabled: false diff --git a/ts/deploy/otel-collector/config.yml b/ts/deploy/otel-collector/config.yml new file mode 100644 index 00000000..bad1c8d2 --- /dev/null +++ b/ts/deploy/otel-collector/config.yml @@ -0,0 +1,41 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + http: + endpoint: "0.0.0.0:4318" + +processors: + batch: + timeout: 5s + send_batch_size: 1024 + +exporters: + otlp/tempo: + endpoint: "tempo:4317" + tls: + insecure: true + + prometheus: + endpoint: "0.0.0.0:8889" + namespace: "tg" + resource_to_telemetry_conversion: + enabled: true + + debug: + verbosity: basic + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp/tempo] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus] + telemetry: + logs: + level: warn diff --git a/ts/deploy/prometheus/prometheus.yml b/ts/deploy/prometheus/prometheus.yml new file mode 100644 index 00000000..b58b54f2 --- /dev/null +++ b/ts/deploy/prometheus/prometheus.yml @@ -0,0 +1,36 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + + external_labels: + monitor: "trustgraph-ts" + +scrape_configs: + # Prometheus self-monitoring + - job_name: "prometheus" + scrape_interval: 15s + static_configs: + - targets: + - "prometheus:9090" + + # NATS monitoring + - job_name: "nats" + scrape_interval: 15s + metrics_path: "/varz" + static_configs: + - targets: + - "nats:8222" + + # OpenTelemetry Collector (exposes Prometheus metrics from OTLP pipeline) + - job_name: "otel-collector" + scrape_interval: 15s + static_configs: + - targets: + - "otel-collector:8889" + + # TrustGraph gateway (enabled when gateway container is running) + - job_name: "gateway" + scrape_interval: 5s + static_configs: + - targets: + - "gateway:8000" diff --git a/ts/deploy/tempo/tempo-config.yml b/ts/deploy/tempo/tempo-config.yml new file mode 100644 index 00000000..b43e4bc1 --- /dev/null +++ b/ts/deploy/tempo/tempo-config.yml @@ -0,0 +1,49 @@ +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + http: + endpoint: "0.0.0.0:4318" + +ingester: + max_block_duration: 5m + +compactor: + compaction: + block_retention: 48h + +metrics_generator: + registry: + external_labels: + source: tempo + cluster: trustgraph-dev + storage: + path: /tmp/tempo/generator/wal + remote_write: + - url: http://prometheus:9090/api/v1/write + send_exemplars: true + +storage: + trace: + backend: local + wal: + path: /tmp/tempo/wal + local: + path: /tmp/tempo/blocks + +overrides: + defaults: + metrics_generator: + processors: + - service-graphs + - span-metrics + +search_enabled: true + +analytics: + reporting_enabled: false diff --git a/ts/packages/base/package.json b/ts/packages/base/package.json index 070e7e80..1b3c1c1c 100644 --- a/ts/packages/base/package.json +++ b/ts/packages/base/package.json @@ -15,6 +15,7 @@ "prom-client": "^15.1.0" }, "devDependencies": { + "@types/node": "^22.0.0", "typescript": "^5.8.0", "vitest": "^3.1.0" } diff --git a/ts/packages/base/src/backend/nats.ts b/ts/packages/base/src/backend/nats.ts index 7fc4bf43..c268f1a5 100644 --- a/ts/packages/base/src/backend/nats.ts +++ b/ts/packages/base/src/backend/nats.ts @@ -13,10 +13,11 @@ import { type NatsConnection, type JetStreamClient, type JetStreamManager, - type ConsumerMessages, + type Consumer as NatsJsConsumer, type JsMsg, StringCodec, AckPolicy, + DeliverPolicy, } from "nats"; import type { @@ -31,17 +32,22 @@ import type { const sc = StringCodec(); class NatsMessage implements Message { + /** Exposed so acknowledge/negativeAcknowledge can access the raw JsMsg */ + readonly _jsMsg: JsMsg; + constructor( - private readonly msg: JsMsg, + msg: JsMsg, private readonly decoded: T, - ) {} + ) { + this._jsMsg = msg; + } value(): T { return this.decoded; } properties(): Record { - const headers = this.msg.headers; + const headers = this._jsMsg.headers; const props: Record = {}; if (headers) { for (const [key, values] of headers) { @@ -84,7 +90,7 @@ class NatsProducer implements BackendProducer { } class NatsConsumer implements BackendConsumer { - private messages: ConsumerMessages | null = null; + private consumer: NatsJsConsumer | null = null; constructor( private readonly js: JetStreamClient, @@ -106,43 +112,57 @@ class NatsConsumer implements BackendConsumer { }); } - // Create or bind to durable consumer - const consumer = await this.js.consumers.get(streamName, this.subscription); - this.messages = await consumer.consume(); + // Create or bind to durable consumer. + // Try to get an existing durable consumer first; if it doesn't exist, create it. + try { + this.consumer = await this.js.consumers.get(streamName, this.subscription); + } catch { + const deliverPolicy = + this.initialPosition === "earliest" + ? DeliverPolicy.All + : DeliverPolicy.New; + + await this.jsm.consumers.add(streamName, { + durable_name: this.subscription, + ack_policy: AckPolicy.Explicit, + deliver_policy: deliverPolicy, + filter_subject: this.subject, + }); + + this.consumer = await this.js.consumers.get(streamName, this.subscription); + } } async receive(timeoutMs = 2000): Promise | null> { - if (!this.messages) throw new Error("Consumer not initialized"); + if (!this.consumer) throw new Error("Consumer not initialized"); - const deadline = Date.now() + timeoutMs; - for await (const msg of this.messages) { - const decoded = JSON.parse(sc.decode(msg.data)) as T; - return new NatsMessage(msg, decoded); - } + // Pull a single message with a timeout using the pull-based API. + // consumer.next() returns a JsMsg or null when the timeout expires. + const msg = await this.consumer.next({ expires: timeoutMs }); + if (!msg) return null; - if (Date.now() >= deadline) return null; - return null; + const decoded = JSON.parse(sc.decode(msg.data)) as T; + return new NatsMessage(msg, decoded); } async acknowledge(message: Message): Promise { const natsMsg = message as NatsMessage; - // Access internal JsMsg for ack — in practice we'd store the ref - // This is a simplified version; real impl tracks msg refs - void natsMsg; + natsMsg._jsMsg.ack(); } async negativeAcknowledge(message: Message): Promise { - void message; + const natsMsg = message as NatsMessage; + natsMsg._jsMsg.nak(); } async unsubscribe(): Promise { - // Drain and close consumer + // The pull-based consumer does not have a persistent subscription to drain. + // Clearing the reference is sufficient; the durable consumer persists server-side. + this.consumer = null; } async close(): Promise { - if (this.messages) { - this.messages.stop(); - } + this.consumer = null; } private streamNameFromSubject(subject: string): string { diff --git a/ts/packages/base/src/messaging/consumer.ts b/ts/packages/base/src/messaging/consumer.ts index 4282f3b9..f8bbe9d4 100644 --- a/ts/packages/base/src/messaging/consumer.ts +++ b/ts/packages/base/src/messaging/consumer.ts @@ -5,6 +5,7 @@ */ import type { PubSubBackend, BackendConsumer, Message } from "../backend/types.js"; +import type { Flow } from "../processor/flow.js"; import { TooManyRequestsError } from "../errors.js"; export type MessageHandler = ( @@ -16,6 +17,8 @@ export type MessageHandler = ( export interface FlowContext { id: string; name: string; + /** Reference to the owning Flow instance, giving handlers access to producers and parameters. */ + flow: Flow; } export interface ConsumerOptions { diff --git a/ts/packages/base/src/processor/flow.ts b/ts/packages/base/src/processor/flow.ts index d27fb3ab..4c872700 100644 --- a/ts/packages/base/src/processor/flow.ts +++ b/ts/packages/base/src/processor/flow.ts @@ -34,9 +34,9 @@ export class Flow { await spec.add(this, this.pubsub, this.definition); } - // Start all consumers + // Start all consumers, passing this Flow instance via FlowContext for (const consumer of this.consumers.values()) { - consumer.start({ id: this.processorId, name: this.name }).catch((err) => { + consumer.start({ id: this.processorId, name: this.name, flow: this }).catch((err) => { console.error(`[Flow:${this.name}] Consumer error:`, err); }); } diff --git a/ts/packages/base/src/services/embeddings-service.ts b/ts/packages/base/src/services/embeddings-service.ts index 89fbadd2..6696198e 100644 --- a/ts/packages/base/src/services/embeddings-service.ts +++ b/ts/packages/base/src/services/embeddings-service.ts @@ -29,16 +29,24 @@ export abstract class EmbeddingsService extends FlowProcessor { private async onRequest( msg: EmbeddingsRequest, properties: Record, - _flowCtx: FlowContext, + flowCtx: FlowContext, ): Promise { const requestId = properties.id; if (!requestId) return; + const responseProducer = flowCtx.flow.producer("response"); + try { const vectors = await this.onEmbeddings(msg.text, msg.model); - void vectors; // Producer send would go here + await responseProducer.send(requestId, { vectors }); } catch (err) { console.error(`[EmbeddingsService] Error processing request:`, err); + + const message = err instanceof Error ? err.message : String(err); + await responseProducer.send(requestId, { + vectors: [], + error: { type: "embeddings-error", message }, + }); } } diff --git a/ts/packages/base/src/services/llm-service.ts b/ts/packages/base/src/services/llm-service.ts index d7f025e8..ea3770a6 100644 --- a/ts/packages/base/src/services/llm-service.ts +++ b/ts/packages/base/src/services/llm-service.ts @@ -10,7 +10,6 @@ import { ProducerSpec } from "../spec/producer-spec.js"; import { ParameterSpec } from "../spec/parameter-spec.js"; import type { ProcessorConfig } from "../processor/async-processor.js"; import type { FlowContext } from "../messaging/consumer.js"; -import type { Flow } from "../processor/flow.js"; import type { TextCompletionRequest, TextCompletionResponse, @@ -37,12 +36,11 @@ export abstract class LlmService extends FlowProcessor { properties: Record, flowCtx: FlowContext, ): Promise { - // We need the actual flow instance to access producers/parameters. - // In the full implementation, FlowContext would carry a flow reference. - // For now this shows the pattern. const requestId = properties.id; if (!requestId) return; + const responseProducer = flowCtx.flow.producer("response"); + try { if (msg.streaming && this.supportsStreaming()) { for await (const chunk of this.generateContentStream( @@ -51,8 +49,13 @@ export abstract class LlmService extends FlowProcessor { msg.model, msg.temperature, )) { - // Send each chunk as a response with the same request ID - void chunk; // Producer send would go here + await responseProducer.send(requestId, { + response: chunk.text, + model: chunk.model, + inToken: chunk.inToken ?? undefined, + outToken: chunk.outToken ?? undefined, + endOfStream: chunk.isFinal, + }); } } else { const result = await this.generateContent( @@ -61,10 +64,24 @@ export abstract class LlmService extends FlowProcessor { msg.model, msg.temperature, ); - void result; // Producer send would go here + + await responseProducer.send(requestId, { + response: result.text, + model: result.model, + inToken: result.inToken, + outToken: result.outToken, + endOfStream: true, + }); } } catch (err) { console.error(`[LlmService] Error processing request:`, err); + + const message = err instanceof Error ? err.message : String(err); + await responseProducer.send(requestId, { + response: "", + error: { type: "llm-error", message }, + endOfStream: true, + }); } } diff --git a/ts/packages/base/tsconfig.json b/ts/packages/base/tsconfig.json index 5a24989c..d231bbc5 100644 --- a/ts/packages/base/tsconfig.json +++ b/ts/packages/base/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "composite": true }, "include": ["src"] } diff --git a/ts/packages/cli/package.json b/ts/packages/cli/package.json index c18e4c5e..dc4ae3fb 100644 --- a/ts/packages/cli/package.json +++ b/ts/packages/cli/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@trustgraph/base": "workspace:*", - "@trustgraph/mcp": "workspace:*", + "@trustgraph/client": "workspace:*", "commander": "^13.1.0", "ws": "^8.18.0" }, diff --git a/ts/packages/cli/src/commands/agent.ts b/ts/packages/cli/src/commands/agent.ts index 13ccb293..dd74f051 100644 --- a/ts/packages/cli/src/commands/agent.ts +++ b/ts/packages/cli/src/commands/agent.ts @@ -17,18 +17,32 @@ export function registerAgentCommands(program: Command): void { const socket = await createSocket(opts); try { - const resp = await socket.request("agent", { question }, { - flowId: opts.flow, - onChunk: (chunk) => { - const c = chunk as { answer?: string }; - if (c.answer) process.stdout.write(c.answer); - }, - }); + const flow = socket.flow(opts.flow); - const r = resp as { answer?: string }; - if (r.answer) console.log(r.answer); + await new Promise((resolve, reject) => { + flow.agent( + question, + (chunk) => { + // think — show thought process + if (chunk) process.stderr.write(chunk); + }, + (chunk) => { + // observe — show observations + if (chunk) process.stderr.write(chunk); + }, + (chunk, complete) => { + // answer — print to stdout + if (chunk) process.stdout.write(chunk); + if (complete) { + process.stdout.write("\n"); + resolve(); + } + }, + (err) => reject(new Error(err)), + ); + }); } finally { - await socket.close(); + socket.close(); } }); } diff --git a/ts/packages/cli/src/commands/config.ts b/ts/packages/cli/src/commands/config.ts index 28ecc263..2ab8c6cf 100644 --- a/ts/packages/cli/src/commands/config.ts +++ b/ts/packages/cli/src/commands/config.ts @@ -20,62 +20,96 @@ export function registerConfigCommands(program: Command): void { const socket = await createSocket(opts); try { - const resp = await socket.request("config", { operation: "config" }); + const cfg = socket.config(); + const resp = await cfg.getConfigAll(); console.log(JSON.stringify(resp, null, 2)); } finally { - await socket.close(); + socket.close(); } }); config .command("get") .description("Get a configuration value") - .argument("", "Config key") + .argument("", "Config key (format: type/key)") .action(async (key: string, _opts, cmd) => { const opts = getOpts(cmd); const socket = await createSocket(opts); try { - const resp = await socket.request("config", { operation: "get", keys: [key] }); + const cfg = socket.config(); + // Support "type/key" format; fall back to using the whole string as key + const parts = key.split("/"); + const configKey = + parts.length >= 2 + ? { type: parts[0], key: parts.slice(1).join("/") } + : { type: "config", key }; + const resp = await cfg.getConfig([configKey]); console.log(JSON.stringify(resp, null, 2)); } finally { - await socket.close(); + socket.close(); } }); config .command("set") .description("Set a configuration value") - .argument("", "Config key") + .argument("", "Config key (format: type/key)") .argument("", "Config value (JSON)") .action(async (key: string, value: string, _opts, cmd) => { const opts = getOpts(cmd); const socket = await createSocket(opts); try { - const parsed = JSON.parse(value); - const resp = await socket.request("config", { - operation: "put", - values: { [key]: parsed }, - }); + const cfg = socket.config(); + const parts = key.split("/"); + const configEntry = + parts.length >= 2 + ? { type: parts[0], key: parts.slice(1).join("/"), value } + : { type: "config", key, value }; + const resp = await cfg.putConfig([configEntry]); console.log(JSON.stringify(resp, null, 2)); } finally { - await socket.close(); + socket.close(); } }); config .command("list") - .description("List configuration keys") - .action(async (_opts, cmd) => { + .description("List configuration keys for a type") + .argument("[type]", "Config type to list", "config") + .action(async (type: string, _opts, cmd) => { const opts = getOpts(cmd); const socket = await createSocket(opts); try { - const resp = await socket.request("config", { operation: "list" }); + const cfg = socket.config(); + const resp = await cfg.list(type); console.log(JSON.stringify(resp, null, 2)); } finally { - await socket.close(); + socket.close(); + } + }); + + config + .command("delete") + .description("Delete a configuration entry") + .argument("", "Config key (format: type/key)") + .action(async (key: string, _opts, cmd) => { + const opts = getOpts(cmd); + const socket = await createSocket(opts); + + try { + const cfg = socket.config(); + const parts = key.split("/"); + const configKey = + parts.length >= 2 + ? { type: parts[0], key: parts.slice(1).join("/") } + : { type: "config", key }; + const resp = await cfg.deleteConfig(configKey); + console.log(JSON.stringify(resp, null, 2)); + } finally { + socket.close(); } }); } diff --git a/ts/packages/cli/src/commands/embeddings.ts b/ts/packages/cli/src/commands/embeddings.ts new file mode 100644 index 00000000..153f8418 --- /dev/null +++ b/ts/packages/cli/src/commands/embeddings.ts @@ -0,0 +1,27 @@ +/** + * Embeddings CLI commands. + * + * Generate text embeddings using the configured embedding model. + */ + +import type { Command } from "commander"; +import { createSocket, getOpts } from "./util.js"; + +export function registerEmbeddingsCommands(program: Command): void { + program + .command("embeddings") + .description("Generate text embeddings") + .argument("", "Text(s) to embed") + .action(async (texts: string[], _opts, cmd) => { + const opts = getOpts(cmd); + const socket = await createSocket(opts); + + try { + const flow = socket.flow(opts.flow); + const vectors = await flow.embeddings(texts); + console.log(JSON.stringify(vectors, null, 2)); + } finally { + socket.close(); + } + }); +} diff --git a/ts/packages/cli/src/commands/flow.ts b/ts/packages/cli/src/commands/flow.ts index 0d18a32e..346faaed 100644 --- a/ts/packages/cli/src/commands/flow.ts +++ b/ts/packages/cli/src/commands/flow.ts @@ -20,61 +20,73 @@ export function registerFlowCommands(program: Command): void { const socket = await createSocket(opts); try { - const resp = await socket.request("flow", { operation: "list" }); - console.log(JSON.stringify(resp, null, 2)); + const flows = socket.flows(); + const ids = await flows.getFlows(); + console.log(JSON.stringify(ids, null, 2)); } finally { - await socket.close(); + socket.close(); + } + }); + + flow + .command("get") + .description("Get a flow definition") + .argument("", "Flow ID") + .action(async (id: string, _opts, cmd) => { + const opts = getOpts(cmd); + const socket = await createSocket(opts); + + try { + const flows = socket.flows(); + const def = await flows.getFlow(id); + console.log(JSON.stringify(def, null, 2)); + } finally { + socket.close(); } }); flow .command("start") .description("Start a flow") - .argument("", "Flow name") - .action(async (name: string, _opts, cmd) => { + .argument("", "Flow ID") + .requiredOption("-b, --blueprint ", "Blueprint name") + .option("-d, --description ", "Flow description", "") + .option("-p, --parameters ", "Parameters as JSON") + .action(async (id: string, cmdOpts, cmd) => { const opts = getOpts(cmd); const socket = await createSocket(opts); try { - const resp = await socket.request("flow", { operation: "start", name }); + const flows = socket.flows(); + const params = cmdOpts.parameters + ? JSON.parse(cmdOpts.parameters as string) + : undefined; + const resp = await flows.startFlow( + id, + cmdOpts.blueprint as string, + cmdOpts.description as string, + params as Record | undefined, + ); console.log(JSON.stringify(resp, null, 2)); } finally { - await socket.close(); + socket.close(); } }); flow .command("stop") .description("Stop a flow") - .argument("", "Flow name") - .action(async (name: string, _opts, cmd) => { + .argument("", "Flow ID") + .action(async (id: string, _opts, cmd) => { const opts = getOpts(cmd); const socket = await createSocket(opts); try { - const resp = await socket.request("flow", { operation: "stop", name }); + const flows = socket.flows(); + const resp = await flows.stopFlow(id); console.log(JSON.stringify(resp, null, 2)); } finally { - await socket.close(); - } - }); - - flow - .command("status") - .description("Show flow status") - .argument("[name]", "Flow name (all if omitted)") - .action(async (name: string | undefined, _opts, cmd) => { - const opts = getOpts(cmd); - const socket = await createSocket(opts); - - try { - const resp = await socket.request("flow", { - operation: "status", - ...(name ? { name } : {}), - }); - console.log(JSON.stringify(resp, null, 2)); - } finally { - await socket.close(); + socket.close(); } }); } diff --git a/ts/packages/cli/src/commands/graph-rag.ts b/ts/packages/cli/src/commands/graph-rag.ts index d89b2606..ea883d87 100644 --- a/ts/packages/cli/src/commands/graph-rag.ts +++ b/ts/packages/cli/src/commands/graph-rag.ts @@ -1,5 +1,5 @@ /** - * Graph RAG CLI commands. + * Graph RAG and Document RAG CLI commands. * * Python reference: trustgraph-cli/trustgraph/cli/invoke_graph_rag.py */ @@ -14,24 +14,24 @@ export function registerGraphRagCommands(program: Command): void { .argument("", "Natural language query") .option("--entity-limit ", "Max entities", "50") .option("--triple-limit ", "Max triples per entity", "30") + .option("--collection ", "Collection name") .action(async (query: string, cmdOpts, cmd) => { const opts = getOpts(cmd); const socket = await createSocket(opts); try { - const resp = await socket.request( - "graph-rag", + const flow = socket.flow(opts.flow); + const response = await flow.graphRag( + query, { - query, - entity_limit: parseInt(cmdOpts.entityLimit, 10), - triple_limit: parseInt(cmdOpts.tripleLimit, 10), + entityLimit: parseInt(cmdOpts.entityLimit, 10), + tripleLimit: parseInt(cmdOpts.tripleLimit, 10), }, - { flowId: opts.flow }, - ) as { response?: string }; - - console.log(resp.response ?? JSON.stringify(resp, null, 2)); + cmdOpts.collection, + ); + console.log(response); } finally { - await socket.close(); + socket.close(); } }); @@ -39,20 +39,22 @@ export function registerGraphRagCommands(program: Command): void { .command("document-rag") .description("Query documents using RAG") .argument("", "Natural language query") - .action(async (query: string, _cmdOpts, cmd) => { + .option("--doc-limit ", "Max documents", "20") + .option("--collection ", "Collection name") + .action(async (query: string, cmdOpts, cmd) => { const opts = getOpts(cmd); const socket = await createSocket(opts); try { - const resp = await socket.request( - "document-rag", - { query }, - { flowId: opts.flow }, - ) as { response?: string }; - - console.log(resp.response ?? JSON.stringify(resp, null, 2)); + const flow = socket.flow(opts.flow); + const response = await flow.documentRag( + query, + cmdOpts.docLimit ? parseInt(cmdOpts.docLimit, 10) : undefined, + cmdOpts.collection, + ); + console.log(response); } finally { - await socket.close(); + socket.close(); } }); } diff --git a/ts/packages/cli/src/commands/library.ts b/ts/packages/cli/src/commands/library.ts new file mode 100644 index 00000000..ac9e9dfa --- /dev/null +++ b/ts/packages/cli/src/commands/library.ts @@ -0,0 +1,126 @@ +/** + * Document library CLI commands. + * + * Manages documents stored in the TrustGraph library. + */ + +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import type { Command } from "commander"; +import { createSocket, getOpts } from "./util.js"; + +/** Simple MIME-type lookup by file extension. */ +function guessMimeType(filepath: string): string { + const ext = filepath.split(".").pop()?.toLowerCase(); + switch (ext) { + case "pdf": + return "application/pdf"; + case "txt": + return "text/plain"; + case "md": + return "text/markdown"; + case "html": + case "htm": + return "text/html"; + case "json": + return "application/json"; + case "csv": + return "text/csv"; + case "docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + default: + return "application/octet-stream"; + } +} + +export function registerLibraryCommands(program: Command): void { + const library = program + .command("library") + .description("Document library management"); + + library + .command("list") + .description("List documents in the library") + .action(async (_opts, cmd) => { + const opts = getOpts(cmd); + const socket = await createSocket(opts); + + try { + const lib = socket.librarian(); + const docs = await lib.getDocuments(); + console.log(JSON.stringify(docs, null, 2)); + } finally { + socket.close(); + } + }); + + library + .command("load") + .description("Load a document into the library") + .argument("", "Path to the file to load") + .option("-t, --title ", "Document title") + .option("-m, --mime-type <type>", "MIME type (auto-detected if omitted)") + .option("-c, --comments <text>", "Comments", "") + .option("--tags <tags...>", "Document tags") + .option("--id <id>", "Optional document ID") + .action(async (file: string, cmdOpts, cmd) => { + const opts = getOpts(cmd); + const socket = await createSocket(opts); + + try { + const lib = socket.librarian(); + const data = readFileSync(file); + const b64 = data.toString("base64"); + const mimeType = (cmdOpts.mimeType as string | undefined) ?? guessMimeType(file); + const title = (cmdOpts.title as string | undefined) ?? basename(file); + const comments = cmdOpts.comments as string; + const tags: string[] = (cmdOpts.tags as string[] | undefined) ?? []; + + const resp = await lib.loadDocument( + b64, + mimeType, + title, + comments, + tags, + cmdOpts.id as string | undefined, + ); + console.log(JSON.stringify(resp, null, 2)); + } finally { + socket.close(); + } + }); + + library + .command("remove") + .description("Remove a document from the library") + .argument("<id>", "Document ID to remove") + .option("--collection <name>", "Collection name") + .action(async (id: string, cmdOpts, cmd) => { + const opts = getOpts(cmd); + const socket = await createSocket(opts); + + try { + const lib = socket.librarian(); + const resp = await lib.removeDocument(id, cmdOpts.collection as string | undefined); + console.log(JSON.stringify(resp, null, 2)); + } finally { + socket.close(); + } + }); + + library + .command("processing") + .description("List documents currently being processed") + .action(async (_opts, cmd) => { + const opts = getOpts(cmd); + const socket = await createSocket(opts); + + try { + const lib = socket.librarian(); + const items = await lib.getProcessing(); + console.log(JSON.stringify(items, null, 2)); + } finally { + socket.close(); + } + }); +} diff --git a/ts/packages/cli/src/commands/triples.ts b/ts/packages/cli/src/commands/triples.ts new file mode 100644 index 00000000..afbe84ef --- /dev/null +++ b/ts/packages/cli/src/commands/triples.ts @@ -0,0 +1,48 @@ +/** + * Triples query CLI commands. + * + * Query the knowledge graph for subject-predicate-object triples. + */ + +import type { Command } from "commander"; +import type { Term } from "@trustgraph/client"; +import { createSocket, getOpts } from "./util.js"; + +export function registerTriplesCommands(program: Command): void { + program + .command("triples") + .description("Query knowledge graph triples") + .option("-s, --subject <iri>", "Subject IRI") + .option("-p, --predicate <iri>", "Predicate IRI") + .option("-o, --object <iri>", "Object IRI or literal") + .option("-l, --limit <n>", "Max results", "20") + .option("--collection <name>", "Collection name") + .action(async (cmdOpts, cmd) => { + const opts = getOpts(cmd); + const socket = await createSocket(opts); + + try { + const flow = socket.flow(opts.flow); + const s: Term | undefined = cmdOpts.subject + ? { t: "i", i: cmdOpts.subject as string } + : undefined; + const p: Term | undefined = cmdOpts.predicate + ? { t: "i", i: cmdOpts.predicate as string } + : undefined; + const o: Term | undefined = cmdOpts.object + ? { t: "i", i: cmdOpts.object as string } + : undefined; + + const triples = await flow.triplesQuery( + s, + p, + o, + parseInt(cmdOpts.limit as string, 10), + cmdOpts.collection as string | undefined, + ); + console.log(JSON.stringify(triples, null, 2)); + } finally { + socket.close(); + } + }); +} diff --git a/ts/packages/cli/src/commands/util.ts b/ts/packages/cli/src/commands/util.ts index 4492d200..cb582037 100644 --- a/ts/packages/cli/src/commands/util.ts +++ b/ts/packages/cli/src/commands/util.ts @@ -3,10 +3,11 @@ */ import type { Command } from "commander"; -import { SocketManager } from "@trustgraph/mcp"; +import { createTrustGraphSocket, type BaseApi } from "@trustgraph/client"; export interface CliOpts { gateway: string; + user: string; token?: string; flow: string; } @@ -18,11 +19,36 @@ export function getOpts(cmd: Command): CliOpts { return root.opts() as CliOpts; } -export async function createSocket(opts: CliOpts): Promise<SocketManager> { - const socket = new SocketManager({ - gatewayUrl: opts.gateway, - token: opts.token, +/** + * Create a BaseApi socket client and wait for the connection to be established. + * The client auto-connects; we listen for the first "connected/authenticated" + * state before handing it back to the caller. + */ +export async function createSocket(opts: CliOpts): Promise<BaseApi> { + const socket = createTrustGraphSocket(opts.user, opts.token, opts.gateway); + + // Wait for the socket to reach an open state + await new Promise<void>((resolve, reject) => { + const timeout = setTimeout(() => { + unsub(); + reject(new Error("Timed out waiting for WebSocket connection")); + }, 15_000); + + const unsub = socket.onConnectionStateChange((state) => { + if ( + state.status === "authenticated" || + state.status === "unauthenticated" + ) { + clearTimeout(timeout); + unsub(); + resolve(); + } else if (state.status === "failed") { + clearTimeout(timeout); + unsub(); + reject(new Error(state.lastError ?? "WebSocket connection failed")); + } + }); }); - await socket.connect(); + return socket; } diff --git a/ts/packages/cli/src/index.ts b/ts/packages/cli/src/index.ts index 20e3dbe4..07848599 100644 --- a/ts/packages/cli/src/index.ts +++ b/ts/packages/cli/src/index.ts @@ -14,6 +14,9 @@ import { registerAgentCommands } from "./commands/agent.js"; import { registerGraphRagCommands } from "./commands/graph-rag.js"; import { registerConfigCommands } from "./commands/config.js"; import { registerFlowCommands } from "./commands/flow.js"; +import { registerLibraryCommands } from "./commands/library.js"; +import { registerTriplesCommands } from "./commands/triples.js"; +import { registerEmbeddingsCommands } from "./commands/embeddings.js"; const program = new Command(); @@ -22,6 +25,7 @@ program .description("TrustGraph CLI — interact with TrustGraph services") .version("0.1.0") .option("-g, --gateway <url>", "Gateway WebSocket URL", "ws://localhost:8088/api/v1/socket") + .option("-u, --user <id>", "User identifier", "cli") .option("-t, --token <token>", "Authentication token") .option("-f, --flow <id>", "Flow ID", "default"); @@ -29,5 +33,8 @@ registerAgentCommands(program); registerGraphRagCommands(program); registerConfigCommands(program); registerFlowCommands(program); +registerLibraryCommands(program); +registerTriplesCommands(program); +registerEmbeddingsCommands(program); program.parse(); diff --git a/ts/packages/cli/tsconfig.json b/ts/packages/cli/tsconfig.json index bbcfb7e7..944bd1d6 100644 --- a/ts/packages/cli/tsconfig.json +++ b/ts/packages/cli/tsconfig.json @@ -2,11 +2,12 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "composite": true }, "include": ["src"], "references": [ { "path": "../base" }, - { "path": "../mcp" } + { "path": "../client" } ] } diff --git a/ts/packages/client/package.json b/ts/packages/client/package.json new file mode 100644 index 00000000..7c562a94 --- /dev/null +++ b/ts/packages/client/package.json @@ -0,0 +1,30 @@ +{ + "name": "@trustgraph/client", + "version": "0.1.0", + "description": "Vendored TrustGraph WebSocket client (forked from trustgraph-client v1.6.0)", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "clean": "rm -rf dist", + "test": "vitest run" + }, + "peerDependencies": { + "ws": "^8.0.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.0", + "typescript": "^5.8.0", + "vitest": "^3.1.0", + "happy-dom": "^20.0.0" + }, + "license": "Apache-2.0" +} diff --git a/ts/packages/client/src/__tests__/flows-api.test.ts b/ts/packages/client/src/__tests__/flows-api.test.ts new file mode 100644 index 00000000..e011af72 --- /dev/null +++ b/ts/packages/client/src/__tests__/flows-api.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { FlowsApi } from "../socket/trustgraph-socket"; +import { FlowResponse } from "../models/messages"; + +describe("FlowsApi", () => { + let mockApi: { + makeRequest: ReturnType<typeof vi.fn>; + }; + let flowsApi: FlowsApi; + + beforeEach(() => { + mockApi = { + makeRequest: vi.fn(), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + flowsApi = new FlowsApi(mockApi as any); + }); + + describe("startFlow", () => { + it("should call makeRequest with correct types and parameters", async () => { + const mockResponse: FlowResponse = { + flow: "started", + description: "Flow started successfully", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.startFlow( + "test-flow-id", + "test-class", + "Test description", + ); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "start-flow", + "flow-id": "test-flow-id", + "blueprint-name": "test-class", + description: "Test description", + }, + 30000, + ); + expect(result).toEqual(mockResponse); + }); + + it("should use FlowRequest and FlowResponse types", async () => { + const mockResponse: FlowResponse = {}; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + await flowsApi.startFlow("id", "class", "desc"); + + // Verify the call signature matches FlowRequest/FlowResponse types + const callArgs = mockApi.makeRequest.mock.calls[0]; + const request = callArgs[1]; + + // These properties should match FlowRequest interface + expect(request).toHaveProperty("operation"); + expect(request).toHaveProperty("flow-id"); + expect(request).toHaveProperty("blueprint-name"); + expect(request).toHaveProperty("description"); + }); + }); + + describe("stopFlow", () => { + it("should call makeRequest with correct types and parameters", async () => { + const mockResponse: FlowResponse = { + flow: "stopped", + description: "Flow stopped successfully", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.stopFlow("test-flow-id"); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "stop-flow", + "flow-id": "test-flow-id", + }, + 30000, + ); + expect(result).toEqual(mockResponse); + }); + + it("should use FlowRequest and FlowResponse types", async () => { + const mockResponse: FlowResponse = {}; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + await flowsApi.stopFlow("id"); + + // Verify the call signature matches FlowRequest/FlowResponse types + const callArgs = mockApi.makeRequest.mock.calls[0]; + const request = callArgs[1]; + + // These properties should match FlowRequest interface + expect(request).toHaveProperty("operation"); + expect(request).toHaveProperty("flow-id"); + }); + }); + + describe("getFlows", () => { + it("should return flow-ids array from response", async () => { + const mockResponse: FlowResponse = { + "flow-ids": ["flow1", "flow2", "flow3"], + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlows(); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "list-flows", + }, + 60000, + ); + expect(result).toEqual(["flow1", "flow2", "flow3"]); + }); + + it("should return empty array when flow-ids is undefined", async () => { + const mockResponse: FlowResponse = {}; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlows(); + + expect(result).toEqual([]); + }); + + it("should handle response with flow-ids property correctly", async () => { + // This test ensures we're accessing the hyphenated property name correctly + const mockResponse = { + "flow-ids": ["test-flow"], + "other-property": "should-be-ignored", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlows(); + + expect(result).toEqual(["test-flow"]); + }); + }); + + describe("getFlowBlueprints", () => { + it("should return blueprint-names array from response", async () => { + const mockResponse: FlowResponse = { + "blueprint-names": ["class1", "class2"], + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlowBlueprints(); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "list-blueprints", + }, + 60000, + ); + expect(result).toEqual(["class1", "class2"]); + }); + + it("should handle response with blueprint-names property correctly", async () => { + // This test ensures we're accessing the hyphenated property name correctly + const mockResponse = { + "blueprint-names": ["test-class"], + "other-property": "should-be-ignored", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlowBlueprints(); + + expect(result).toEqual(["test-class"]); + }); + }); + + describe("getFlow", () => { + it("should call makeRequest with correct parameters and parse JSON", async () => { + const flowDefinition = { type: "flow", config: "test" }; + const mockResponse: FlowResponse = { + flow: JSON.stringify(flowDefinition), // Must be valid JSON string + description: "Test flow", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlow("test-flow-id"); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "get-flow", + "flow-id": "test-flow-id", + }, + 60000, + ); + expect(result).toEqual(flowDefinition); // Result should be parsed JSON + }); + }); + + describe("getFlowBlueprint", () => { + it("should call makeRequest with correct parameters and parse JSON", async () => { + const blueprintDefinition = { type: "blueprint", name: "test-blueprint" }; + const mockResponse: FlowResponse = { + "blueprint-definition": JSON.stringify(blueprintDefinition), // Must be valid JSON string + description: "Test blueprint", + }; + mockApi.makeRequest.mockResolvedValue(mockResponse); + + const result = await flowsApi.getFlowBlueprint("test-class"); + + expect(mockApi.makeRequest).toHaveBeenCalledWith( + "flow", + { + operation: "get-blueprint", + "blueprint-name": "test-class", + }, + 60000, + ); + expect(result).toEqual(blueprintDefinition); // Result should be parsed JSON + }); + }); +}); diff --git a/ts/packages/client/src/__tests__/messages.test.ts b/ts/packages/client/src/__tests__/messages.test.ts new file mode 100644 index 00000000..65d96c9e --- /dev/null +++ b/ts/packages/client/src/__tests__/messages.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect } from "vitest"; +import type { + RequestMessage, + ApiResponse, + TextCompletionRequest, + TextCompletionResponse, + GraphRagRequest, + GraphRagResponse, + AgentRequest, + AgentResponse, + EmbeddingsRequest, + EmbeddingsResponse, + GraphEmbeddingsQueryRequest, + GraphEmbeddingsQueryResponse, + TriplesQueryRequest, + LoadDocumentRequest, + LoadTextRequest, + LibraryRequest, + LibraryResponse, + FlowRequest, + FlowResponse, + DocumentMetadata, + ProcessingMetadata, +} from "../models/messages"; + +describe("Message Types", () => { + describe("RequestMessage", () => { + it("should have correct structure", () => { + const message: RequestMessage = { + id: "test-id", + service: "test-service", + request: { test: "data" }, + }; + + expect(message.id).toBe("test-id"); + expect(message.service).toBe("test-service"); + expect(message.request).toEqual({ test: "data" }); + }); + }); + + describe("ApiResponse", () => { + it("should have correct structure", () => { + const response: ApiResponse = { + id: "test-id", + response: { result: "success" }, + }; + + expect(response.id).toBe("test-id"); + expect(response.response).toEqual({ result: "success" }); + }); + }); + + describe("TextCompletionRequest", () => { + it("should have correct structure", () => { + const request: TextCompletionRequest = { + system: "You are a helpful assistant", + prompt: "Hello, world!", + }; + + expect(request.system).toBe("You are a helpful assistant"); + expect(request.prompt).toBe("Hello, world!"); + }); + }); + + describe("TextCompletionResponse", () => { + it("should have correct structure", () => { + const response: TextCompletionResponse = { + response: "Hello! How can I help you today?", + }; + + expect(response.response).toBe("Hello! How can I help you today?"); + }); + }); + + describe("GraphRagRequest", () => { + it("should have correct structure with required query", () => { + const request: GraphRagRequest = { + query: "What is the capital of France?", + }; + + expect(request.query).toBe("What is the capital of France?"); + }); + + it("should have correct structure with optional parameters", () => { + const request: GraphRagRequest = { + query: "What is the capital of France?", + "entity-limit": 100, + "triple-limit": 50, + "max-subgraph-size": 2000, + "max-path-length": 3, + }; + + expect(request.query).toBe("What is the capital of France?"); + expect(request["entity-limit"]).toBe(100); + expect(request["triple-limit"]).toBe(50); + expect(request["max-subgraph-size"]).toBe(2000); + expect(request["max-path-length"]).toBe(3); + }); + }); + + describe("GraphRagResponse", () => { + it("should have correct structure", () => { + const response: GraphRagResponse = { + response: "The capital of France is Paris.", + }; + + expect(response.response).toBe("The capital of France is Paris."); + }); + }); + + describe("AgentRequest", () => { + it("should have correct structure", () => { + const request: AgentRequest = { + question: "What is the weather like today?", + }; + + expect(request.question).toBe("What is the weather like today?"); + }); + }); + + describe("AgentResponse", () => { + it("should have correct structure with all fields", () => { + const response: AgentResponse = { + thought: "I need to check the weather", + observation: "Weather API shows sunny conditions", + answer: "It is sunny today", + error: undefined, + }; + + expect(response.thought).toBe("I need to check the weather"); + expect(response.observation).toBe("Weather API shows sunny conditions"); + expect(response.answer).toBe("It is sunny today"); + expect(response.error).toBeUndefined(); + }); + + it("should handle error response", () => { + const response: AgentResponse = { + error: { type: "agent-error", message: "Weather service unavailable" }, + }; + + expect(response.error?.message).toBe("Weather service unavailable"); + expect(response.error?.type).toBe("agent-error"); + }); + }); + + describe("EmbeddingsRequest", () => { + it("should have correct structure", () => { + const request: EmbeddingsRequest = { + texts: ["This is a test sentence for embedding", "Another text"], + }; + + expect(request.texts).toEqual(["This is a test sentence for embedding", "Another text"]); + }); + }); + + describe("EmbeddingsResponse", () => { + it("should have correct structure", () => { + // vectors[text_index][dimension_index] - one vector per input text + const response: EmbeddingsResponse = { + vectors: [ + [0.1, 0.2, 0.3], // First text's vector + [0.4, 0.5, 0.6], // Second text's vector + ], + }; + + expect(response.vectors).toEqual([ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ]); + }); + }); + + describe("GraphEmbeddingsQueryRequest", () => { + it("should have correct structure", () => { + const request: GraphEmbeddingsQueryRequest = { + vector: [0.1, 0.2, 0.3], + limit: 10, + }; + + expect(request.vector).toEqual([0.1, 0.2, 0.3]); + expect(request.limit).toBe(10); + }); + }); + + describe("GraphEmbeddingsQueryResponse", () => { + it("should have correct structure", () => { + const response: GraphEmbeddingsQueryResponse = { + entities: [ + { entity: { t: "i", i: "http://example.org/entity1" }, score: 0.95 }, + { entity: { t: "i", i: "http://example.org/entity2" }, score: 0.87 }, + ], + }; + + expect(response.entities).toHaveLength(2); + expect(response.entities[0].score).toBe(0.95); + expect(response.entities[0].entity?.t).toBe("i"); + expect((response.entities[0].entity as { t: "i"; i: string }).i).toBe("http://example.org/entity1"); + expect(response.entities[1].score).toBe(0.87); + }); + }); + + describe("TriplesQueryRequest", () => { + it("should have correct structure with all fields", () => { + const request: TriplesQueryRequest = { + s: { t: "i", i: "http://example.org/subject" }, + p: { t: "i", i: "http://example.org/predicate" }, + o: { t: "l", v: "object value" }, + limit: 100, + }; + + expect((request.s as { t: "i"; i: string }).i).toBe("http://example.org/subject"); + expect((request.p as { t: "i"; i: string }).i).toBe("http://example.org/predicate"); + expect((request.o as { t: "l"; v: string }).v).toBe("object value"); + expect(request.limit).toBe(100); + }); + + it("should handle optional fields", () => { + const request: TriplesQueryRequest = { + limit: 50, + }; + + expect(request.s).toBeUndefined(); + expect(request.p).toBeUndefined(); + expect(request.o).toBeUndefined(); + expect(request.limit).toBe(50); + }); + }); + + describe("LoadDocumentRequest", () => { + it("should have correct structure", () => { + const request: LoadDocumentRequest = { + id: "doc-123", + data: "base64-encoded-document-data", + metadata: [ + { + s: { t: "i", i: "http://example.org/doc-123" }, + p: { t: "i", i: "http://example.org/title" }, + o: { t: "l", v: "Test Document" }, + }, + ], + }; + + expect(request.id).toBe("doc-123"); + expect(request.data).toBe("base64-encoded-document-data"); + expect(request.metadata).toHaveLength(1); + }); + }); + + describe("LoadTextRequest", () => { + it("should have correct structure", () => { + const request: LoadTextRequest = { + id: "text-123", + text: "This is some text to load", + charset: "utf-8", + metadata: [], + }; + + expect(request.id).toBe("text-123"); + expect(request.text).toBe("This is some text to load"); + expect(request.charset).toBe("utf-8"); + expect(request.metadata).toEqual([]); + }); + }); + + describe("DocumentMetadata", () => { + it("should have correct structure", () => { + const metadata: DocumentMetadata = { + id: "doc-123", + time: 1640995200000, + kind: "pdf", + title: "Test Document", + comments: "A test document", + metadata: [], + user: "test-user", + tags: ["test", "document"], + }; + + expect(metadata.id).toBe("doc-123"); + expect(metadata.time).toBe(1640995200000); + expect(metadata.kind).toBe("pdf"); + expect(metadata.title).toBe("Test Document"); + expect(metadata.comments).toBe("A test document"); + expect(metadata.user).toBe("test-user"); + expect(metadata.tags).toEqual(["test", "document"]); + }); + }); + + describe("ProcessingMetadata", () => { + it("should have correct structure", () => { + const metadata: ProcessingMetadata = { + id: "proc-123", + "document-id": "doc-123", + time: 1640995200000, + flow: "default-flow", + user: "test-user", + collection: "test-collection", + tags: ["processing", "test"], + }; + + expect(metadata.id).toBe("proc-123"); + expect(metadata["document-id"]).toBe("doc-123"); + expect(metadata.time).toBe(1640995200000); + expect(metadata.flow).toBe("default-flow"); + expect(metadata.user).toBe("test-user"); + expect(metadata.collection).toBe("test-collection"); + expect(metadata.tags).toEqual(["processing", "test"]); + }); + }); + + describe("LibraryRequest", () => { + it("should have correct structure", () => { + const request: LibraryRequest = { + operation: "list_documents", + user: "test-user", + collection: "test-collection", + }; + + expect(request.operation).toBe("list_documents"); + expect(request.user).toBe("test-user"); + expect(request.collection).toBe("test-collection"); + }); + }); + + describe("LibraryResponse", () => { + it("should have correct structure", () => { + const response: LibraryResponse = { + error: new Error(), + "document-metadatas": [ + { + id: "doc-1", + title: "Document 1", + time: 1640995200000, + }, + ], + }; + + expect(response.error).toBeInstanceOf(Error); + expect(response["document-metadatas"]).toHaveLength(1); + expect(response["document-metadatas"]![0].id).toBe("doc-1"); + }); + }); + + describe("FlowRequest", () => { + it("should have correct structure", () => { + const request: FlowRequest = { + operation: "get_flow", + "flow-id": "default-flow", + }; + + expect(request.operation).toBe("get_flow"); + expect(request["flow-id"]).toBe("default-flow"); + }); + }); + + describe("FlowResponse", () => { + it("should have correct structure", () => { + const response: FlowResponse = { + "flow-ids": ["flow-1", "flow-2"], + flow: "flow-definition", + description: "A test flow", + error: undefined, + }; + + expect(response["flow-ids"]).toEqual(["flow-1", "flow-2"]); + expect(response.flow).toBe("flow-definition"); + expect(response.description).toBe("A test flow"); + expect(response.error).toBeUndefined(); + }); + }); +}); diff --git a/ts/packages/client/src/__tests__/service-call-multi.test.ts b/ts/packages/client/src/__tests__/service-call-multi.test.ts new file mode 100644 index 00000000..c414c574 --- /dev/null +++ b/ts/packages/client/src/__tests__/service-call-multi.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ServiceCallMulti } from "../socket/service-call-multi"; + +// Mock WebSocket constants +vi.stubGlobal("WebSocket", { + OPEN: 1, + CONNECTING: 0, + CLOSING: 2, + CLOSED: 3, +}); + +// Mock Socket interface +const mockSocket = { + inflight: {} as Record<string, unknown>, + ws: { + send: vi.fn(), + readyState: 1, // WebSocket.OPEN + }, + reopen: vi.fn(), +}; + +// Mock setTimeout and clearTimeout +const mockSetTimeout = vi.fn(); +const mockClearTimeout = vi.fn(); + +vi.stubGlobal("setTimeout", mockSetTimeout); +vi.stubGlobal("clearTimeout", mockClearTimeout); + +describe("ServiceCallMulti", () => { + let mockSuccess: ReturnType<typeof vi.fn>; + let mockError: ReturnType<typeof vi.fn>; + let mockReceiver: ReturnType<typeof vi.fn>; + let serviceCallMulti: ServiceCallMulti; + + beforeEach(() => { + vi.clearAllMocks(); + mockSuccess = vi.fn(); + mockError = vi.fn(); + mockReceiver = vi.fn(); + mockSocket.inflight = {} as Record<string, unknown>; + mockSocket.ws = { + send: vi.fn(), + readyState: 1, // WebSocket.OPEN + }; + mockSocket.reopen.mockClear(); + + serviceCallMulti = new ServiceCallMulti( + "test-mid", + { id: "test-id", service: "test-service", request: { test: "data" } }, + mockSuccess, + mockError, + 5000, // 5 second timeout + 3, // 3 retries + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSocket as any, + mockReceiver, + ); + }); + + it("should initialize with correct properties", () => { + expect(serviceCallMulti.mid).toBe("test-mid"); + expect(serviceCallMulti.timeout).toBe(5000); + expect(serviceCallMulti.retries).toBe(3); + expect(serviceCallMulti.complete).toBe(false); + expect(serviceCallMulti.socket).toBe(mockSocket); + expect(serviceCallMulti.receiver).toBe(mockReceiver); + }); + + it("should register itself in socket inflight when started", () => { + serviceCallMulti.start(); + + expect(mockSocket.inflight["test-mid"]).toBe(serviceCallMulti); + }); + + it("should send message on successful attempt", () => { + serviceCallMulti.start(); + + expect(mockSocket.ws.send).toHaveBeenCalledWith( + JSON.stringify({ + id: "test-id", + service: "test-service", + request: { test: "data" }, + }), + ); + expect(mockSetTimeout).toHaveBeenCalled(); + }); + + it("should handle response when receiver returns true (completion)", () => { + mockReceiver.mockReturnValue(true); // Signal completion + const response = { result: "success" }; + + serviceCallMulti.start(); + serviceCallMulti.onReceived(response); + + expect(mockReceiver).toHaveBeenCalledWith(response); + expect(serviceCallMulti.complete).toBe(true); + expect(mockSuccess).toHaveBeenCalledWith(response); + expect(mockClearTimeout).toHaveBeenCalled(); + expect(mockSocket.inflight["test-mid"]).toBeUndefined(); + }); + + it("should handle response when receiver returns false (continue)", () => { + mockReceiver.mockReturnValue(false); // Signal to continue + const response = { partial: "data" }; + + serviceCallMulti.start(); + serviceCallMulti.onReceived(response); + + expect(mockReceiver).toHaveBeenCalledWith(response); + expect(serviceCallMulti.complete).toBe(false); + expect(mockSuccess).not.toHaveBeenCalled(); + expect(mockClearTimeout).not.toHaveBeenCalled(); + expect(mockSocket.inflight["test-mid"]).toBe(serviceCallMulti); + }); + + it("should handle timeout and retry", () => { + serviceCallMulti.start(); + + // Initial retries should be 3, but start() calls attempt() which decrements to 2 + expect(serviceCallMulti.retries).toBe(2); + + // Simulate timeout + serviceCallMulti.onTimeout(); + + expect(mockClearTimeout).toHaveBeenCalled(); + expect(serviceCallMulti.retries).toBe(1); // Should decrement from 2 to 1 + }); + + it("should exhaust retries and call error callback", () => { + // Set retries to 0 to force immediate failure + serviceCallMulti.retries = 0; + + serviceCallMulti.start(); + + expect(mockError).toHaveBeenCalledWith("Ran out of retries"); + expect(mockSocket.inflight["test-mid"]).toBeUndefined(); + }); + + it("should handle WebSocket send failure", () => { + mockSocket.ws.send.mockImplementation(() => { + throw new Error("Connection failed"); + }); + + serviceCallMulti.start(); + + expect(mockSocket.reopen).toHaveBeenCalled(); + + // With exponential backoff, the delay should be calculated as: + // SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - retries) + random + // Since retries is decremented to 2 after start(), it's 3 - 2 = 1 + // So base delay is 2000 * 2^1 = 4000, plus random up to 1000 + // The delay should be between 4000 and 5000ms (capped at 30000) + const callArgs = mockSetTimeout.mock.calls[0]; + expect(callArgs[0]).toEqual(expect.any(Function)); + expect(callArgs[1]).toBeGreaterThanOrEqual(4000); + expect(callArgs[1]).toBeLessThanOrEqual(5000); + }); + + it("should handle missing WebSocket connection", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockSocket as any).ws = null; + + serviceCallMulti.start(); + + // Should trigger reopen and schedule with exponential backoff + expect(mockSocket.reopen).toHaveBeenCalled(); + + // Same calculation as above - base delay 4000ms + random up to 1000ms + const callArgs = mockSetTimeout.mock.calls[0]; + expect(callArgs[0]).toEqual(expect.any(Function)); + expect(callArgs[1]).toBeGreaterThanOrEqual(4000); + expect(callArgs[1]).toBeLessThanOrEqual(5000); + }); + + it("should not process response if already complete", () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + serviceCallMulti.complete = true; + serviceCallMulti.onReceived({ result: "test" }); + + expect(consoleSpy).toHaveBeenCalledWith( + "test-mid", + "should not happen, request is already complete", + ); + + consoleSpy.mockRestore(); + }); + + it("should not timeout if already complete", () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + serviceCallMulti.complete = true; + serviceCallMulti.onTimeout(); + + expect(consoleSpy).toHaveBeenCalledWith( + "test-mid", + "timeout should not happen, request is already complete", + ); + + consoleSpy.mockRestore(); + }); + + it("should not attempt if already complete", () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + serviceCallMulti.complete = true; + serviceCallMulti.attempt(); + + expect(consoleSpy).toHaveBeenCalledWith( + "test-mid", + "attempt should not be called, request is already complete", + ); + + consoleSpy.mockRestore(); + }); + + it("should handle streaming responses correctly", () => { + mockReceiver + .mockReturnValueOnce(false) // First response - continue + .mockReturnValueOnce(false) // Second response - continue + .mockReturnValueOnce(true); // Third response - complete + + serviceCallMulti.start(); + + // First response + serviceCallMulti.onReceived({ chunk: 1 }); + expect(serviceCallMulti.complete).toBe(false); + expect(mockSuccess).not.toHaveBeenCalled(); + + // Second response + serviceCallMulti.onReceived({ chunk: 2 }); + expect(serviceCallMulti.complete).toBe(false); + expect(mockSuccess).not.toHaveBeenCalled(); + + // Third response (final) + serviceCallMulti.onReceived({ chunk: 3, final: true }); + expect(serviceCallMulti.complete).toBe(true); + expect(mockSuccess).toHaveBeenCalledWith({ chunk: 3, final: true }); + }); + + it("should handle receiver function errors gracefully", () => { + mockReceiver.mockImplementation(() => { + throw new Error("Receiver error"); + }); + + serviceCallMulti.start(); + + expect(() => { + serviceCallMulti.onReceived({ test: "data" }); + }).toThrow("Receiver error"); + }); + + it("should handle multiple timeout scenarios", () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + serviceCallMulti.start(); + + // After start, retries should be 2 (decremented from 3) + expect(serviceCallMulti.retries).toBe(2); + + // First timeout + serviceCallMulti.onTimeout(); + expect(serviceCallMulti.retries).toBe(1); + + // Second timeout + serviceCallMulti.onTimeout(); + expect(serviceCallMulti.retries).toBe(0); + + consoleSpy.mockRestore(); + }); + + it("should clean up properly when receiver signals completion", () => { + mockReceiver.mockReturnValue(true); + + serviceCallMulti.start(); + + const response = { final: true }; + serviceCallMulti.onReceived(response); + + expect(serviceCallMulti.complete).toBe(true); + expect(mockClearTimeout).toHaveBeenCalled(); + expect(mockSocket.inflight["test-mid"]).toBeUndefined(); + expect(mockSuccess).toHaveBeenCalledWith(response); + }); +}); diff --git a/ts/packages/client/src/__tests__/service-call.test.ts b/ts/packages/client/src/__tests__/service-call.test.ts new file mode 100644 index 00000000..acd72111 --- /dev/null +++ b/ts/packages/client/src/__tests__/service-call.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ServiceCall } from "../socket/service-call"; + +// Mock WebSocket constants +vi.stubGlobal("WebSocket", { + OPEN: 1, + CONNECTING: 0, + CLOSING: 2, + CLOSED: 3, +}); + +// Mock Socket interface +const mockSocket = { + inflight: {} as Record<string, unknown>, + ws: { + send: vi.fn(), + readyState: 1, // WebSocket.OPEN + }, + reopen: vi.fn(), +}; + +// Mock setTimeout and clearTimeout +const mockSetTimeout = vi.fn(); +const mockClearTimeout = vi.fn(); + +vi.stubGlobal("setTimeout", mockSetTimeout); +vi.stubGlobal("clearTimeout", mockClearTimeout); + +describe("ServiceCall", () => { + let mockSuccess: ReturnType<typeof vi.fn>; + let mockError: ReturnType<typeof vi.fn>; + let serviceCall: ServiceCall; + + beforeEach(() => { + vi.clearAllMocks(); + mockSuccess = vi.fn(); + mockError = vi.fn(); + mockSocket.inflight = {} as Record<string, unknown>; + mockSocket.ws = { + send: vi.fn(), + readyState: 1, // WebSocket.OPEN + }; + mockSocket.reopen.mockClear(); + + serviceCall = new ServiceCall( + "test-mid", + { id: "test-id", service: "test-service", request: { test: "data" } }, + mockSuccess, + mockError, + 5000, // 5 second timeout + 3, // 3 retries + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSocket as any, + ); + }); + + it("should initialize with correct properties", () => { + expect(serviceCall.mid).toBe("test-mid"); + expect(serviceCall.timeout).toBe(5000); + expect(serviceCall.retries).toBe(3); + expect(serviceCall.complete).toBe(false); + expect(serviceCall.socket).toBe(mockSocket); + }); + + it("should register itself in socket inflight when started", () => { + serviceCall.start(); + + expect(mockSocket.inflight["test-mid"]).toBe(serviceCall); + }); + + it("should send message on successful attempt", () => { + serviceCall.start(); + + expect(mockSocket.ws.send).toHaveBeenCalledWith( + JSON.stringify({ + id: "test-id", + service: "test-service", + request: { test: "data" }, + }), + ); + expect(mockSetTimeout).toHaveBeenCalled(); + }); + + it("should handle successful response", () => { + const responseData = { result: "success" }; + const message = { response: responseData }; + + serviceCall.start(); + serviceCall.onReceived(message); + + expect(serviceCall.complete).toBe(true); + expect(mockSuccess).toHaveBeenCalledWith(responseData); + expect(mockClearTimeout).toHaveBeenCalled(); + expect(mockSocket.inflight["test-mid"]).toBeUndefined(); + }); + + it("should handle timeout and retry", () => { + serviceCall.start(); + + // Initial retries should be 3, but start() calls attempt() which decrements to 2 + expect(serviceCall.retries).toBe(2); + + // Simulate timeout + serviceCall.onTimeout(); + + expect(mockClearTimeout).toHaveBeenCalled(); + expect(serviceCall.retries).toBe(1); // Should decrement from 2 to 1 + }); + + it("should exhaust retries and call error callback", () => { + // Set retries to 0 to force immediate failure + serviceCall.retries = 0; + + serviceCall.start(); + + expect(mockError).toHaveBeenCalledWith("Ran out of retries"); + expect(mockSocket.inflight["test-mid"]).toBeUndefined(); + }); + + it("should handle WebSocket send failure", () => { + mockSocket.ws.send.mockImplementation(() => { + throw new Error("Connection failed"); + }); + + serviceCall.start(); + + // Should NOT call reopen anymore - BaseApi handles reconnection + expect(mockSocket.reopen).not.toHaveBeenCalled(); + + // With exponential backoff, the delay should be calculated as: + // SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - retries) + random + // Since retries is decremented to 2 after start(), it's 3 - 2 = 1 + // So base delay is 2000 * 2^1 = 4000, plus random up to 1000 + // The delay should be between 4000 and 5000ms (capped at 30000) + const callArgs = mockSetTimeout.mock.calls[0]; + expect(callArgs[0]).toEqual(expect.any(Function)); + expect(callArgs[1]).toBeGreaterThanOrEqual(4000); + expect(callArgs[1]).toBeLessThanOrEqual(5000); + }); + + it("should handle missing WebSocket connection", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockSocket as any).ws = null; + + serviceCall.start(); + + // Should NOT trigger reopen - just wait for BaseApi to reconnect + expect(mockSocket.reopen).not.toHaveBeenCalled(); + + // Same calculation as above - base delay 4000ms + random up to 1000ms + const callArgs = mockSetTimeout.mock.calls[0]; + expect(callArgs[0]).toEqual(expect.any(Function)); + expect(callArgs[1]).toBeGreaterThanOrEqual(4000); + expect(callArgs[1]).toBeLessThanOrEqual(5000); + }); + + it("should not process response if already complete", () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + serviceCall.complete = true; + serviceCall.onReceived({ result: "test" }); + + expect(consoleSpy).toHaveBeenCalledWith( + "test-mid", + "should not happen, request is already complete", + ); + + consoleSpy.mockRestore(); + }); + + it("should not timeout if already complete", () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + serviceCall.complete = true; + serviceCall.onTimeout(); + + expect(consoleSpy).toHaveBeenCalledWith( + "test-mid", + "timeout should not happen, request is already complete", + ); + + consoleSpy.mockRestore(); + }); + + it("should not attempt if already complete", () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + serviceCall.complete = true; + serviceCall.attempt(); + + expect(consoleSpy).toHaveBeenCalledWith( + "test-mid", + "attempt should not be called, request is already complete", + ); + + consoleSpy.mockRestore(); + }); + + it("should handle multiple retries correctly", () => { + mockSocket.ws.send.mockImplementation(() => { + throw new Error("Connection failed"); + }); + + serviceCall.start(); + + // Should have decremented retries and scheduled a retry + expect(serviceCall.retries).toBe(2); + // Should NOT call reopen - BaseApi handles reconnection + expect(mockSocket.reopen).not.toHaveBeenCalled(); + }); + + it("should clean up properly on successful response", () => { + serviceCall.start(); + + const responseData = { success: true }; + const message = { response: responseData }; + serviceCall.onReceived(message); + + expect(serviceCall.complete).toBe(true); + expect(mockClearTimeout).toHaveBeenCalled(); + expect(mockSocket.inflight["test-mid"]).toBeUndefined(); + expect(mockSuccess).toHaveBeenCalledWith(responseData); + }); + + it("should handle edge case of negative retries", () => { + serviceCall.retries = -1; + + serviceCall.attempt(); + + expect(mockError).toHaveBeenCalledWith("Ran out of retries"); + }); + + it("should bind timeout callbacks correctly", () => { + serviceCall.start(); + + // Verify that setTimeout was called with a bound function + expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 5000); + }); +}); diff --git a/ts/packages/client/src/index.ts b/ts/packages/client/src/index.ts new file mode 100644 index 00000000..3862a873 --- /dev/null +++ b/ts/packages/client/src/index.ts @@ -0,0 +1,13 @@ +// @trustgraph/client +// TrustGraph TypeScript Client + +// Export models (data types) +export * from "./models/Triple.js"; +export * from "./models/messages.js"; +export * from "./models/namespaces.js"; + +// Export socket client +export * from "./socket/trustgraph-socket.js"; + +// Export WebSocket adapter (isomorphic helpers and types) +export * from "./socket/websocket-adapter.js"; diff --git a/ts/packages/client/src/models/Triple.ts b/ts/packages/client/src/models/Triple.ts new file mode 100644 index 00000000..c9d7ca4c --- /dev/null +++ b/ts/packages/client/src/models/Triple.ts @@ -0,0 +1,40 @@ +// Term type discriminators matching the wire format +// i = IRI, b = BLANK node, l = LITERAL, t = TRIPLE (reified) +export type TermType = "i" | "b" | "l" | "t"; + +export interface IriTerm { + t: "i"; + i: string; +} + +export interface BlankTerm { + t: "b"; + d: string; +} + +export interface LiteralTerm { + t: "l"; + v: string; + dt?: string; // datatype + ln?: string; // language +} + +export interface TripleTerm { + t: "t"; + tr?: Triple; +} + +export type Term = IriTerm | BlankTerm | LiteralTerm | TripleTerm; + +export interface PartialTriple { + s?: Term; + p?: Term; + o?: Term; +} + +export interface Triple { + s: Term; + p: Term; + o: Term; + g?: string; // graph (renamed from direc to match backend) +} diff --git a/ts/packages/client/src/models/messages.ts b/ts/packages/client/src/models/messages.ts new file mode 100644 index 00000000..a5687aa8 --- /dev/null +++ b/ts/packages/client/src/models/messages.ts @@ -0,0 +1,496 @@ +import { Triple, Term } from "./Triple.js"; + +// FIXME: Better types? +export type Request = object; +export type Response = object; +export type Error = object | string; + +export interface ResponseError { + type?: string; + message: string; +} + +export interface RequestMessage { + id: string; + service: string; + request: Request; + flow?: string; +} + +export interface ApiResponse { + id: string; + response: Response; +} + +export interface Metadata { + id?: string; + metadata?: Triple[]; + user?: string; + collection?: string; +} + +export interface EntityEmbeddings { + entity?: Term; + vectors?: number[][]; +} + +export interface GraphEmbeddings { + metadata?: Metadata; + entities?: EntityEmbeddings[]; +} + +export interface TextCompletionRequest { + system: string; + prompt: string; + streaming?: boolean; +} + +export interface TextCompletionResponse { + response: string; + // Streaming fields + end_of_stream?: boolean; + error?: { + message: string; + type?: string; + }; + // Token usage (appears in final message) + in_token?: number; + out_token?: number; + model?: string; +} + +export interface GraphRagRequest { + query: string; + user?: string; + collection?: string; + "entity-limit"?: number; // Default: 50 + "triple-limit"?: number; // Default: 30 + "max-subgraph-size"?: number; // Default: 1000 + "max-path-length"?: number; // Default: 2 + streaming?: boolean; +} + +export interface GraphRagResponse { + response: string; + // Streaming fields + chunk?: string; + end_of_stream?: boolean; + error?: { + message: string; + type?: string; + }; + // Token usage (appears in final message) + in_token?: number; + out_token?: number; + model?: string; + // Explainability fields + message_type?: "chunk" | "explain"; + explain_id?: string; + explain_graph?: string; // Named graph where explain data is stored (e.g., urn:graph:retrieval) + end_of_session?: boolean; +} + +export interface DocumentRagRequest { + query: string; + user?: string; + collection?: string; + "doc-limit"?: number; // Default: 20 + streaming?: boolean; +} + +export interface DocumentRagResponse { + response: string; + // Streaming fields + chunk?: string; + end_of_stream?: boolean; + error?: { + message: string; + type?: string; + }; + // Token usage (appears in final message) + in_token?: number; + out_token?: number; + model?: string; + // Explainability fields + message_type?: "chunk" | "explain"; + explain_id?: string; + explain_graph?: string; + end_of_session?: boolean; +} + +export interface AgentRequest { + question: string; + user?: string; + streaming?: boolean; +} + +export interface AgentResponse { + // Streaming response format (new protocol) + chunk_type?: "thought" | "action" | "observation" | "answer" | "final-answer" | "explain" | "error"; + content?: string; + end_of_message?: boolean; + end_of_dialog?: boolean; + + // Legacy fields for backward compatibility with non-streaming + thought?: string; + observation?: string; + answer?: string; + error?: ResponseError; + + // Token usage (appears in final message) + in_token?: number; + out_token?: number; + model?: string; + + // Explainability fields + message_type?: "chunk" | "explain"; + explain_id?: string; + explain_graph?: string; +} + +export interface EmbeddingsRequest { + texts: string[]; +} + +export interface EmbeddingsResponse { + vectors: number[][]; // One vector per input text +} + +export interface GraphEmbeddingsQueryRequest { + vector: number[]; // Single query vector + limit: number; + user?: string; + collection?: string; +} + +export interface EntityMatch { + entity: Term | null; + score: number; +} + +export interface GraphEmbeddingsQueryResponse { + entities: EntityMatch[]; +} + +export interface TriplesQueryRequest { + s?: Term; + p?: Term; + o?: Term; + g?: string; // Named graph URI filter (plain string, not Term) + limit: number; + user?: string; + collection?: string; +} + +export interface TriplesQueryResponse { + response: Triple[]; +} + +export interface RowsQueryRequest { + query: string; + user?: string; + collection?: string; + variables?: Record<string, unknown>; + operation_name?: string; +} + +export interface RowsQueryResponse { + data?: Record<string, unknown>; + errors?: Record<string, unknown>[]; + extensions?: Record<string, unknown>; + values?: unknown[]; +} + +export interface NlpQueryRequest { + question: string; + max_results?: number; +} + +export interface NlpQueryResponse { + graphql_query?: string; + variables?: Record<string, unknown>; + detected_schemas?: Record<string, unknown>[]; + confidence?: number; +} + +export interface StructuredQueryRequest { + question: string; + user?: string; + collection?: string; +} + +export interface StructuredQueryResponse { + data?: Record<string, unknown>; + errors?: Record<string, unknown>[]; +} + +export interface RowEmbeddingsQueryRequest { + vector: number[]; // Single query vector + schema_name: string; + user?: string; + collection?: string; + index_name?: string; + limit?: number; +} + +export interface RowEmbeddingsMatch { + index_name: string; + index_value: string[]; + text: string; + score: number; +} + +export interface RowEmbeddingsQueryResponse { + matches?: RowEmbeddingsMatch[]; + error?: { + message: string; + type?: string; + }; +} + +export interface LoadDocumentRequest { + id?: string; + data: string; + metadata?: Triple[]; +} + +export type LoadDocumentResponse = void; + +export interface LoadTextRequest { + id?: string; + text: string; + charset?: string; + metadata?: Triple[]; +} + +export type LoadTextResponse = void; + +export interface DocumentMetadata { + id?: string; + time?: number; + kind?: string; + title?: string; + comments?: string; + metadata?: Triple[]; + user?: string; + tags?: string[]; + "document-type"?: string; +} + +export interface ProcessingMetadata { + id?: string; + "document-id"?: string; + time?: number; + flow?: string; + user?: string; + collection?: string; + tags?: string[]; +} + +export interface LibraryRequest { + operation: string; + "document-id"?: string; + "processing-id"?: string; + "document-metadata"?: DocumentMetadata; + "processing-metadata"?: ProcessingMetadata; + content?: string; + user?: string; + collection?: string; + metadata?: Triple[]; + id?: string; + flow?: string; +} + +export interface LibraryResponse { + error: Error; + "document-metadata"?: DocumentMetadata; + content?: string; + "document-metadatas"?: DocumentMetadata[]; + "processing-metadata"?: ProcessingMetadata; +} + +export interface KnowledgeRequest { + operation: string; + user?: string; + id?: string; + flow?: string; + collection?: string; + triples?: Triple[]; + "graph-embeddings"?: GraphEmbeddings; +} + +export interface KnowledgeResponse { + error?: Error; + ids?: string[]; + eos?: boolean; + triples?: Triple[]; + "graph-embeddings"?: GraphEmbeddings; +} + +export interface FlowRequest { + operation: string; + "blueprint-name"?: string; + "blueprint-definition"?: string; + description?: string; + "flow-id"?: string; + parameters?: Record<string, unknown>; + user?: string; +} + +export interface FlowResponse { + "blueprint-names"?: string[]; + "flow-ids"?: string[]; + ids?: string[]; + flow?: string; + "blueprint-definition"?: string; + description?: string; + error?: + | { + message?: string; + } + | Error; +} + +export interface PromptRequest { + id: string; + terms: Record<string, unknown>; + streaming?: boolean; +} + +export interface PromptResponse { + text: string; + // Streaming fields + end_of_stream?: boolean; + error?: { + message: string; + type?: string; + }; + // Token usage (appears in final message) + in_token?: number; + out_token?: number; + model?: string; +} + +export type ConfigRequest = object; +export type ConfigResponse = object; + +// Chunked Upload Types + +export interface ChunkedUploadDocumentMetadata { + id: string; + time: number; + kind: string; + title: string; + comments?: string; + metadata?: Triple[]; + user: string; + collection?: string; + tags?: string[]; +} + +export interface BeginUploadRequest { + operation: "begin-upload"; + "document-metadata": ChunkedUploadDocumentMetadata; + "total-size": number; + "chunk-size"?: number; +} + +export interface BeginUploadResponse { + "upload-id": string; + "chunk-size": number; + "total-chunks": number; + error?: ResponseError; +} + +export interface UploadChunkRequest { + operation: "upload-chunk"; + "upload-id": string; + "chunk-index": number; + content: string; // base64-encoded + user: string; +} + +export interface UploadChunkResponse { + "upload-id": string; + "chunk-index": number; + "chunks-received": number; + "total-chunks": number; + "bytes-received": number; + "total-bytes": number; + error?: ResponseError; +} + +export interface CompleteUploadRequest { + operation: "complete-upload"; + "upload-id": string; + user: string; +} + +export interface CompleteUploadResponse { + "document-id": string; + "object-id": string; + error?: ResponseError; +} + +export interface GetUploadStatusRequest { + operation: "get-upload-status"; + "upload-id": string; + user: string; +} + +export interface GetUploadStatusResponse { + "upload-id": string; + "upload-state": "in-progress" | "completed" | "expired"; + "chunks-received": number; + "total-chunks": number; + "received-chunks": number[]; + "missing-chunks": number[]; + "bytes-received": number; + "total-bytes": number; + error?: ResponseError; +} + +export interface AbortUploadRequest { + operation: "abort-upload"; + "upload-id": string; + user: string; +} + +export interface AbortUploadResponse { + error?: ResponseError; +} + +export interface ListUploadsRequest { + operation: "list-uploads"; + user: string; +} + +export interface UploadSession { + "upload-id": string; + "document-id": string; + "document-metadata-json": string; + "total-size": number; + "chunk-size": number; + "total-chunks": number; + "chunks-received": number; + "created-at": string; +} + +export interface ListUploadsResponse { + "upload-sessions": UploadSession[]; + error?: ResponseError; +} + +export interface StreamDocumentRequest { + operation: "stream-document"; + "document-id": string; + "chunk-size"?: number; + user: string; +} + +export interface StreamDocumentResponse { + content: string; // base64-encoded chunk + "chunk-index": number; + "total-chunks": number; + error?: ResponseError; +} diff --git a/ts/packages/client/src/models/namespaces.ts b/ts/packages/client/src/models/namespaces.ts new file mode 100644 index 00000000..df75fc04 --- /dev/null +++ b/ts/packages/client/src/models/namespaces.ts @@ -0,0 +1,42 @@ +/** + * RDF namespace constants for TrustGraph + * Used for querying explainability data, provenance chains, and knowledge graph + */ + +// TrustGraph namespace +export const TG = "https://trustgraph.ai/ns/"; +export const TG_QUERY = TG + "query"; +export const TG_EDGE_COUNT = TG + "edgeCount"; +export const TG_SELECTED_EDGE = TG + "selectedEdge"; +export const TG_EDGE = TG + "edge"; +export const TG_REASONING = TG + "reasoning"; +export const TG_CONTENT = TG + "content"; +export const TG_REIFIES = TG + "reifies"; +export const TG_DOCUMENT = TG + "document"; + +// W3C PROV-O namespace +export const PROV = "http://www.w3.org/ns/prov#"; +export const PROV_STARTED_AT_TIME = PROV + "startedAtTime"; +export const PROV_WAS_DERIVED_FROM = PROV + "wasDerivedFrom"; +export const PROV_WAS_GENERATED_BY = PROV + "wasGeneratedBy"; +export const PROV_ACTIVITY = PROV + "Activity"; +export const PROV_ENTITY = PROV + "Entity"; + +// RDFS namespace +export const RDFS = "http://www.w3.org/2000/01/rdf-schema#"; +export const RDFS_LABEL = RDFS + "label"; + +// RDF namespace +export const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +export const RDF_TYPE = RDF + "type"; + +// Schema.org namespace (used in document metadata) +export const SCHEMA = "https://schema.org/"; +export const SCHEMA_NAME = SCHEMA + "name"; +export const SCHEMA_DESCRIPTION = SCHEMA + "description"; +export const SCHEMA_AUTHOR = SCHEMA + "author"; +export const SCHEMA_KEYWORDS = SCHEMA + "keywords"; + +// SKOS namespace +export const SKOS = "http://www.w3.org/2004/02/skos/core#"; +export const SKOS_DEFINITION = SKOS + "definition"; diff --git a/ts/packages/client/src/socket/service-call-multi.ts b/ts/packages/client/src/socket/service-call-multi.ts new file mode 100644 index 00000000..eb0d4668 --- /dev/null +++ b/ts/packages/client/src/socket/service-call-multi.ts @@ -0,0 +1,172 @@ +import { RequestMessage } from "../models/messages.js"; +import { WS_OPEN, WS_CONNECTING, type IsomorphicWebSocket } from "./websocket-adapter.js"; + +// Constant defining the delay before attempting to reconnect a WebSocket +// (2 seconds) +export const SOCKET_RECONNECTION_TIMEOUT = 2000; + +// Forward declare Socket type to avoid circular dependency +// Using a minimal interface that matches what BaseApi provides +interface Socket { + ws?: IsomorphicWebSocket; + inflight: { [key: string]: ServiceCallMulti }; + reopen: () => void; + getNextId?: () => string; + user?: string; +} + +export class ServiceCallMulti { + constructor( + mid: string, + msg: RequestMessage, + success: (resp: unknown) => void, + error: (err: object | string) => void, + timeout: number, + retries: number, + socket: Socket, + receiver: (resp: unknown) => boolean, + ) { + this.mid = mid; + this.msg = msg; + this.success = success; + this.error = error; + this.timeout = timeout; + this.retries = retries; + this.socket = socket; + this.complete = false; + this.receiver = receiver; + } + + mid: string; + msg: RequestMessage; + success: (resp: unknown) => void; + error: (err: object | string) => void; + receiver: (resp: unknown) => boolean; + timeoutId?: ReturnType<typeof setTimeout>; + timeout: number; + retries: number; + socket: Socket; + complete: boolean; + + start() { + this.socket.inflight[this.mid] = this; + this.attempt(); + } + + onReceived(resp: object) { + if (this.complete == true) + console.log(this.mid, "should not happen, request is already complete"); + + const fin = this.receiver(resp); + + if (fin) { + this.complete = true; + + // console.log("Received for", this.mid); + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + delete this.socket.inflight[this.mid]; + this.success(resp); + } + } + + /** + * Called when socket connects - immediately retry if we were waiting + */ + retryNow() { + if (this.complete) return; + + // Clear any pending backoff timer + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + + // Restore retry count since we didn't actually fail + this.retries++; + + // Attempt immediately + this.attempt(); + } + + onTimeout() { + if (this.complete == true) + console.log( + this.mid, + "timeout should not happen, request is already complete", + ); + + console.log("Request", this.mid, "timed out"); + clearTimeout(this.timeoutId); + this.attempt(); + } + + attempt() { + // console.log("attempt:", this.mid); + + if (this.complete == true) + console.log( + this.mid, + "attempt should not be called, request is already complete", + ); + + this.retries--; + + if (this.retries < 0) { + console.log("Request", this.mid, "ran out of retries"); + + clearTimeout(this.timeoutId); + delete this.socket.inflight[this.mid]; + + this.error("Ran out of retries"); + return; // Exit early - no more attempts + } + + // Check if WebSocket connection is available and ready + if (this.socket.ws && this.socket.ws.readyState === WS_OPEN) { + try { + this.socket.ws.send(JSON.stringify(this.msg)); + this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout); + + return; + } catch (e) { + console.log("Error:", e); + console.log("Message send failure, retry..."); + + // Calculate backoff delay with jitter + const backoffDelay = Math.min( + SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) + + Math.random() * 1000, + 30000, // Max 30 seconds + ); + + this.timeoutId = setTimeout(this.attempt.bind(this), backoffDelay); + + console.log("Reopen..."); + // Attempt to reopen the WebSocket connection + this.socket.reopen(); + } + } else { + // No WebSocket connection available or not ready + // Check if socket is connecting + if ( + this.socket.ws && + this.socket.ws.readyState === WS_CONNECTING + ) { + // Wait a bit longer for connection to establish + setTimeout(this.attempt.bind(this), 500); + } else { + // Socket is closed or closing, trigger reopen + console.log("Socket not ready, reopening..."); + this.socket.reopen(); + + // Calculate backoff delay + const backoffDelay = Math.min( + SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) + + Math.random() * 1000, + 30000, + ); + + setTimeout(this.attempt.bind(this), backoffDelay); + } + } + } +} diff --git a/ts/packages/client/src/socket/service-call.ts b/ts/packages/client/src/socket/service-call.ts new file mode 100644 index 00000000..f3990aa9 --- /dev/null +++ b/ts/packages/client/src/socket/service-call.ts @@ -0,0 +1,240 @@ +import { RequestMessage } from "../models/messages.js"; +import { WS_OPEN, type IsomorphicWebSocket } from "./websocket-adapter.js"; + +// Constant defining the delay before attempting to reconnect a WebSocket +// (2 seconds) +export const SOCKET_RECONNECTION_TIMEOUT = 2000; + +// Forward declare Socket type to avoid circular dependency +// Using a minimal interface that matches what BaseApi provides +interface Socket { + ws?: IsomorphicWebSocket; + inflight: { [key: string]: ServiceCall }; + reopen: () => void; + getNextId?: () => string; + user?: string; +} + +/** + * ServiceCall represents a single request/response cycle over a WebSocket + * connection with built-in retry logic, timeout handling, and completion + * tracking. + * + * This class manages the lifecycle of a service call including: + * - Sending the initial request + * - Handling timeouts and retries + * - Managing completion state + * - Cleaning up resources + */ +export class ServiceCall { + constructor( + mid: string, // Message ID - unique identifier for this request + msg: RequestMessage, // The actual message/request to send + success: (resp: unknown) => void, // Callback function called on + // successful response + error: (err: object | string) => void, // Callback function called on error/failure + timeout: number, // Timeout duration in milliseconds + retries: number, // Number of retry attempts allowed + socket: Socket, // WebSocket instance to send the message through + ) { + this.mid = mid; + this.msg = msg; + this.success = success; + this.error = error; + this.timeout = timeout; + this.retries = retries; + this.socket = socket; + this.complete = false; // Track if this request has completed + } + + // Properties + mid: string; // Message identifier + msg: RequestMessage; // The request message + success: (resp: unknown) => void; // Success callback + error: (err: object | string) => void; // Error callback + timeoutId?: ReturnType<typeof setTimeout>; // Reference to the active timeout timer + timeout: number; // Timeout duration in milliseconds + retries: number; // Remaining retry attempts + socket: Socket; // WebSocket connection reference + complete: boolean; // Flag indicating if request is complete + + /** + * Initiates the service call by registering it with the socket's inflight + * requests and making the first attempt to send the message + */ + start() { + // Register this request as "in-flight" so responses can be matched to it + this.socket.inflight[this.mid] = this; + // Make the first attempt to send the message + this.attempt(); + } + + /** + * Called when a response is received for this request + * Handles cleanup and calls the success or error callback based on response + * + * @param resp - The response object received from the server + */ + onReceived(resp: object) { + // Defensive check - this shouldn't happen but log if it does + if (this.complete == true) + console.log(this.mid, "should not happen, request is already complete"); + + // Mark as complete to prevent duplicate processing + this.complete = true; + + // Clean up timeout timer + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + + // Remove from inflight requests tracker + delete this.socket.inflight[this.mid]; + + // Check if the response contains an error (error can be directly in resp or nested under response) + let errorToHandle: unknown = null; + + // Check for direct error in response + if (resp && typeof resp === "object" && "error" in resp) { + errorToHandle = (resp as Record<string, unknown>).error; + } + // Check for nested error under response property + else if (resp && typeof resp === "object" && "response" in resp) { + const response = (resp as Record<string, unknown>).response; + if (response && typeof response === "object" && "error" in response) { + errorToHandle = (response as Record<string, unknown>).error; + } + } + + if (errorToHandle) { + // Response contains an error - call error callback + const errorObj = errorToHandle as Record<string, unknown>; + const errorMessage = + (typeof errorObj.message === "string" ? errorObj.message : null) || + (typeof errorObj.type === "string" ? errorObj.type : null) || + "Unknown error"; + console.log( + "ServiceCall: API error detected in response:", + errorMessage, + "Full error:", + errorToHandle, + ); + this.error(new Error(errorMessage)); + return; + } + + // Extract the response field from the message object + // The resp parameter is the full message: {id, response, complete} + // We need to pass just the response field to the success callback + const responseData = (resp as { response?: unknown }).response; + this.success(responseData); + } + + /** + * Called when socket connects - immediately retry if we were waiting + */ + retryNow() { + if (this.complete) return; + + // Clear any pending backoff timer + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + + // Restore retry count since we didn't actually fail + this.retries++; + + // Attempt immediately + this.attempt(); + } + + /** + * Called when the request times out + * Triggers another attempt if retries are available + */ + onTimeout() { + // Defensive check - this shouldn't happen but log if it does + if (this.complete == true) + console.log( + this.mid, + "timeout should not happen, request is already complete", + ); + + console.log("Request", this.mid, "timed out"); + + // Clear the current timeout + clearTimeout(this.timeoutId); + + // Try again (this will check retry count) + this.attempt(); + } + + /** + * Calculates exponential backoff delay with jitter + * @returns backoff delay in milliseconds + */ + calculateBackoff() { + return Math.min( + SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) + + Math.random() * 1000, + 30000, // Max 30 seconds + ); + } + + /** + * Core retry logic - attempts to send the message over the WebSocket + * Handles retries and waits for BaseApi to handle reconnection + */ + attempt() { + // Defensive check - this shouldn't be called on completed requests + if (this.complete == true) + console.log( + this.mid, + "attempt should not be called, request is already complete", + ); + + // Decrement retry counter + this.retries--; + + // Check if we've exhausted all retries + if (this.retries < 0) { + console.log("Request", this.mid, "ran out of retries"); + + // Clean up and call error callback + clearTimeout(this.timeoutId); + delete this.socket.inflight[this.mid]; + this.error("Ran out of retries"); + return; // Exit early - no more attempts + } + + // Check if WebSocket connection is available and ready + if (this.socket.ws && this.socket.ws.readyState === WS_OPEN) { + try { + // Attempt to send the message as JSON + this.socket.ws.send(JSON.stringify(this.msg)); + + // Set up timeout for this attempt + this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout); + + return; // Success - message sent, waiting for response or timeout + } catch (e) { + // Handle send failure - wait for BaseApi to handle reconnection + console.log("Error:", e); + console.log( + "Message send failure, waiting for socket reconnection...", + ); + + // Schedule retry with backoff - let BaseApi handle the reconnection + this.timeoutId = setTimeout( + this.attempt.bind(this), + this.calculateBackoff(), + ); + } + } else { + // No WebSocket connection available or not ready + // Let BaseApi handle reconnection, just wait and retry + console.log("Request", this.mid, "waiting for socket reconnection..."); + + // Use consistent backoff for all waiting scenarios + setTimeout(this.attempt.bind(this), this.calculateBackoff()); + } + } +} diff --git a/ts/packages/client/src/socket/trustgraph-socket.ts b/ts/packages/client/src/socket/trustgraph-socket.ts new file mode 100644 index 00000000..c864849e --- /dev/null +++ b/ts/packages/client/src/socket/trustgraph-socket.ts @@ -0,0 +1,2366 @@ +// Import core types and classes for the TrustGraph API +import { Triple, Term } from "../models/Triple.js"; +import { ServiceCallMulti } from "./service-call-multi.js"; +import { ServiceCall } from "./service-call.js"; +import { + getWebSocketConstructor, + getDefaultSocketUrl, + getRandomValues, + WS_CONNECTING, + WS_OPEN, + WS_CLOSED, + type IsomorphicWebSocket, + type WsMessageEvent, + type WsCloseEvent, + type WsEvent, +} from "./websocket-adapter.js"; + +// Import all message types for different services +import { + AgentRequest, + AgentResponse, + ConfigRequest, + ConfigResponse, + DocumentMetadata, + DocumentRagRequest, + DocumentRagResponse, + EmbeddingsRequest, + EmbeddingsResponse, + EntityMatch, + FlowRequest, + FlowResponse, + GraphEmbeddingsQueryRequest, + GraphEmbeddingsQueryResponse, + GraphRagRequest, + GraphRagResponse, + // KnowledgeRequest, + // KnowledgeResponse, + LibraryRequest, + LibraryResponse, + LoadDocumentRequest, + LoadDocumentResponse, + LoadTextRequest, + LoadTextResponse, + NlpQueryRequest, + NlpQueryResponse, + RowsQueryRequest, + RowsQueryResponse, + RowEmbeddingsQueryRequest, + RowEmbeddingsQueryResponse, + RowEmbeddingsMatch, + PromptRequest, + PromptResponse, + // ProcessingMetadata, + RequestMessage, + StructuredQueryRequest, + StructuredQueryResponse, + TextCompletionRequest, + TextCompletionResponse, + TriplesQueryRequest, + TriplesQueryResponse, + // Chunked upload types + ChunkedUploadDocumentMetadata, + BeginUploadRequest, + BeginUploadResponse, + UploadChunkRequest, + UploadChunkResponse, + CompleteUploadRequest, + CompleteUploadResponse, + GetUploadStatusRequest, + GetUploadStatusResponse, + AbortUploadRequest, + AbortUploadResponse, + ListUploadsRequest, + ListUploadsResponse, + UploadSession, + StreamDocumentRequest, + StreamDocumentResponse, + // EntityEmbeddings, + // Error, + // GraphEmbedding, + // Metadata, + // Request, + // Response, +} from "../models/messages.js"; + +// GraphRAG options interface for configurable parameters +export interface GraphRagOptions { + entityLimit?: number; + tripleLimit?: number; + maxSubgraphSize?: number; + pathLength?: number; +} + +// Metadata included in final streaming message +export interface StreamingMetadata { + in_token?: number; + out_token?: number; + model?: string; +} + +// Explainability event data +export interface ExplainEvent { + explainId: string; + explainGraph: string; // Named graph where explain data is stored (e.g., urn:graph:retrieval) +} + +// Configuration constants +const SOCKET_RECONNECTION_TIMEOUT = 2000; // 2 seconds between reconnection +// attempts +const SOCKET_URL = getDefaultSocketUrl(); // WebSocket endpoint path (isomorphic) + +/** + * Socket interface defining all available operations for the TrustGraph API + * This provides a unified interface for various AI/ML and knowledge graph + * operations + */ +export interface Socket { + close: () => void; + + // Text completion using AI models + textCompletion: (system: string, text: string) => Promise<string>; + + // Graph-based Retrieval Augmented Generation + graphRag: (text: string, options?: GraphRagOptions) => Promise<string>; + + // Agent interaction with streaming callbacks for different phases + // BREAKING CHANGE: Callbacks now receive (chunk, complete, metadata?) instead of full messages + agent: ( + question: string, + think: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + observe: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + error: (e: string) => void, + onExplain?: (event: ExplainEvent) => void, + ) => void; + + // Streaming variants for RAG and completion services + graphRagStreaming: ( + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + options?: GraphRagOptions, + collection?: string, + ) => void; + + documentRagStreaming: ( + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + docLimit?: number, + collection?: string, + onExplain?: (event: ExplainEvent) => void, + ) => void; + + textCompletionStreaming: ( + system: string, + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + ) => void; + + promptStreaming: ( + id: string, + terms: Record<string, unknown>, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + ) => void; + + // Generate embeddings for texts (batch) + embeddings: (texts: string[]) => Promise<number[][]>; + + // Query graph using embedding vector + graphEmbeddingsQuery: (vec: number[], limit: number) => Promise<EntityMatch[]>; + + // Query knowledge graph triples (subject-predicate-object) + triplesQuery: ( + s?: Term, // Subject (optional) + p?: Term, // Predicate (optional) + o?: Term, // Object (optional) + limit?: number, + collection?: string, + graph?: string, // Named graph URI filter + ) => Promise<Triple[]>; + + // Load a document into the system + loadDocument: ( + document: string, // Base64-encoded document + id?: string, // Optional document ID + metadata?: Triple[], // Optional metadata as triples + ) => Promise<void>; + + // Load plain text into the system + loadText: (text: string, id?: string, metadata?: Triple[]) => Promise<void>; + + // Load a document into the library with full metadata + loadLibraryDocument: ( + document: string, + mimeType: string, + id?: string, + metadata?: Triple[], + ) => Promise<void>; +} + +/** + * Generates a random message ID using cryptographically secure random values + * @param length - Number of random characters to generate + * @returns Random string of specified length + */ +function makeid(length: number) { + const array = new Uint32Array(length); + getRandomValues(array); + + const characters = "abcdefghijklmnopqrstuvwxyz1234567890"; + + return array.reduce( + (acc, current) => acc + characters[current % characters.length], + "", + ); +} + +/** + * BaseApi - Core WebSocket client for TrustGraph API + * Manages connection lifecycle, message routing, and provides base request + * functionality + */ +// Connection state interface for UI consumption +export interface ConnectionState { + status: + | "connecting" + | "connected" + | "reconnecting" + | "failed" + | "authenticated" + | "unauthenticated"; + hasApiKey: boolean; + reconnectAttempt?: number; + maxAttempts?: number; + nextRetryIn?: number; + lastError?: string; +} + +export class BaseApi { + ws?: IsomorphicWebSocket; // WebSocket connection instance + tag: string; // Unique client identifier + id: number; // Counter for generating unique message IDs + token?: string; // Optional authentication token + user: string; // User identifier for API requests + socketUrl: string; // WebSocket URL + inflight: { [key: string]: ServiceCall } = {}; // Track active requests by + // message ID + reconnectAttempts: number = 0; // Track reconnection attempts + maxReconnectAttempts: number = 10; // Maximum reconnection attempts + reconnectTimer?: number; // Timer for reconnection attempts + reconnectionState: "idle" | "reconnecting" | "failed" = "idle"; // Connection state + + // Connection state tracking for UI + private connectionStateListeners: ((state: ConnectionState) => void)[] = []; + private lastError?: string; + + constructor(user: string, token?: string, socketUrl?: string) { + this.tag = makeid(16); // Generate unique client tag + this.id = 1; // Start message ID counter + this.token = token; // Store authentication token + this.user = user; // Store user identifier + this.socketUrl = socketUrl || SOCKET_URL; // Use provided URL or default + + console.log( + "SOCKET: opening socket...", + token ? "with auth" : "without auth", + "user:", + user, + ); + this.openSocket(); // Establish WebSocket connection + console.log("SOCKET: socket opened"); + } + + /** + * Subscribe to connection state changes for UI updates + */ + onConnectionStateChange(listener: (state: ConnectionState) => void) { + this.connectionStateListeners.push(listener); + // Immediately send current state + listener(this.getConnectionState()); + + // Return unsubscribe function + return () => { + const index = this.connectionStateListeners.indexOf(listener); + if (index > -1) { + this.connectionStateListeners.splice(index, 1); + } + }; + } + + /** + * Get current connection state + */ + private getConnectionState(): ConnectionState { + const hasApiKey = !!this.token; + + // Determine status based on WebSocket state and reconnection state + let status: ConnectionState["status"]; + + if (!this.ws || this.ws.readyState === WS_CLOSED) { + if (this.reconnectionState === "failed") { + status = "failed"; + } else if (this.reconnectionState === "reconnecting") { + status = "reconnecting"; + } else { + status = "connecting"; + } + } else if (this.ws.readyState === WS_CONNECTING) { + status = "connecting"; + } else if (this.ws.readyState === WS_OPEN) { + status = hasApiKey ? "authenticated" : "unauthenticated"; + } else { + status = "connecting"; + } + + const state: ConnectionState = { + status, + hasApiKey, + lastError: this.lastError, + }; + + // Add reconnection details if applicable + if (status === "reconnecting") { + state.reconnectAttempt = this.reconnectAttempts; + state.maxAttempts = this.maxReconnectAttempts; + } + + return state; + } + + /** + * Notify all listeners of connection state changes + */ + private notifyStateChange() { + const state = this.getConnectionState(); + this.connectionStateListeners.forEach((listener) => { + try { + listener(state); + } catch (error) { + console.error("Error in connection state listener:", error); + } + }); + } + + /** + * Establishes WebSocket connection and sets up event handlers + */ + openSocket() { + // Don't create multiple connections + if ( + this.ws && + (this.ws.readyState === WS_CONNECTING || + this.ws.readyState === WS_OPEN) + ) { + return; + } + + // Clean up old socket if exists + if (this.ws) { + this.ws.removeEventListener("message", this.onMessage); + this.ws.removeEventListener("close", this.onClose); + this.ws.removeEventListener("open", this.onOpen); + this.ws.removeEventListener("error", this.onError); + this.ws = undefined; + } + + try { + // Build WebSocket URL with optional token parameter + const wsUrl = this.token + ? `${this.socketUrl}?token=${this.token}` + : this.socketUrl; + console.log( + "SOCKET: connecting to", + wsUrl.replace(/token=[^&]*/, "token=***"), + ); + const WS = getWebSocketConstructor(); + this.ws = new WS(wsUrl); + } catch (e) { + console.error("[socket creation error]", e); + this.scheduleReconnect(); + return; + } + + // Bind event handlers to maintain proper 'this' context + this.onMessage = this.onMessage.bind(this); + this.onClose = this.onClose.bind(this); + this.onOpen = this.onOpen.bind(this); + this.onError = this.onError.bind(this); + + // Attach event listeners + this.ws.addEventListener("message", this.onMessage); + this.ws.addEventListener("close", this.onClose); + this.ws.addEventListener("open", this.onOpen); + this.ws.addEventListener("error", this.onError); + } + + // Handle incoming messages from server + onMessage(message: WsMessageEvent) { + if (!message.data) return; + + try { + const obj = JSON.parse(String(message.data)); + + // Skip messages without ID (can't route them) + if (!obj.id) return; + + // Route response to the corresponding inflight request + if (this.inflight[obj.id]) { + // Pass the whole message object so receiver can access 'complete' flag + this.inflight[obj.id].onReceived(obj); + } + } catch (e) { + console.error("[socket message parse error]", e); + } + } + + // Handle connection closure - automatically attempt reconnection + onClose(event: WsCloseEvent) { + console.log("[socket close]", event.code, event.reason); + this.lastError = `Connection closed: ${event.reason || "Unknown reason"}`; + this.ws = undefined; + this.notifyStateChange(); + this.scheduleReconnect(); + } + + // Handle successful connection + onOpen(_event: WsEvent) { + console.log("[socket open]"); + this.reconnectAttempts = 0; // Reset reconnection attempts on success + this.reconnectionState = "idle"; // Reset connection state + this.lastError = undefined; // Clear any previous errors + + // Clear any pending reconnect timer + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + + // Notify UI of successful connection + this.notifyStateChange(); + + // Immediately retry any pending requests that were waiting for connection + for (const mid in this.inflight) { + this.inflight[mid].retryNow(); + } + } + + // Handle socket errors + onError(event: WsEvent) { + console.error("[socket error]", event); + this.lastError = "Connection error occurred"; + this.notifyStateChange(); + } + + /** + * Schedules a reconnection attempt with exponential backoff + */ + scheduleReconnect() { + // Prevent concurrent reconnection attempts + if (this.reconnectionState === "reconnecting") { + console.log("[socket] Reconnection already in progress, skipping"); + return; + } + + // Don't schedule if already scheduled + if (this.reconnectTimer) return; + + this.reconnectionState = "reconnecting"; + this.reconnectAttempts++; + this.notifyStateChange(); // Notify UI of reconnection attempt + + if (this.reconnectAttempts > this.maxReconnectAttempts) { + console.error("[socket] Max reconnection attempts reached"); + this.reconnectionState = "failed"; + this.lastError = "Max reconnection attempts exceeded"; + this.notifyStateChange(); + // Notify all pending requests of the failure + for (const mid in this.inflight) { + this.inflight[mid].error(new Error("WebSocket connection failed")); + } + return; + } + + // Calculate exponential backoff with jitter + const backoffDelay = Math.min( + SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, this.reconnectAttempts - 1) + + Math.random() * 1000, + 30000, // Max 30 seconds + ); + + console.log( + `[socket] Reconnecting in ${backoffDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`, + ); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + this.reopen(); + }, backoffDelay) as unknown as number; + } + + /** + * Reopens the WebSocket connection (used after connection failures) + */ + reopen() { + console.log("[socket reopen]"); + // Check if we're already connected or connecting + if ( + this.ws && + (this.ws.readyState === WS_OPEN || + this.ws.readyState === WS_CONNECTING) + ) { + return; + } + this.openSocket(); + } + + /** + * Closes the WebSocket connection and cleans up + */ + close() { + // Clear reconnection timer + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + + // Clean up WebSocket + if (this.ws) { + // Remove event listeners to prevent memory leaks + this.ws.removeEventListener("message", this.onMessage); + this.ws.removeEventListener("close", this.onClose); + this.ws.removeEventListener("open", this.onOpen); + this.ws.removeEventListener("error", this.onError); + + this.ws.close(); + this.ws = undefined; + } + + // Clear any remaining inflight requests + for (const mid in this.inflight) { + this.inflight[mid].error(new Error("Socket closed")); + } + this.inflight = {}; + } + + /** + * Generates the next unique message ID for requests + * Format: {clientTag}-{incrementingNumber} + */ + getNextId() { + const mid = this.tag + "-" + this.id.toString(); + this.id++; + return mid; + } + + /** + * Core method for making service requests over WebSocket + * @param service - Name of the service to call + * @param request - Request payload + * @param timeout - Request timeout in milliseconds (default: 10000) + * @param retries - Number of retry attempts (default: 3) + * @param flow - Optional flow identifier + * @returns Promise resolving to the service response + */ + makeRequest<RequestType extends object, ResponseType>( + service: string, + request: RequestType, + timeout?: number, + retries?: number, + flow?: string, + ) { + const mid = this.getNextId(); + + // Set default values + if (timeout == undefined) timeout = 10000; + if (retries == undefined) retries = 3; + + // Construct the request message + const msg: RequestMessage = { + id: mid, + service: service, + request: request, + }; + + // Add flow identifier if provided + if (flow) msg.flow = flow; + + // Return a Promise that will be resolved/rejected by the ServiceCall + return new Promise<ResponseType>((resolve, reject) => { + const call = new ServiceCall( + mid, + msg, + resolve as (resp: unknown) => void, + reject as (err: object | string) => void, + timeout, + retries, + this, + ); + + call.start(); + // Commented out debug logging: console.log("-->", msg); + }).then((obj) => { + // Commented out success logging: console.log("Success for", mid); + return obj as ResponseType; + }); + } + + /** + * Makes a request that can receive multiple responses (streaming) + * Used for operations that return data in chunks + */ + makeRequestMulti<RequestType extends object, ResponseType>( + service: string, + request: RequestType, + receiver: (resp: unknown) => boolean, // Callback to handle each response chunk + timeout?: number, + retries?: number, + flow?: string, + ) { + const mid = this.getNextId(); + + // Set defaults + if (timeout == undefined) timeout = 10000; + if (retries == undefined) retries = 3; + + // Construct request message + const msg: RequestMessage = { + id: mid, + service: service, + request: request, + }; + + if (flow) msg.flow = flow; + + return new Promise<ResponseType>((resolve, reject) => { + const call = new ServiceCallMulti( + mid, + msg, + resolve as (resp: unknown) => void, + reject as (err: object | string) => void, + timeout, + retries, + this as any, // eslint-disable-line @typescript-eslint/no-explicit-any + receiver, + ); + + call.start(); + }).then((obj) => { + return obj as ResponseType; + }); + } + + /** + * Convenience method for making flow-specific requests + * Defaults to "default" flow if none specified + */ + makeFlowRequest<RequestType extends object, ResponseType>( + service: string, + request: RequestType, + timeout?: number, + retries?: number, + flow?: string, + ) { + if (!flow) flow = "default"; + + return this.makeRequest<RequestType, ResponseType>( + service, + request, + timeout, + retries, + flow, + ); + } + + // Factory methods for creating specialized API instances + librarian() { + return new LibrarianApi(this); + } + + flows() { + return new FlowsApi(this); + } + + flow(id: string) { + return new FlowApi(this, id); + } + + knowledge() { + return new KnowledgeApi(this); + } + + config() { + return new ConfigApi(this); + } + + collectionManagement() { + return new CollectionManagementApi(this); + } +} + +/** + * LibrarianApi - Manages document storage and retrieval + * Handles document lifecycle including upload, processing, and removal + */ +export class LibrarianApi { + api: BaseApi; + + constructor(api: BaseApi) { + this.api = api; + } + + /** + * Retrieves list of all documents in the system + */ + getDocuments() { + return this.api + .makeRequest<LibraryRequest, LibraryResponse>( + "librarian", + { + operation: "list-documents", + user: this.api.user, + }, + 60000, // 60 second timeout for potentially large lists + ) + .then((r) => r["document-metadatas"] || []); + } + + /** + * Retrieves list of documents currently being processed + */ + getProcessing() { + return this.api + .makeRequest<LibraryRequest, LibraryResponse>( + "librarian", + { + operation: "list-processing", + user: this.api.user, + }, + 60000, + ) + .then((r) => r["processing-metadata"] || []); + } + + /** + * Retrieves metadata for a single document by ID + * @param documentId - Document URI/ID to fetch + * @returns Document metadata including title, comments, tags, and RDF metadata + */ + getDocumentMetadata(documentId: string): Promise<DocumentMetadata | null> { + return this.api + .makeRequest<LibraryRequest, LibraryResponse>( + "librarian", + { + operation: "get-document-metadata", + "document-id": documentId, + user: this.api.user, + }, + 30000, + ) + .then((r) => r["document-metadata"] || null); + } + + /** + * Uploads a document to the library with full metadata + * @param document - Base64-encoded document content + * @param id - Optional document identifier + * @param metadata - Optional metadata as triples + * @param mimeType - Document MIME type + * @param title - Document title + * @param comments - Additional comments + * @param tags - Document tags for categorization + */ + loadDocument( + document: string, // base64-encoded doc + mimeType: string, + title: string, + comments: string, + tags: string[], + id?: string, + metadata?: Triple[], + ) { + return this.api.makeRequest<LibraryRequest, LibraryResponse>( + "librarian", + { + operation: "add-document", + "document-metadata": { + id: id, + time: Math.floor(Date.now() / 1000), // Unix timestamp + kind: mimeType, + title: title, + comments: comments, + metadata: metadata, + user: this.api.user, + tags: tags, + }, + content: document, + }, + 30000, // 30 second timeout for document upload + ); + } + + /** + * Removes a document from the library + */ + removeDocument(id: string, collection?: string) { + return this.api.makeRequest<LibraryRequest, LibraryResponse>( + "librarian", + { + operation: "remove-document", + "document-id": id, + user: this.api.user, + collection: collection || "default", + }, + 30000, + ); + } + + /** + * Adds a document to the processing queue + * @param id - Processing job identifier + * @param doc_id - Document to process + * @param flow - Processing flow to use + * @param collection - Collection to add processed data to + * @param tags - Tags for the processing job + */ + addProcessing( + id: string, + doc_id: string, + flow: string, + collection?: string, + tags?: string[], + ) { + return this.api.makeRequest<LibraryRequest, LibraryResponse>( + "librarian", + { + operation: "add-processing", + "processing-metadata": { + id: id, + "document-id": doc_id, + time: Math.floor(Date.now() / 1000), + flow: flow, + user: this.api.user, + collection: collection ? collection : "default", + tags: tags ? tags : [], + }, + }, + 30000, + ); + } + + // ========== Chunked Upload API ========== + + /** + * Initialize a chunked upload session for large documents (>2MB) + * @param metadata - Document metadata including id, title, kind (MIME type), etc. + * @param totalSize - Total size of the document in bytes + * @param chunkSize - Optional chunk size (default: 5MB) + * @returns Upload session info including upload-id and total-chunks + */ + beginUpload( + metadata: ChunkedUploadDocumentMetadata, + totalSize: number, + chunkSize?: number, + ): Promise<BeginUploadResponse> { + return this.api + .makeRequest<BeginUploadRequest, BeginUploadResponse>( + "librarian", + { + operation: "begin-upload", + "document-metadata": metadata, + "total-size": totalSize, + "chunk-size": chunkSize, + }, + 30000, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r; + }); + } + + /** + * Upload a single chunk of a document + * Chunks can be uploaded in any order and in parallel + * @param uploadId - Upload session ID from beginUpload + * @param chunkIndex - Zero-based chunk index + * @param content - Base64-encoded chunk content + * @returns Progress info including chunks-received and bytes-received + */ + uploadChunk( + uploadId: string, + chunkIndex: number, + content: string, + ): Promise<UploadChunkResponse> { + return this.api + .makeRequest<UploadChunkRequest, UploadChunkResponse>( + "librarian", + { + operation: "upload-chunk", + "upload-id": uploadId, + "chunk-index": chunkIndex, + content: content, + user: this.api.user, + }, + 60000, // Longer timeout for chunk uploads + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r; + }); + } + + /** + * Finalize a chunked upload after all chunks are received + * Triggers document processing + * @param uploadId - Upload session ID from beginUpload + * @returns Document ID and object ID + */ + completeUpload(uploadId: string): Promise<CompleteUploadResponse> { + return this.api + .makeRequest<CompleteUploadRequest, CompleteUploadResponse>( + "librarian", + { + operation: "complete-upload", + "upload-id": uploadId, + user: this.api.user, + }, + 30000, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r; + }); + } + + /** + * Check upload progress (useful for resuming interrupted uploads) + * @param uploadId - Upload session ID + * @returns Status including received/missing chunks + */ + getUploadStatus(uploadId: string): Promise<GetUploadStatusResponse> { + return this.api + .makeRequest<GetUploadStatusRequest, GetUploadStatusResponse>( + "librarian", + { + operation: "get-upload-status", + "upload-id": uploadId, + user: this.api.user, + }, + 30000, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r; + }); + } + + /** + * Cancel an in-progress upload and clean up + * @param uploadId - Upload session ID to abort + */ + abortUpload(uploadId: string): Promise<void> { + return this.api + .makeRequest<AbortUploadRequest, AbortUploadResponse>( + "librarian", + { + operation: "abort-upload", + "upload-id": uploadId, + user: this.api.user, + }, + 30000, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + }); + } + + /** + * List pending upload sessions for the current user + * @returns Array of upload sessions with metadata and progress + */ + listUploads(): Promise<UploadSession[]> { + return this.api + .makeRequest<ListUploadsRequest, ListUploadsResponse>( + "librarian", + { + operation: "list-uploads", + user: this.api.user, + }, + 30000, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r["upload-sessions"] || []; + }); + } + + /** + * Stream a document in chunks for retrieval (streaming response) + * Sends one request, receives multiple chunk responses via callback + * @param documentId - Document ID to retrieve + * @param onChunk - Callback for each chunk: (content, chunkIndex, totalChunks, complete) => void + * @param onError - Callback for errors + * @param chunkSize - Optional chunk size (default: 1MB) + */ + streamDocument( + documentId: string, + onChunk: (content: string, chunkIndex: number, totalChunks: number, complete: boolean) => void, + onError: (error: string) => void, + chunkSize?: number, + ): void { + const receiver = (message: unknown): boolean => { + const msg = message as { response?: StreamDocumentResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + onError(msg.error); + return true; + } + + const resp = msg.response; + if (!resp) { + return !!msg.complete; + } + + // Check for response-level error + if (resp.error) { + onError(resp.error.message); + return true; + } + + const complete = !!msg.complete; + onChunk(resp.content, resp["chunk-index"], resp["total-chunks"], complete); + + return complete; + }; + + this.api.makeRequestMulti<StreamDocumentRequest, StreamDocumentResponse>( + "librarian", + { + operation: "stream-document", + "document-id": documentId, + "chunk-size": chunkSize, + user: this.api.user, + }, + receiver, + 300000, // 5 minute timeout for full document stream + ); + } +} + +/** + * FlowsApi - Manages processing flows and configuration + * Flows define how documents and data are processed through the system + */ +export class FlowsApi { + api: BaseApi; + + constructor(api: BaseApi) { + this.api = api; + } + + /** + * Retrieves list of available flows + */ + getFlows() { + return this.api + .makeRequest<FlowRequest, FlowResponse>( + "flow", + { + operation: "list-flows", + }, + 60000, + ) + .then((r) => r["flow-ids"] || []); + } + + /** + * Retrieves definition of a specific flow + */ + getFlow(id: string) { + return this.api + .makeRequest<FlowRequest, FlowResponse>( + "flow", + { + operation: "get-flow", + "flow-id": id, + }, + 60000, + ) + .then((r) => JSON.parse(r.flow || "{}")); // Parse JSON flow definition + } + + // Configuration management methods + + /** + * Retrieves all configuration settings + */ + getConfigAll() { + return this.api.makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "config", + }, + 60000, + ); + } + + /** + * Retrieves specific configuration values by key + */ + getConfig(keys: { type: string; key: string }[]) { + return this.api.makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "get", + keys: keys, + }, + 60000, + ); + } + + /** + * Updates configuration values + */ + putConfig(values: { type: string; key: string; value: string }[]) { + return this.api.makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "put", + values: values, + }, + 60000, + ); + } + + /** + * Deletes configuration entries + */ + deleteConfig(keys: { type: string; key: string }) { + return this.api.makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "delete", + keys: keys, + }, + 30000, + ); + } + + // Prompt management - specialized config operations for AI prompts + + /** + * Retrieves list of available prompt templates + */ + getPrompts() { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record<string, Record<string, string>> + >; + return JSON.parse(config.config.prompt["template-index"]); + }); + } + + /** + * Retrieves a specific prompt template + */ + getPrompt(id: string) { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record<string, Record<string, string>> + >; + return JSON.parse(config.config.prompt[`template.${id}`]); + }); + } + + /** + * Retrieves the system prompt configuration + */ + getSystemPrompt() { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record<string, Record<string, string>> + >; + return JSON.parse(config.config.prompt.system); + }); + } + + // Flow blueprint management - templates for creating flows + + /** + * Retrieves list of available flow blueprints (templates) + */ + getFlowBlueprints() { + return this.api + .makeRequest<FlowRequest, FlowResponse>( + "flow", + { + operation: "list-blueprints", + }, + 60000, + ) + .then((r) => r["blueprint-names"]); + } + + /** + * Retrieves definition of a specific flow blueprint + */ + getFlowBlueprint(name: string) { + return this.api + .makeRequest<FlowRequest, FlowResponse>( + "flow", + { + operation: "get-blueprint", + "blueprint-name": name, + }, + 60000, + ) + .then((r) => JSON.parse(r["blueprint-definition"] || "{}")); + } + + /** + * Deletes a flow blueprint + */ + deleteFlowBlueprint(name: string) { + return this.api.makeRequest<FlowRequest, FlowResponse>( + "flow", + { + operation: "delete-blueprint", + "blueprint-name": name, + }, + 30000, + ); + } + + // Flow lifecycle management + + /** + * Starts a new flow instance + */ + startFlow( + id: string, + blueprint_name: string, + description: string, + parameters?: Record<string, unknown>, + ) { + const request: FlowRequest = { + operation: "start-flow", + "flow-id": id, + "blueprint-name": blueprint_name, + description: description, + }; + + // Only include parameters if provided and not empty + if (parameters && Object.keys(parameters).length > 0) { + request.parameters = parameters; + } + + return this.api + .makeRequest<FlowRequest, FlowResponse>("flow", request, 30000) + .then((response) => { + if (response.error) { + let errorMessage = "Flow start failed"; + if ( + typeof response.error === "object" && + response.error && + "message" in response.error + ) { + errorMessage = + (response.error as { message?: string }).message || errorMessage; + } else if (typeof response.error === "string") { + errorMessage = response.error; + } + throw new Error(errorMessage); + } + return response; + }); + } + + /** + * Stops a running flow instance + */ + stopFlow(id: string) { + return this.api.makeRequest<FlowRequest, FlowResponse>( + "flow", + { + operation: "stop-flow", + "flow-id": id, + }, + 30000, + ); + } +} + +/** + * FlowApi - Interface for interacting with a specific flow instance + * Provides flow-specific versions of core AI/ML operations + */ +export class FlowApi { + api: BaseApi; + flowId: string; + + constructor(api: BaseApi, flowId: string) { + this.api = api; + this.flowId = flowId; // All requests will be routed through this flow + } + + /** + * Performs text completion using AI models within this flow + */ + textCompletion(system: string, text: string): Promise<string> { + return this.api + .makeRequest<TextCompletionRequest, TextCompletionResponse>( + "text-completion", + { + system: system, // System prompt/instructions + prompt: text, // User prompt + }, + 30000, + undefined, // Use default retries + this.flowId, // Route through this flow + ) + .then((r) => r.response); + } + + /** + * Performs Graph RAG (Retrieval Augmented Generation) query + */ + graphRag(text: string, options?: GraphRagOptions, collection?: string) { + return this.api + .makeRequest<GraphRagRequest, GraphRagResponse>( + "graph-rag", + { + query: text, + user: this.api.user, + collection: collection || "default", + "entity-limit": options?.entityLimit, + "triple-limit": options?.tripleLimit, + "max-subgraph-size": options?.maxSubgraphSize, + "max-path-length": options?.pathLength, + }, + 60000, // Longer timeout for complex graph operations + undefined, + this.flowId, + ) + .then((r) => r.response); + } + + /** + * Performs Document RAG (Retrieval Augmented Generation) query + */ + documentRag(text: string, docLimit?: number, collection?: string) { + return this.api + .makeRequest<DocumentRagRequest, DocumentRagResponse>( + "document-rag", + { + query: text, + user: this.api.user, + collection: collection || "default", + "doc-limit": docLimit || 20, + }, + 60000, // Longer timeout for document operations + undefined, + this.flowId, + ) + .then((r) => r.response); + } + + /** + * Interacts with an AI agent that provides streaming responses + * BREAKING CHANGE: Callbacks now receive (chunk, complete, metadata?) instead of full messages + */ + agent( + question: string, + think: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + observe: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + answer: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + error: (s: string) => void, + onExplain?: (event: ExplainEvent) => void, + ) { + const receiver = (message: unknown) => { + const msg = message as { response?: AgentResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + error(msg.error); + return true; + } + + const resp = msg.response || {}; + + // Check for errors in response + if (resp.chunk_type === "error" || resp.error) { + error(resp.error?.message || "Unknown agent error"); + return true; // End streaming on error + } + + // Handle explainability events (agent uses chunk_type="explain") + if ((resp.chunk_type === "explain" || resp.message_type === "explain") && resp.explain_id && resp.explain_graph) { + onExplain?.({ + explainId: resp.explain_id, + explainGraph: resp.explain_graph, + }); + return false; + } + + // Handle streaming chunks by chunk_type + const content = resp.content || ""; + const messageComplete = !!resp.end_of_message; + const dialogComplete = !!msg.complete; + + // Extract metadata from final message + const metadata: StreamingMetadata | undefined = dialogComplete && (resp.in_token || resp.out_token || resp.model) + ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } + : undefined; + + switch (resp.chunk_type) { + case "thought": + think(content, messageComplete, metadata); + break; + case "observation": + observe(content, messageComplete, metadata); + break; + case "answer": + case "final-answer": + answer(content, messageComplete, metadata); + break; + case "action": + // Actions are typically not streamed incrementally, just logged + console.log("Agent action:", content); + break; + } + + return dialogComplete; // End when backend signals complete + }; + + return this.api + .makeRequestMulti<AgentRequest, AgentResponse>( + "agent", + { + question: question, + user: this.api.user, + streaming: true, // Always use streaming mode + }, + receiver, + 120000, + 2, + this.flowId, + ) + .catch((err) => { + const errorMessage = + err instanceof Error ? err.message : err?.toString() || "Unknown error"; + error(`Agent request failed: ${errorMessage}`); + }); + } + + /** + * Performs Graph RAG query with streaming response + * @param text - Query text + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + * @param options - Graph RAG options (including explainable flag) + * @param collection - Collection name + * @param onExplain - Optional callback for explainability events + */ + graphRagStreaming( + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + options?: GraphRagOptions, + collection?: string, + onExplain?: (event: ExplainEvent) => void, + ): void { + const recv = (message: unknown): boolean => { + const msg = message as { response?: GraphRagResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + onError(msg.error); + return true; + } + + const resp = (msg.response || {}) as GraphRagResponse; + + // Check for response-level error + if (resp.error) { + onError(resp.error.message); + return true; + } + + // Handle explainability events + if (resp.message_type === "explain" && resp.explain_id && resp.explain_graph) { + onExplain?.({ + explainId: resp.explain_id, + explainGraph: resp.explain_graph, + }); + // Don't return true - more messages may follow + return false; + } + + // Handle chunk messages (default behavior) + const chunk = resp.response || resp.chunk || ""; + const complete = !!resp.end_of_session || !!msg.complete; + + // Extract metadata from final message + const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) + ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } + : undefined; + + receiver(chunk, complete, metadata); + + return complete; + }; + + this.api.makeRequestMulti<GraphRagRequest, GraphRagResponse>( + "graph-rag", + { + query: text, + user: this.api.user, + collection: collection || "default", + "entity-limit": options?.entityLimit, + "triple-limit": options?.tripleLimit, + "max-subgraph-size": options?.maxSubgraphSize, + "max-path-length": options?.pathLength, + streaming: true, + }, + recv, + 60000, + undefined, + this.flowId, + ); + } + + /** + * Performs Document RAG query with streaming response + * @param text - Query text + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + * @param docLimit - Maximum documents to retrieve + * @param collection - Collection name + */ + documentRagStreaming( + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + docLimit?: number, + collection?: string, + onExplain?: (event: ExplainEvent) => void, + ): void { + const recv = (message: unknown): boolean => { + const msg = message as { response?: DocumentRagResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + onError(msg.error); + return true; + } + + const resp = (msg.response || {}) as DocumentRagResponse; + + // Check for response-level error + if (resp.error) { + onError(resp.error.message); + return true; + } + + // Handle explainability events + if (resp.message_type === "explain" && resp.explain_id && resp.explain_graph) { + onExplain?.({ + explainId: resp.explain_id, + explainGraph: resp.explain_graph, + }); + return false; + } + + const chunk = resp.response || resp.chunk || ""; + const complete = !!resp.end_of_session || !!msg.complete; + + // Extract metadata from final message + const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) + ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } + : undefined; + + receiver(chunk, complete, metadata); + + return complete; + }; + + this.api.makeRequestMulti<DocumentRagRequest, DocumentRagResponse>( + "document-rag", + { + query: text, + user: this.api.user, + collection: collection || "default", + "doc-limit": docLimit, + streaming: true, + }, + recv, + 60000, + undefined, + this.flowId, + ); + } + + /** + * Performs text completion with streaming response + * @param system - System prompt + * @param text - User prompt + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + */ + textCompletionStreaming( + system: string, + text: string, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + ): void { + const recv = (message: unknown): boolean => { + const msg = message as { response?: TextCompletionResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + onError(msg.error); + return true; + } + + const resp = (msg.response || {}) as TextCompletionResponse; + + // Check for response-level error + if (resp.error) { + onError(resp.error.message); + return true; + } + + // Text completion uses 'response' field for chunks + const chunk = resp.response || ""; + const complete = !!msg.complete; + + // Extract metadata from final message + const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) + ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } + : undefined; + + receiver(chunk, complete, metadata); + + return complete; + }; + + this.api.makeRequestMulti<TextCompletionRequest, TextCompletionResponse>( + "text-completion", + { + system: system, + prompt: text, + streaming: true, + }, + recv, + 30000, + undefined, + this.flowId, + ); + } + + /** + * Executes a prompt template with streaming response + * @param id - Prompt template ID + * @param terms - Template variables + * @param receiver - Called for each chunk with (chunk, complete) where complete=true on final chunk + * @param onError - Called on error + */ + promptStreaming( + id: string, + terms: Record<string, unknown>, + receiver: (chunk: string, complete: boolean, metadata?: StreamingMetadata) => void, + onError: (error: string) => void, + ): void { + const recv = (message: unknown): boolean => { + const msg = message as { response?: PromptResponse; complete?: boolean; error?: string }; + + // Check for top-level error + if (msg.error) { + onError(msg.error); + return true; + } + + const resp = (msg.response || {}) as PromptResponse; + + // Check for response-level error + if (resp.error) { + onError(resp.error.message); + return true; + } + + // Prompt service uses 'text' field for chunks + const chunk = resp.text || ""; + const complete = !!msg.complete; + + // Extract metadata from final message + const metadata: StreamingMetadata | undefined = complete && (resp.in_token || resp.out_token || resp.model) + ? { in_token: resp.in_token, out_token: resp.out_token, model: resp.model } + : undefined; + + receiver(chunk, complete, metadata); + + return complete; + }; + + this.api.makeRequestMulti<PromptRequest, PromptResponse>( + "prompt", + { + id: id, + terms: terms, + streaming: true, + }, + recv, + 30000, + undefined, + this.flowId, + ); + } + + /** + * Generates embeddings for multiple texts within this flow. + * Returns vectors[text_index][dimension_index] - one vector per input text. + */ + embeddings(texts: string[]) { + return this.api + .makeRequest<EmbeddingsRequest, EmbeddingsResponse>( + "embeddings", + { + texts: texts, + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => r.vectors); + } + + /** + * Queries the knowledge graph using a single embedding vector + */ + graphEmbeddingsQuery( + vec: number[], + limit: number | undefined, + collection?: string, + ) { + return this.api + .makeRequest<GraphEmbeddingsQueryRequest, GraphEmbeddingsQueryResponse>( + "graph-embeddings", + { + vector: vec, + limit: limit ? limit : 20, // Default to 20 results + user: this.api.user, + collection: collection || "default", + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => r.entities); + } + + /** + * Queries knowledge graph triples (subject-predicate-object relationships) + * All parameters are optional - omitted parameters act as wildcards + */ + triplesQuery( + s?: Term, + p?: Term, + o?: Term, + limit?: number, + collection?: string, + graph?: string, + ) { + return this.api + .makeRequest<TriplesQueryRequest, TriplesQueryResponse>( + "triples", + { + s: s, // Subject + p: p, // Predicate + o: o, // Object + g: graph, // Named graph URI filter + limit: limit ? limit : 20, + user: this.api.user, + collection: collection || "default", + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => r.response); + } + + /** + * Loads a document into this flow for processing + */ + loadDocument( + document: string, // base64-encoded document + id?: string, + metadata?: Triple[], + ) { + return this.api.makeRequest<LoadDocumentRequest, LoadDocumentResponse>( + "document-load", + { + id: id, + metadata: metadata, + data: document, + }, + 30000, + undefined, + this.flowId, + ); + } + + /** + * Loads plain text into this flow for processing + */ + loadText( + text: string, // Text content + id?: string, + metadata?: Triple[], + charset?: string, // Character encoding + ) { + return this.api.makeRequest<LoadTextRequest, LoadTextResponse>( + "text-load", + { + id: id, + metadata: metadata, + text: text, + charset: charset, + }, + 30000, + undefined, + this.flowId, + ); + } + + /** + * Executes a GraphQL query against structured row data + */ + rowsQuery( + query: string, + collection?: string, + variables?: Record<string, unknown>, + operationName?: string, + ) { + return this.api + .makeRequest<RowsQueryRequest, RowsQueryResponse>( + "rows", + { + query: query, + user: this.api.user, + collection: collection || "default", + variables: variables, + operation_name: operationName, + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => { + // Return the GraphQL response structure directly + const result: Record<string, unknown> = {}; + if (r.data !== undefined) result.data = r.data; + if (r.errors) result.errors = r.errors; + if (r.extensions) result.extensions = r.extensions; + return result; + }); + } + + /** + * Converts a natural language question to a GraphQL query + */ + nlpQuery(question: string, maxResults?: number) { + return this.api + .makeRequest<NlpQueryRequest, NlpQueryResponse>( + "nlp-query", + { + question: question, + max_results: maxResults || 100, + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => r); + } + + /** + * Executes a natural language question against structured data + * Combines NLP query conversion and GraphQL execution + */ + structuredQuery(question: string, collection?: string) { + return this.api + .makeRequest<StructuredQueryRequest, StructuredQueryResponse>( + "structured-query", + { + question: question, + user: this.api.user, + collection: collection || "default", + }, + 30000, + undefined, + this.flowId, + ) + .then((r) => { + // Return the response structure directly + const result: Record<string, unknown> = {}; + if (r.data !== undefined) result.data = r.data; + if (r.errors) result.errors = r.errors; + return result; + }); + } + + /** + * Performs semantic search on structured data indexes using embedding vectors + * @param vectors - Embedding vectors to search for + * @param schemaName - Name of the schema to search + * @param collection - Optional collection name + * @param indexName - Optional index name to filter results + * @param limit - Maximum number of results to return (default: 10) + */ + rowEmbeddingsQuery( + vector: number[], + schemaName: string, + collection?: string, + indexName?: string, + limit?: number, + ): Promise<RowEmbeddingsMatch[]> { + const request: RowEmbeddingsQueryRequest = { + vector: vector, + schema_name: schemaName, + user: this.api.user, + collection: collection || "default", + limit: limit || 10, + }; + + if (indexName) { + request.index_name = indexName; + } + + return this.api + .makeRequest<RowEmbeddingsQueryRequest, RowEmbeddingsQueryResponse>( + "row-embeddings", + request, + 30000, + undefined, + this.flowId, + ) + .then((r) => { + if (r.error) { + throw new Error(r.error.message); + } + return r.matches || []; + }); + } +} + +/** + * ConfigApi - Dedicated configuration management interface + * Handles system configuration, prompts, and token cost tracking + */ +export class ConfigApi { + api: BaseApi; + + constructor(api: BaseApi) { + this.api = api; + } + + /** + * Retrieves complete configuration + */ + getConfigAll() { + return this.api.makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "config", + }, + 60000, + ); + } + + /** + * Retrieves specific configuration entries + */ + getConfig(keys: { type: string; key: string }[]) { + return this.api.makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "get", + keys: keys, + }, + 60000, + ); + } + + /** + * Updates configuration values + */ + putConfig(values: { type: string; key: string; value: string }[]) { + return this.api.makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "put", + values: values, + }, + 60000, + ); + } + + /** + * Deletes configuration entries + */ + deleteConfig(keys: { type: string; key: string }) { + return this.api.makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "delete", + keys: keys, + }, + 30000, + ); + } + + // Specialized prompt management methods + + /** + * Retrieves available prompt templates + */ + getPrompts() { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record<string, Record<string, string>> + >; + return JSON.parse(config.config.prompt["template-index"]); + }); + } + + /** + * Retrieves a specific prompt template + */ + getPrompt(id: string) { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record<string, Record<string, string>> + >; + return JSON.parse(config.config.prompt[`template.${id}`]); + }); + } + + /** + * Retrieves system prompt configuration + */ + getSystemPrompt() { + return this.getConfigAll().then((r) => { + const config = r as Record< + string, + Record<string, Record<string, string>> + >; + return JSON.parse(config.config.prompt.system); + }); + } + + /** + * Lists available configuration types + */ + list(type: string) { + return this.api + .makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "list", + type: type, + }, + 60000, + ) + .then((r) => r); + } + + /** + * Retrieves all key/values for a specific type + */ + getValues(type: string) { + return this.api + .makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "getvalues", + type: type, + }, + 60000, + ) + .then((r) => (r as RowsQueryResponse).values); + } + + /** + * Retrieves token cost information for different AI models + * Useful for cost tracking and optimization + */ + getTokenCosts() { + return this.api + .makeRequest<ConfigRequest, ConfigResponse>( + "config", + { + operation: "getvalues", + type: "token-cost", + }, + 60000, + ) + .then((r) => { + // Parse JSON values and restructure data + const response = r as RowsQueryResponse; + return (response.values || []).map((x: unknown) => { + const item = x as Record<string, string>; + return { key: item.key, value: JSON.parse(item.value) }; + }); + }) + .then((r) => + // Transform to more usable format + r.map((x: unknown) => { + const item = x as Record<string, unknown>; + const value = item.value as Record<string, number>; + return { + model: item.key, + input_price: value.input_price, // Cost per input token + output_price: value.output_price, // Cost per output token + }; + }), + ); + } +} + +/** + * KnowledgeApi - Manages knowledge graph cores and data + * Knowledge cores appear to be collections of processed knowledge graph data + */ +export class KnowledgeApi { + api: BaseApi; + + constructor(api: BaseApi) { + this.api = api; + } + + /** + * Retrieves list of available knowledge graph cores + */ + getKnowledgeCores() { + return this.api + .makeRequest<FlowRequest, FlowResponse>( + "knowledge", + { + operation: "list-kg-cores", + user: this.api.user, + }, + 60000, + ) + .then((r) => r.ids || []); + } + + /** + * Deletes a knowledge graph core + */ + deleteKgCore(id: string, collection?: string) { + return this.api.makeRequest<LibraryRequest, LibraryResponse>( + "knowledge", + { + operation: "delete-kg-core", + id: id, + user: this.api.user, + collection: collection || "default", + }, + 30000, + ); + } + + /** + * Deletes a knowledge graph core + */ + loadKgCore(id: string, flow: string, collection?: string) { + return this.api.makeRequest<LibraryRequest, LibraryResponse>( + "knowledge", + { + operation: "load-kg-core", + id: id, + flow: flow, + user: this.api.user, + collection: collection || "default", + }, + 30000, + ); + } + + /** + * Retrieves a knowledge graph core with streaming data + * Uses multi-request pattern for large datasets + * @param receiver - Callback function to handle streaming data chunks + */ + getKgCore( + id: string, + collection: string | undefined, + receiver: (msg: unknown, eos: boolean) => void, + ) { + // Wrapper to handle end-of-stream detection + const recv = (msg: unknown) => { + const response = msg as Record<string, unknown>; + if (response.eos) { + // End of stream - notify receiver and signal completion + receiver(msg, true); + return true; + } else { + // Regular message - continue streaming + receiver(msg, false); + return false; + } + }; + + return this.api.makeRequestMulti<LibraryRequest, LibraryResponse>( + "knowledge", + { + operation: "get-kg-core", + id: id, + user: this.api.user, + collection: collection || "default", + }, + recv, // Stream handler + 30000, + ); + } +} + +/** + * CollectionManagementApi - Manages collections for organizing documents + * Provides operations for listing, creating, updating, and deleting collections + */ +export class CollectionManagementApi { + api: BaseApi; + + constructor(api: BaseApi) { + this.api = api; + } + + /** + * Lists all collections for the current user with optional tag filtering + * @param tagFilter - Optional array of tags to filter collections + * @returns Promise resolving to array of collection metadata + */ + listCollections(tagFilter?: string[]) { + const request: Record<string, unknown> = { + operation: "list-collections", + user: this.api.user, + }; + + if (tagFilter && tagFilter.length > 0) { + request.tag_filter = tagFilter; + } + + return this.api + .makeRequest< + Record<string, unknown>, + Record<string, unknown> + >("collection-management", request, 30000) + .then((r) => r.collections || []); + } + + /** + * Creates or updates a collection for the current user + * @param collection - Collection ID (unique identifier) + * @param name - Display name for the collection + * @param description - Description of the collection + * @param tags - Array of tags for categorization + * @returns Promise resolving to updated collection metadata + */ + updateCollection( + collection: string, + name?: string, + description?: string, + tags?: string[], + ) { + const request: Record<string, unknown> = { + operation: "update-collection", + user: this.api.user, + collection, + }; + + if (name !== undefined) { + request.name = name; + } + if (description !== undefined) { + request.description = description; + } + if (tags !== undefined) { + request.tags = tags; + } + + return this.api + .makeRequest< + Record<string, unknown>, + Record<string, unknown> + >("collection-management", request, 30000) + .then((r) => { + if ( + r.collections && + Array.isArray(r.collections) && + r.collections.length > 0 + ) { + return r.collections[0]; + } + throw new Error("Failed to update collection"); + }); + } + + /** + * Deletes a collection and all its data for the current user + * @param collection - Collection ID to delete + * @returns Promise resolving when deletion is complete + */ + deleteCollection(collection: string) { + return this.api.makeRequest< + Record<string, unknown>, + Record<string, unknown> + >( + "collection-management", + { + operation: "delete-collection", + user: this.api.user, + collection, + }, + 30000, + ); + } +} + +/** + * Factory function to create a new TrustGraph WebSocket connection + * This is the main entry point for using the TrustGraph API + * @param user - User identifier for API requests + * @param token - Optional authentication token for secure connections + * @param socketUrl - Optional WebSocket URL (defaults to /api/socket for browser, provide full URL for Node.js) + */ +export const createTrustGraphSocket = ( + user: string, + token?: string, + socketUrl?: string, +): BaseApi => { + return new BaseApi(user, token, socketUrl); +}; diff --git a/ts/packages/client/src/socket/websocket-adapter.ts b/ts/packages/client/src/socket/websocket-adapter.ts new file mode 100644 index 00000000..22705c8b --- /dev/null +++ b/ts/packages/client/src/socket/websocket-adapter.ts @@ -0,0 +1,133 @@ +/** + * Isomorphic WebSocket adapter for browser and Node.js environments. + * + * In browsers, uses the native globalThis.WebSocket. + * In Node.js, dynamically requires the 'ws' package. + * + * Provides its own minimal type definitions for the WebSocket API surface + * we actually use, so the package does not require DOM lib types. + */ + +// --------------------------------------------------------------------------- +// WebSocket readyState constants (identical in browser WebSocket and 'ws') +// --------------------------------------------------------------------------- +export const WS_CONNECTING = 0; +export const WS_OPEN = 1; +export const WS_CLOSING = 2; +export const WS_CLOSED = 3; + +// --------------------------------------------------------------------------- +// Minimal WebSocket type surface used by this package +// --------------------------------------------------------------------------- + +/** Minimal event type compatible with both browser Event and ws events. */ +export interface WsEvent { + type: string; + [key: string]: unknown; +} + +/** Minimal MessageEvent-compatible shape. */ +export interface WsMessageEvent { + data: unknown; + type: string; + [key: string]: unknown; +} + +/** Minimal CloseEvent-compatible shape. */ +export interface WsCloseEvent { + code: number; + reason: string; + wasClean: boolean; + type: string; + [key: string]: unknown; +} + +/** + * Minimal interface covering the WebSocket instance methods and properties + * used by this package. Compatible with both browser `WebSocket` and the + * `ws` npm package. + */ +export interface IsomorphicWebSocket { + readonly readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: "message", listener: (event: WsMessageEvent) => void): void; + addEventListener(type: "close", listener: (event: WsCloseEvent) => void): void; + addEventListener(type: "open", listener: (event: WsEvent) => void): void; + addEventListener(type: "error", listener: (event: WsEvent) => void): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + removeEventListener(type: string, listener: (...args: any[]) => void): void; +} + +/** Constructor signature for an isomorphic WebSocket implementation. */ +export interface IsomorphicWebSocketConstructor { + new (url: string): IsomorphicWebSocket; +} + +// --------------------------------------------------------------------------- +// Runtime helpers +// --------------------------------------------------------------------------- + +/** + * Returns the WebSocket constructor appropriate for the current environment. + * + * - Browser: uses `globalThis.WebSocket` (native) + * - Node.js: dynamically `require`s the `ws` npm package + * + * @throws Error if no WebSocket implementation is available + */ +export function getWebSocketConstructor(): IsomorphicWebSocketConstructor { + // Browser environment (or Deno, Bun, etc. where WebSocket is global) + if (typeof globalThis !== "undefined" && "WebSocket" in globalThis) { + return (globalThis as unknown as { WebSocket: IsomorphicWebSocketConstructor }).WebSocket; + } + + // Node.js environment — dynamically require 'ws' + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const ws = require("ws"); + return ws as IsomorphicWebSocketConstructor; + } catch { + throw new Error( + 'WebSocket is not available. In Node.js, install the "ws" package: npm install ws', + ); + } +} + +/** + * Returns the default WebSocket URL for the current environment. + * + * - Browser: returns the relative path `"/api/socket"` (resolved by the + * browser against the current page origin). + * - Node.js: returns a full URL `"ws://localhost:8088/api/v1/socket"` since + * relative URLs are not meaningful outside a browser. + */ +export function getDefaultSocketUrl(): string { + if (typeof window !== "undefined") { + return "/api/socket"; + } + return "ws://localhost:8088/api/v1/socket"; +} + +/** + * Isomorphic `getRandomValues` that works in both browser and Node.js. + * + * - Browser / Node.js 19+: uses `globalThis.crypto.getRandomValues` + * - Older Node.js: falls back to `node:crypto.randomFillSync` + */ +export function getRandomValues(array: Uint32Array): Uint32Array { + if (typeof globalThis.crypto?.getRandomValues === "function") { + return globalThis.crypto.getRandomValues(array); + } + // Node.js fallback for versions < 19 where globalThis.crypto may not exist + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { randomFillSync } = require("node:crypto"); + return randomFillSync(array) as Uint32Array; + } catch { + throw new Error( + "No cryptographic random source available. " + + "Upgrade to Node.js 19+ or ensure the 'crypto' module is available.", + ); + } +} diff --git a/ts/packages/client/src/types.ts b/ts/packages/client/src/types.ts new file mode 100644 index 00000000..19bcb6bf --- /dev/null +++ b/ts/packages/client/src/types.ts @@ -0,0 +1,3 @@ +// Type definitions for TrustGraph client + +export {}; diff --git a/ts/packages/client/tsconfig.json b/ts/packages/client/tsconfig.json new file mode 100644 index 00000000..8b942a8f --- /dev/null +++ b/ts/packages/client/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022", "DOM"], + "composite": true + }, + "include": ["src"], + "exclude": ["src/__tests__"] +} diff --git a/ts/packages/flow/package.json b/ts/packages/flow/package.json index cac52410..fcdeb62c 100644 --- a/ts/packages/flow/package.json +++ b/ts/packages/flow/package.json @@ -15,7 +15,7 @@ "openai": "^4.85.0", "@anthropic-ai/sdk": "^0.39.0", "@qdrant/js-client-rest": "^1.13.0", - "neo4j-driver": "^5.28.0", + "falkordb": "^5.0.0", "fastify": "^5.2.0", "@fastify/websocket": "^11.0.0" }, diff --git a/ts/packages/flow/src/config/service.ts b/ts/packages/flow/src/config/service.ts new file mode 100644 index 00000000..c962c724 --- /dev/null +++ b/ts/packages/flow/src/config/service.ts @@ -0,0 +1,357 @@ +/** + * Config service — manages system global configuration state. + * + * An AsyncProcessor (NOT FlowProcessor) that: + * 1. Listens on config-request topic + * 2. Handles operations: get, put, delete, list, config (full dump) + * 3. Stores config in-memory with a nested Map structure + * 4. On any mutation: increments version, broadcasts ConfigPush on config-push topic + * 5. Optionally persists to a JSON file for restart durability + * + * Python reference: trustgraph-flow/trustgraph/config/service/service.py + */ + +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { + AsyncProcessor, + type ProcessorConfig, + topics, + type ConfigRequest, + type ConfigResponse, + type ConfigOperation, +} from "@trustgraph/base"; +import type { PubSubBackend, BackendProducer, BackendConsumer, Message } from "@trustgraph/base"; + +export interface ConfigServiceConfig extends ProcessorConfig { + persistPath?: string; +} + +interface ConfigPush { + version: number; + config: Record<string, unknown>; +} + +export class ConfigService extends AsyncProcessor { + private store = new Map<string, Map<string, unknown>>(); + private version = 0; + private persistPath: string | null; + private consumer: BackendConsumer<ConfigRequest> | null = null; + private responseProducer: BackendProducer<ConfigResponse> | null = null; + private pushProducer: BackendProducer<ConfigPush> | null = null; + + constructor(config: ConfigServiceConfig) { + super(config); + this.persistPath = config.persistPath ?? process.env.CONFIG_PERSIST_PATH ?? null; + } + + protected override async run(): Promise<void> { + // Optionally load persisted state + if (this.persistPath) { + await this.loadFromDisk(); + } + + // Create producers + this.responseProducer = await this.pubsub.createProducer<ConfigResponse>({ + topic: topics.configResponse, + }); + this.pushProducer = await this.pubsub.createProducer<ConfigPush>({ + topic: topics.configPush, + }); + + // Create consumer for config requests + this.consumer = await this.pubsub.createConsumer<ConfigRequest>({ + topic: topics.configRequest, + subscription: `${this.config.id}-config-request`, + }); + + // Push initial config + await this.pushConfig(); + + console.log(`[ConfigService] Listening on ${topics.configRequest}`); + + // Main consume loop + while (this.running) { + try { + const msg = await this.consumer.receive(2000); + if (!msg) continue; + + await this.handleMessage(msg); + await this.consumer.acknowledge(msg); + } catch (err) { + if (!this.running) break; + console.error("[ConfigService] Error in consume loop:", err); + await sleep(1000); + } + } + } + + private async handleMessage(msg: Message<ConfigRequest>): Promise<void> { + const request = msg.value(); + const props = msg.properties(); + const requestId = props.id; + + if (!requestId) { + console.warn("[ConfigService] Received request without id, ignoring"); + return; + } + + try { + const response = await this.handleOperation(request); + await this.responseProducer!.send(response, { id: requestId }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.responseProducer!.send( + { + error: { type: "config-error", message }, + }, + { id: requestId }, + ); + } + } + + private async handleOperation(request: ConfigRequest): Promise<ConfigResponse> { + const op: ConfigOperation = request.operation; + + switch (op) { + case "get": + return this.handleGet(request.keys ?? []); + + case "put": + return await this.handlePut(request.keys ?? [], request.values ?? {}); + + case "delete": + return await this.handleDelete(request.keys ?? []); + + case "list": + return this.handleList(request.keys ?? []); + + case "config": + return this.handleConfigDump(); + + default: + throw new Error(`Unknown config operation: ${op as string}`); + } + } + + private handleGet(keys: string[]): ConfigResponse { + if (keys.length === 0) { + return { version: this.version, values: {} }; + } + + const values: Record<string, unknown> = {}; + const namespace = keys[0]; + const subMap = this.store.get(namespace); + + if (subMap) { + if (keys.length === 1) { + // Return entire namespace + for (const [k, v] of subMap) { + values[k] = v; + } + } else { + // Return specific keys within namespace + for (let i = 1; i < keys.length; i++) { + const key = keys[i]; + if (subMap.has(key)) { + values[key] = subMap.get(key); + } + } + } + } + + return { version: this.version, values }; + } + + private async handlePut( + keys: string[], + values: Record<string, unknown>, + ): Promise<ConfigResponse> { + if (keys.length === 0) { + throw new Error("Put requires at least one key (namespace)"); + } + + const namespace = keys[0]; + let subMap = this.store.get(namespace); + if (!subMap) { + subMap = new Map<string, unknown>(); + this.store.set(namespace, subMap); + } + + for (const [k, v] of Object.entries(values)) { + subMap.set(k, v); + } + + this.version++; + await this.persist(); + await this.pushConfig(); + + return { version: this.version }; + } + + private async handleDelete(keys: string[]): Promise<ConfigResponse> { + if (keys.length === 0) { + throw new Error("Delete requires at least one key"); + } + + const namespace = keys[0]; + + if (keys.length === 1) { + // Delete entire namespace + this.store.delete(namespace); + } else { + // Delete specific keys within namespace + const subMap = this.store.get(namespace); + if (subMap) { + for (let i = 1; i < keys.length; i++) { + subMap.delete(keys[i]); + } + if (subMap.size === 0) { + this.store.delete(namespace); + } + } + } + + this.version++; + await this.persist(); + await this.pushConfig(); + + return { version: this.version }; + } + + private handleList(keys: string[]): ConfigResponse { + if (keys.length === 0) { + // List all namespaces + return { + version: this.version, + directory: [...this.store.keys()], + }; + } + + const namespace = keys[0]; + const subMap = this.store.get(namespace); + + return { + version: this.version, + directory: subMap ? [...subMap.keys()] : [], + }; + } + + private handleConfigDump(): ConfigResponse { + const config: Record<string, unknown> = {}; + + for (const [namespace, subMap] of this.store) { + const obj: Record<string, unknown> = {}; + for (const [k, v] of subMap) { + obj[k] = v; + } + config[namespace] = obj; + } + + return { + version: this.version, + config, + }; + } + + private async pushConfig(): Promise<void> { + if (!this.pushProducer) return; + + const config: Record<string, unknown> = {}; + for (const [namespace, subMap] of this.store) { + const obj: Record<string, unknown> = {}; + for (const [k, v] of subMap) { + obj[k] = v; + } + config[namespace] = obj; + } + + await this.pushProducer.send({ + version: this.version, + config, + }); + + console.log(`[ConfigService] Pushed configuration version ${this.version}`); + } + + private async persist(): Promise<void> { + if (!this.persistPath) return; + + try { + const data: Record<string, Record<string, unknown>> = {}; + + for (const [namespace, subMap] of this.store) { + const obj: Record<string, unknown> = {}; + for (const [k, v] of subMap) { + obj[k] = v; + } + data[namespace] = obj; + } + + const json = JSON.stringify( + { version: this.version, data }, + null, + 2, + ); + + await mkdir(dirname(this.persistPath), { recursive: true }); + await writeFile(this.persistPath, json, "utf-8"); + } catch (err) { + console.error("[ConfigService] Failed to persist config:", err); + } + } + + private async loadFromDisk(): Promise<void> { + if (!this.persistPath) return; + + try { + const raw = await readFile(this.persistPath, "utf-8"); + const parsed = JSON.parse(raw) as { + version: number; + data: Record<string, Record<string, unknown>>; + }; + + this.version = parsed.version ?? 0; + this.store.clear(); + + for (const [namespace, obj] of Object.entries(parsed.data ?? {})) { + const subMap = new Map<string, unknown>(); + for (const [k, v] of Object.entries(obj)) { + subMap.set(k, v); + } + this.store.set(namespace, subMap); + } + + console.log( + `[ConfigService] Loaded persisted config (version=${this.version}, namespaces=${this.store.size})`, + ); + } catch { + // File doesn't exist yet or is invalid — start fresh + console.log("[ConfigService] No persisted config found, starting fresh"); + } + } + + override async stop(): Promise<void> { + if (this.consumer) { + await this.consumer.close(); + this.consumer = null; + } + if (this.responseProducer) { + await this.responseProducer.close(); + this.responseProducer = null; + } + if (this.pushProducer) { + await this.pushProducer.close(); + this.pushProducer = null; + } + await super.stop(); + } +} + +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function run(): Promise<void> { + await ConfigService.launch("config-svc"); +} diff --git a/ts/packages/flow/src/embeddings/ollama.ts b/ts/packages/flow/src/embeddings/ollama.ts new file mode 100644 index 00000000..5af3f4c5 --- /dev/null +++ b/ts/packages/flow/src/embeddings/ollama.ts @@ -0,0 +1,76 @@ +/** + * Ollama embeddings service. + * + * Simple HTTP POST to a local Ollama instance to generate embeddings. + * Extends EmbeddingsService from @trustgraph/base so it plugs into the + * flow processor framework (consumer/producer wiring is handled by the base class). + * + * Python reference: trustgraph-flow/trustgraph/embeddings/ollama/processor.py + */ + +import { + EmbeddingsService, + type ProcessorConfig, +} from "@trustgraph/base"; + +export interface OllamaEmbeddingsConfig extends ProcessorConfig { + model?: string; + ollamaHost?: string; +} + +interface OllamaEmbedResponse { + embeddings: number[][]; +} + +export class OllamaEmbeddingsProcessor extends EmbeddingsService { + private defaultModel: string; + private ollamaHost: string; + + constructor(config: OllamaEmbeddingsConfig) { + super(config); + + this.defaultModel = config.model ?? "mxbai-embed-large"; + this.ollamaHost = + config.ollamaHost ?? + process.env.OLLAMA_HOST ?? + "http://localhost:11434"; + + console.log( + `[OllamaEmbeddings] Initialized (host=${this.ollamaHost}, model=${this.defaultModel})`, + ); + } + + async onEmbeddings(texts: string[], model?: string): Promise<number[][]> { + if (!texts || texts.length === 0) { + return []; + } + + const useModel = model ?? this.defaultModel; + + const url = `${this.ollamaHost}/api/embed`; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: useModel, + input: texts, + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error( + `Ollama embeddings request failed (${response.status}): ${body}`, + ); + } + + const data = (await response.json()) as OllamaEmbedResponse; + + return data.embeddings; + } +} + +export async function run(): Promise<void> { + await OllamaEmbeddingsProcessor.launch("embeddings"); +} diff --git a/ts/packages/flow/src/gateway/dispatch/manager.ts b/ts/packages/flow/src/gateway/dispatch/manager.ts index 0551bba0..e0b23b5e 100644 --- a/ts/packages/flow/src/gateway/dispatch/manager.ts +++ b/ts/packages/flow/src/gateway/dispatch/manager.ts @@ -1,14 +1,70 @@ /** * Dispatcher manager — routes requests to backend services via pub/sub. * + * Maintains a service registry mapping service names to NATS topic pairs. + * Applies wire format translation on requests (client → internal) and + * reverse translation on responses (internal → client). + * * Python reference: trustgraph-flow/trustgraph/gateway/dispatch/manager.py */ import { NatsBackend, RequestResponse, type PubSubBackend } from "@trustgraph/base"; import type { GatewayConfig } from "../server.js"; +import { translateRequest, translateResponse } from "./serialize.js"; export type Responder = (response: unknown, complete: boolean) => Promise<void>; +// ---------- Service registry ---------- + +/** + * Flow-scoped request/response services. + * These are resolved within a specific flow's interface definitions. + * Topic pattern: tg.flow.<name>-request / tg.flow.<name>-response + */ +const FLOW_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([ + ["agent", { request: "agent-request", response: "agent-response" }], + ["text-completion", { request: "text-completion-request", response: "text-completion-response" }], + ["prompt", { request: "prompt-request", response: "prompt-response" }], + ["graph-rag", { request: "graph-rag-request", response: "graph-rag-response" }], + ["document-rag", { request: "document-rag-request", response: "document-rag-response" }], + ["embeddings", { request: "embeddings-request", response: "embeddings-response" }], + ["graph-embeddings", { request: "graph-embeddings-request", response: "graph-embeddings-response" }], + ["document-embeddings", { request: "doc-embeddings-request", response: "doc-embeddings-response" }], + ["triples", { request: "triples-request", response: "triples-response" }], +]); + +/** + * Global services (not flow-scoped). + * These always use fixed topics regardless of which flow is active. + */ +const GLOBAL_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([ + ["config", { request: "config-request", response: "config-response" }], + ["flow", { request: "flow-request", response: "flow-response" }], + ["librarian", { request: "librarian-request", response: "librarian-response" }], + ["knowledge", { request: "knowledge-request", response: "knowledge-response" }], + ["collection-management", { request: "collection-management-request", response: "collection-management-response" }], +]); + +/** + * Services that support streaming responses (multiple messages per request). + * The completion flag is determined by checking for end-of-stream markers. + */ +const STREAMING_SERVICES = new Set([ + "agent", + "text-completion", + "graph-rag", + "document-rag", + "triples", + "knowledge", + "librarian", +]); + +function topicName(name: string): string { + return `tg.flow.${name}`; +} + +// ---------- Manager ---------- + export class DispatcherManager { private pubsub: PubSubBackend; private requestors = new Map<string, RequestResponse<unknown, unknown>>(); @@ -18,8 +74,7 @@ export class DispatcherManager { } async start(): Promise<void> { - // Pre-create requestors for known global services - // Flow-specific requestors are created on demand + // Requestors are created on demand when first accessed } async stop(): Promise<void> { @@ -29,6 +84,8 @@ export class DispatcherManager { await this.pubsub.close(); } + // ---------- Internal helpers ---------- + private async getRequestor( requestTopic: string, responseTopic: string, @@ -48,25 +105,71 @@ export class DispatcherManager { return rr; } + private resolveGlobalTopics( + kind: string, + ): { requestTopic: string; responseTopic: string } { + const entry = GLOBAL_SERVICES.get(kind); + if (entry) { + return { + requestTopic: topicName(entry.request), + responseTopic: topicName(entry.response), + }; + } + // Fallback: derive from kind name directly + return { + requestTopic: topicName(`${kind}-request`), + responseTopic: topicName(`${kind}-response`), + }; + } + + private resolveFlowTopics( + kind: string, + ): { requestTopic: string; responseTopic: string } { + const entry = FLOW_SERVICES.get(kind); + if (entry) { + return { + requestTopic: topicName(entry.request), + responseTopic: topicName(entry.response), + }; + } + // Fallback: derive from kind name directly + return { + requestTopic: topicName(`${kind}-request`), + responseTopic: topicName(`${kind}-response`), + }; + } + + /** + * Determine whether a response is the final one in a streaming sequence. + * Checks for various end-of-stream markers used by different services. + */ + private isComplete(response: unknown): boolean { + if (typeof response !== "object" || response === null) return true; + const res = response as Record<string, unknown>; + return ( + !!res.complete || + !!res.endOfStream || + !!res.endOfSession || + !!res.end_of_stream || + !!res.end_of_session || + !!res.eos || + // error responses are always final + !!res.error + ); + } + + // ---------- Global service dispatch ---------- + async dispatchGlobalService( kind: string, request: Record<string, unknown>, ): Promise<unknown> { - const requestTopic = `tg.flow.${kind}-request`; - const responseTopic = `tg.flow.${kind}-response`; + const { requestTopic, responseTopic } = this.resolveGlobalTopics(kind); const rr = await this.getRequestor(requestTopic, responseTopic, `global:${kind}`); - return rr.request(request); - } - async dispatchFlowService( - flow: string, - kind: string, - request: Record<string, unknown>, - ): Promise<unknown> { - const requestTopic = `tg.flow.${kind}-request`; - const responseTopic = `tg.flow.${kind}-response`; - const rr = await this.getRequestor(requestTopic, responseTopic, `flow:${flow}:${kind}`); - return rr.request(request); + const translated = translateRequest(kind, request); + const response = await rr.request(translated); + return translateResponse(kind, response); } async dispatchGlobalServiceStreaming( @@ -74,37 +177,74 @@ export class DispatcherManager { request: Record<string, unknown>, responder: Responder, ): Promise<void> { - const requestTopic = `tg.flow.${kind}-request`; - const responseTopic = `tg.flow.${kind}-response`; + const { requestTopic, responseTopic } = this.resolveGlobalTopics(kind); const rr = await this.getRequestor(requestTopic, responseTopic, `global:${kind}`); + const translated = translateRequest(kind, request); - await rr.request(request, { + await rr.request(translated, { recipient: async (response) => { - const res = response as Record<string, unknown>; - const complete = !!res.complete || !!res.endOfStream || !!res.endOfSession; - await responder(res, complete); + const translatedRes = translateResponse(kind, response); + const complete = this.isComplete(translatedRes); + await responder(translatedRes, complete); return complete; }, }); } + // ---------- Flow-scoped service dispatch ---------- + + async dispatchFlowService( + flow: string, + kind: string, + request: Record<string, unknown>, + ): Promise<unknown> { + const { requestTopic, responseTopic } = this.resolveFlowTopics(kind); + const rr = await this.getRequestor( + requestTopic, + responseTopic, + `flow:${flow}:${kind}`, + ); + + const translated = translateRequest(kind, request); + const response = await rr.request(translated); + return translateResponse(kind, response); + } + async dispatchFlowServiceStreaming( flow: string, kind: string, request: Record<string, unknown>, responder: Responder, ): Promise<void> { - const requestTopic = `tg.flow.${kind}-request`; - const responseTopic = `tg.flow.${kind}-response`; - const rr = await this.getRequestor(requestTopic, responseTopic, `flow:${flow}:${kind}`); + const { requestTopic, responseTopic } = this.resolveFlowTopics(kind); + const rr = await this.getRequestor( + requestTopic, + responseTopic, + `flow:${flow}:${kind}`, + ); + const translated = translateRequest(kind, request); - await rr.request(request, { + await rr.request(translated, { recipient: async (response) => { - const res = response as Record<string, unknown>; - const complete = !!res.complete || !!res.endOfStream || !!res.endOfSession; - await responder(res, complete); + const translatedRes = translateResponse(kind, response); + const complete = this.isComplete(translatedRes); + await responder(translatedRes, complete); return complete; }, }); } + + // ---------- Static introspection ---------- + + static get flowServiceNames(): readonly string[] { + return [...FLOW_SERVICES.keys()]; + } + + static get globalServiceNames(): readonly string[] { + return [...GLOBAL_SERVICES.keys()]; + } + + static isStreamingService(kind: string): boolean { + return STREAMING_SERVICES.has(kind); + } } diff --git a/ts/packages/flow/src/gateway/dispatch/serialize.ts b/ts/packages/flow/src/gateway/dispatch/serialize.ts new file mode 100644 index 00000000..fee42e0f --- /dev/null +++ b/ts/packages/flow/src/gateway/dispatch/serialize.ts @@ -0,0 +1,272 @@ +/** + * Wire format serializer — translates between the compact client wire format + * (used by @trustgraph/client) and the verbose internal format + * (used by @trustgraph/base services). + * + * Client wire format (compact): + * IRI: { t: "i", i: "<iri>" } + * BLANK: { t: "b", d: "<id>" } + * LITERAL: { t: "l", v: "<value>", dt?: "<datatype>", ln?: "<language>" } + * TRIPLE: { t: "t", tr?: { s, p, o, g? } } + * + * Internal format (verbose): + * IRI: { type: "IRI", iri: "<iri>" } + * BLANK: { type: "BLANK", id: "<id>" } + * LITERAL: { type: "LITERAL", value: "<value>", datatype?: "<dt>", language?: "<lang>" } + * TRIPLE: { type: "TRIPLE", triple: { s, p, o, g? } } + * + * Python reference: trustgraph-base/trustgraph/messaging/translators/primitives.py + */ + +import type { Term, Triple } from "@trustgraph/base"; + +// ---------- Client wire format type definitions ---------- + +interface ClientIriTerm { + t: "i"; + i: string; +} + +interface ClientBlankTerm { + t: "b"; + d: string; +} + +interface ClientLiteralTerm { + t: "l"; + v: string; + dt?: string; + ln?: string; +} + +interface ClientTripleTerm { + t: "t"; + tr?: ClientTriple; +} + +type ClientTerm = ClientIriTerm | ClientBlankTerm | ClientLiteralTerm | ClientTripleTerm; + +interface ClientTriple { + s: ClientTerm; + p: ClientTerm; + o: ClientTerm; + g?: string; +} + +// ---------- Client → Internal ---------- + +export function clientTermToInternal(wire: ClientTerm): Term { + switch (wire.t) { + case "i": + return { type: "IRI", iri: wire.i }; + case "b": + return { type: "BLANK", id: wire.d }; + case "l": + return { + type: "LITERAL", + value: wire.v, + datatype: wire.dt, + language: wire.ln, + }; + case "t": + return { + type: "TRIPLE", + triple: wire.tr ? clientTripleToInternal(wire.tr) : undefined!, + }; + default: + // Defensive: pass through unknown term types + return wire as unknown as Term; + } +} + +export function clientTripleToInternal(wire: ClientTriple): Triple { + const result: Triple = { + s: clientTermToInternal(wire.s), + p: clientTermToInternal(wire.p), + o: clientTermToInternal(wire.o), + }; + if (wire.g !== undefined) { + // In the client wire format, g is a plain string. + // In the internal format, g is an optional Term (named graph). + // The Python translator treats g as a plain string passthrough, + // so we keep it as-is for compatibility. + (result as unknown as Record<string, unknown>).g = wire.g; + } + return result; +} + +// ---------- Internal → Client ---------- + +export function internalTermToClient(term: Term): ClientTerm { + switch (term.type) { + case "IRI": + return { t: "i", i: term.iri }; + case "BLANK": + return { t: "b", d: term.id }; + case "LITERAL": { + const lit: ClientLiteralTerm = { t: "l", v: term.value }; + if (term.datatype) lit.dt = term.datatype; + if (term.language) lit.ln = term.language; + return lit; + } + case "TRIPLE": + return { + t: "t", + tr: term.triple ? internalTripleToClient(term.triple) : undefined, + }; + default: + return term as unknown as ClientTerm; + } +} + +export function internalTripleToClient(triple: Triple): ClientTriple { + const result: ClientTriple = { + s: internalTermToClient(triple.s), + p: internalTermToClient(triple.p), + o: internalTermToClient(triple.o), + }; + const g = (triple as unknown as Record<string, unknown>).g; + if (g !== undefined && g !== null) { + if (typeof g === "string") { + result.g = g; + } else { + // If g is a Term, convert it back to client wire format + result.g = (g as Record<string, unknown>).iri as string | undefined; + } + } + return result; +} + +// ---------- Deep object translation ---------- + +/** + * Recursively walk an object and translate every Term-shaped value. + * A client term is detected by the presence of a `t` property + * with value "i", "b", "l", or "t". + */ +function isClientTerm(v: unknown): v is ClientTerm { + return ( + typeof v === "object" && + v !== null && + "t" in v && + typeof (v as Record<string, unknown>).t === "string" && + ["i", "b", "l", "t"].includes((v as Record<string, unknown>).t as string) + ); +} + +/** + * An internal term is detected by the presence of a `type` property + * with value "IRI", "BLANK", "LITERAL", or "TRIPLE". + */ +function isInternalTerm(v: unknown): v is Term { + return ( + typeof v === "object" && + v !== null && + "type" in v && + typeof (v as Record<string, unknown>).type === "string" && + ["IRI", "BLANK", "LITERAL", "TRIPLE"].includes( + (v as Record<string, unknown>).type as string, + ) + ); +} + +/** + * Deep-translate all client Terms in a request body to internal format. + * Handles nested objects and arrays. + */ +function deepClientToInternal(value: unknown): unknown { + if (value === null || value === undefined) return value; + + if (Array.isArray(value)) { + return value.map(deepClientToInternal); + } + + if (typeof value === "object") { + if (isClientTerm(value)) { + return clientTermToInternal(value); + } + const result: Record<string, unknown> = {}; + for (const [k, v] of Object.entries(value as Record<string, unknown>)) { + result[k] = deepClientToInternal(v); + } + return result; + } + + return value; +} + +/** + * Deep-translate all internal Terms in a response body to client format. + * Handles nested objects and arrays. + */ +function deepInternalToClient(value: unknown): unknown { + if (value === null || value === undefined) return value; + + if (Array.isArray(value)) { + return value.map(deepInternalToClient); + } + + if (typeof value === "object") { + if (isInternalTerm(value)) { + return internalTermToClient(value); + } + const result: Record<string, unknown> = {}; + for (const [k, v] of Object.entries(value as Record<string, unknown>)) { + result[k] = deepInternalToClient(v); + } + return result; + } + + return value; +} + +// ---------- Services that contain Term fields ---------- + +/** + * Services whose requests contain Term/Triple fields that need translation. + * All other services pass through without term translation. + */ +const TERM_BEARING_REQUEST_SERVICES = new Set([ + "triples", + "knowledge", +]); + +/** + * Services whose responses contain Term/Triple fields that need translation. + */ +const TERM_BEARING_RESPONSE_SERVICES = new Set([ + "triples", + "graph-embeddings", + "knowledge", +]); + +// ---------- Top-level request / response translators ---------- + +/** + * Translate a client request body to internal format. + * + * For services that carry Term fields (triples, knowledge), this deep-walks + * the request and converts compact → verbose. + * All other services pass through unchanged, since their payloads are simple + * scalar fields (query strings, limits, etc.). + */ +export function translateRequest(service: string, body: unknown): unknown { + if (TERM_BEARING_REQUEST_SERVICES.has(service)) { + return deepClientToInternal(body); + } + return body; +} + +/** + * Translate an internal response body to client wire format. + * + * For services that return Term fields (triples, graph-embeddings, knowledge), + * this deep-walks the response and converts verbose → compact. + * All other services pass through unchanged. + */ +export function translateResponse(service: string, response: unknown): unknown { + if (TERM_BEARING_RESPONSE_SERVICES.has(service)) { + return deepInternalToClient(response); + } + return response; +} diff --git a/ts/packages/flow/src/gateway/index.ts b/ts/packages/flow/src/gateway/index.ts index 15ab6529..e83c3b56 100644 --- a/ts/packages/flow/src/gateway/index.ts +++ b/ts/packages/flow/src/gateway/index.ts @@ -1,3 +1,11 @@ export { createGateway, run, type GatewayConfig } from "./server.js"; export { DispatcherManager } from "./dispatch/manager.js"; export { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js"; +export { + clientTermToInternal, + clientTripleToInternal, + internalTermToClient, + internalTripleToClient, + translateRequest, + translateResponse, +} from "./dispatch/serialize.js"; diff --git a/ts/packages/flow/src/gateway/server.ts b/ts/packages/flow/src/gateway/server.ts index a8a8395d..96a8dff3 100644 --- a/ts/packages/flow/src/gateway/server.ts +++ b/ts/packages/flow/src/gateway/server.ts @@ -2,13 +2,17 @@ * API Gateway — HTTP + WebSocket server. * * Replaces the Python aiohttp gateway with Fastify. + * Uses the Mux class for WebSocket multiplexing (queue-based request + * buffering, concurrency control, proper task lifecycle). * * Python reference: trustgraph-flow/trustgraph/gateway/service.py */ import Fastify from "fastify"; import websocketPlugin from "@fastify/websocket"; +import { registry } from "@trustgraph/base"; import { DispatcherManager } from "./dispatch/manager.js"; +import { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js"; export interface GatewayConfig { port: number; @@ -27,7 +31,7 @@ export async function createGateway(config: GatewayConfig) { // Authentication middleware app.addHook("onRequest", async (request, reply) => { if (request.url === "/api/v1/metrics") return; - if (request.url === "/api/v1/socket") return; // Socket auth via query param + if (request.url.startsWith("/api/v1/socket")) return; // Socket auth via query param if (config.secret) { const auth = request.headers.authorization; @@ -37,7 +41,7 @@ export async function createGateway(config: GatewayConfig) { } }); - // REST endpoint: POST /api/v1/:kind + // REST endpoint: POST /api/v1/:kind (global services) app.post<{ Params: { kind: string } }>("/api/v1/:kind", async (request, reply) => { const { kind } = request.params; const body = request.body as Record<string, unknown>; @@ -50,7 +54,7 @@ export async function createGateway(config: GatewayConfig) { } }); - // REST endpoint: POST /api/v1/flow/:flow/service/:kind + // REST endpoint: POST /api/v1/flow/:flow/service/:kind (flow-scoped services) app.post<{ Params: { flow: string; kind: string } }>( "/api/v1/flow/:flow/service/:kind", async (request, reply) => { @@ -67,6 +71,7 @@ export async function createGateway(config: GatewayConfig) { ); // WebSocket endpoint: /api/v1/socket + // Uses Mux for queue-based request buffering and concurrency control. app.get("/api/v1/socket", { websocket: true }, (socket, request) => { // Auth via query param const url = new URL(request.url, `http://${request.headers.host}`); @@ -76,26 +81,67 @@ export async function createGateway(config: GatewayConfig) { return; } - socket.on("message", async (data) => { - try { - const msg = JSON.parse(data.toString()); - const { id, service, flow, request: req } = msg; + // Build the MuxHandler that dispatches to the DispatcherManager + const handler: MuxHandler = async (muxReq, respond) => { + if (muxReq.flow) { + await dispatcher.dispatchFlowServiceStreaming( + muxReq.flow, + muxReq.service, + muxReq.request, + respond, + ); + } else { + await dispatcher.dispatchGlobalServiceStreaming( + muxReq.service, + muxReq.request, + respond, + ); + } + }; - const responder = async (response: unknown, complete: boolean) => { - socket.send(JSON.stringify({ id, response, complete })); + const mux = new Mux(handler); + + // Start the Mux run loop — sends responses back over the socket + const runPromise = mux.run((data) => { + // Only send if the socket is still open (readyState 1 = OPEN) + if (socket.readyState === 1) { + socket.send(data); + } + }); + + // Incoming messages get queued into the Mux + socket.on("message", (data) => { + try { + const msg = JSON.parse(data.toString()) as { + id?: string; + service?: string; + flow?: string; + request?: Record<string, unknown>; }; - if (flow) { - await dispatcher.dispatchFlowServiceStreaming(flow, service, req, responder); - } else { - await dispatcher.dispatchGlobalServiceStreaming(service, req, responder); + if (!msg.id || !msg.service || !msg.request) { + socket.send( + JSON.stringify({ + id: msg.id ?? null, + error: { type: "bad-request", message: "Missing id, service, or request" }, + complete: true, + }), + ); + return; } + + const muxReq: MuxRequest = { + id: msg.id, + service: msg.service, + flow: msg.flow, + request: msg.request, + }; + + mux.receive(muxReq); } catch (err) { - const msg = JSON.parse(data.toString()); socket.send( JSON.stringify({ - id: msg.id, - error: { type: "internal", message: String(err) }, + error: { type: "parse-error", message: String(err) }, complete: true, }), ); @@ -103,13 +149,26 @@ export async function createGateway(config: GatewayConfig) { }); socket.on("close", () => { - // Cleanup + mux.stop(); + }); + + socket.on("error", () => { + mux.stop(); + }); + + // Ensure runPromise errors don't go unhandled + runPromise.catch((err) => { + console.error("[Gateway] Mux run loop error:", err); + mux.stop(); + if (socket.readyState === 1) { + socket.close(1011, "Internal server error"); + } }); }); - // Metrics endpoint - app.get("/api/v1/metrics", async () => { - const { registry } = await import("@trustgraph/base"); + // Metrics endpoint — returns Prometheus metrics from prom-client + app.get("/api/v1/metrics", async (_, reply) => { + reply.header("content-type", registry.contentType); return registry.metrics(); }); diff --git a/ts/packages/flow/src/index.ts b/ts/packages/flow/src/index.ts index 56c19d51..34757efa 100644 --- a/ts/packages/flow/src/index.ts +++ b/ts/packages/flow/src/index.ts @@ -5,3 +5,42 @@ export { OpenAIProcessor } from "./model/text-completion/openai.js"; export { ClaudeProcessor } from "./model/text-completion/claude.js"; export { GraphRag, type GraphRagConfig, type GraphRagClients } from "./retrieval/graph-rag.js"; export { DocumentRag, type DocumentRagClients } from "./retrieval/document-rag.js"; +export { FalkorDBTriplesStore, type FalkorDBConfig } from "./storage/triples/falkordb.js"; +export { FalkorDBTriplesQuery, type FalkorDBQueryConfig } from "./query/triples/falkordb.js"; + +// Qdrant embeddings storage +export { + QdrantDocEmbeddingsStore, + type QdrantDocEmbeddingsConfig, + type DocEmbeddingsMessage, + type DocEmbeddingChunk, +} from "./storage/embeddings/qdrant-doc.js"; +export { + QdrantGraphEmbeddingsStore, + type QdrantGraphEmbeddingsConfig, + type GraphEmbeddingsMessage, + type GraphEmbeddingEntity, +} from "./storage/embeddings/qdrant-graph.js"; + +// Qdrant embeddings query +export { + QdrantDocEmbeddingsQuery, + type QdrantDocQueryConfig, + type ChunkMatch, + type DocEmbeddingsQueryRequest, +} from "./query/embeddings/qdrant-doc.js"; +export { + QdrantGraphEmbeddingsQuery, + type QdrantGraphQueryConfig, + type EntityMatch, + type GraphEmbeddingsQueryRequest, +} from "./query/embeddings/qdrant-graph.js"; + +// Embeddings services +export { OllamaEmbeddingsProcessor, type OllamaEmbeddingsConfig } from "./embeddings/ollama.js"; + +// Prompt template service +export { PromptTemplateService, type PromptTemplate, type PromptTemplateConfig } from "./prompt/template.js"; + +// Config service +export { ConfigService, type ConfigServiceConfig } from "./config/service.js"; diff --git a/ts/packages/flow/src/prompt/template.ts b/ts/packages/flow/src/prompt/template.ts new file mode 100644 index 00000000..33e364b7 --- /dev/null +++ b/ts/packages/flow/src/prompt/template.ts @@ -0,0 +1,154 @@ +/** + * Prompt template service. + * + * A FlowProcessor that: + * 1. Consumes prompt requests (name + variables) + * 2. Looks up template by name from an in-memory template map (loaded via config) + * 3. Renders template: replaces {variable} placeholders with values + * 4. Returns { system, prompt } strings + * + * Template config shape (received via config push): + * { + * "prompt": { + * "extract-concepts": { + * "system": "You are a helpful assistant.", + * "prompt": "Extract key concepts from: {query}" + * }, + * "graph-rag-synthesize": { + * "system": "You are a knowledge graph assistant.", + * "prompt": "Given this context:\n{context}\n\nAnswer: {query}" + * } + * } + * } + * + * Python reference: trustgraph-flow/trustgraph/prompt/template/service.py + */ + +import { + FlowProcessor, + ConsumerSpec, + ProducerSpec, + type ProcessorConfig, + type FlowContext, + type PromptRequest, + type PromptResponse, +} from "@trustgraph/base"; + +export interface PromptTemplate { + system: string; + prompt: string; +} + +export interface PromptTemplateConfig extends ProcessorConfig { + configKey?: string; +} + +export class PromptTemplateService extends FlowProcessor { + private templates = new Map<string, PromptTemplate>(); + private configKey: string; + + constructor(config: PromptTemplateConfig) { + super(config); + + this.configKey = config.configKey ?? "prompt"; + + this.registerSpecification( + new ConsumerSpec<PromptRequest>( + "request", + this.onRequest.bind(this), + ), + ); + this.registerSpecification(new ProducerSpec<PromptResponse>("response")); + + this.registerConfigHandler(this.onPromptConfig.bind(this)); + + console.log("[PromptTemplate] Service initialized"); + } + + private async onPromptConfig( + config: Record<string, unknown>, + version: number, + ): Promise<void> { + console.log(`[PromptTemplate] Loading prompt configuration version ${version}`); + + const promptConfig = config[this.configKey] as + | Record<string, { system?: string; prompt?: string }> + | undefined; + + if (!promptConfig) { + console.warn(`[PromptTemplate] No key "${this.configKey}" in config`); + return; + } + + try { + this.templates.clear(); + + for (const [name, template] of Object.entries(promptConfig)) { + this.templates.set(name, { + system: template.system ?? "", + prompt: template.prompt ?? "", + }); + } + + console.log( + `[PromptTemplate] Loaded ${this.templates.size} template(s): ${[...this.templates.keys()].join(", ")}`, + ); + } catch (err) { + console.error("[PromptTemplate] Failed to load prompt configuration:", err); + } + } + + private async onRequest( + msg: PromptRequest, + properties: Record<string, string>, + flowCtx: FlowContext, + ): Promise<void> { + const requestId = properties.id; + if (!requestId) return; + + const responseProducer = flowCtx.flow.producer<PromptResponse>("response"); + + try { + const template = this.templates.get(msg.name); + if (!template) { + throw new Error(`Unknown prompt template: "${msg.name}"`); + } + + const variables = msg.variables ?? {}; + + const system = renderTemplate(template.system, variables); + const prompt = renderTemplate(template.prompt, variables); + + await responseProducer.send(requestId, { system, prompt }); + } catch (err) { + console.error(`[PromptTemplate] Error processing request:`, err); + + const message = err instanceof Error ? err.message : String(err); + await responseProducer.send(requestId, { + system: "", + prompt: "", + error: { type: "prompt-error", message }, + }); + } + } +} + +/** + * Simple template rendering: replaces {variable} placeholders with values. + * Unmatched placeholders are left as-is. + */ +function renderTemplate( + template: string, + variables: Record<string, string>, +): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => { + if (key in variables) { + return variables[key]; + } + return match; + }); +} + +export async function run(): Promise<void> { + await PromptTemplateService.launch("prompt"); +} diff --git a/ts/packages/flow/src/query/embeddings/qdrant-doc.ts b/ts/packages/flow/src/query/embeddings/qdrant-doc.ts new file mode 100644 index 00000000..80d8a87c --- /dev/null +++ b/ts/packages/flow/src/query/embeddings/qdrant-doc.ts @@ -0,0 +1,80 @@ +/** + * Qdrant document embeddings query service. + * + * Input: vector, user, collection, limit + * Output: list of { chunkId, score } matches + * + * Python reference: trustgraph-flow/trustgraph/query/doc_embeddings/qdrant/service.py + */ + +import { QdrantClient } from "@qdrant/js-client-rest"; + +export interface QdrantDocQueryConfig { + url?: string; + apiKey?: string; +} + +export interface ChunkMatch { + chunkId: string; + score: number; +} + +export interface DocEmbeddingsQueryRequest { + vector: number[]; + user: string; + collection: string; + limit: number; +} + +export class QdrantDocEmbeddingsQuery { + private client: QdrantClient; + + constructor(config: QdrantDocQueryConfig = {}) { + const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; + const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY; + + this.client = new QdrantClient({ url, apiKey }); + + console.log("[QdrantDocQuery] Query service initialized"); + } + + async query(request: DocEmbeddingsQueryRequest): Promise<ChunkMatch[]> { + const { vector, user, collection, limit } = request; + + if (!vector || vector.length === 0) { + return []; + } + + const dim = vector.length; + const collectionName = `d_${user}_${collection}_${dim}`; + + // Check if collection exists -- return empty if not + const exists = await this.client.collectionExists(collectionName); + if (!exists.exists) { + console.log( + `[QdrantDocQuery] Collection ${collectionName} does not exist, returning empty results`, + ); + return []; + } + + const searchResult = await this.client.search(collectionName, { + vector, + limit, + with_payload: true, + }); + + const chunks: ChunkMatch[] = []; + for (const point of searchResult) { + const payload = point.payload as Record<string, unknown> | undefined; + const chunkId = payload?.chunk_id as string | undefined; + if (chunkId) { + chunks.push({ + chunkId, + score: point.score, + }); + } + } + + return chunks; + } +} diff --git a/ts/packages/flow/src/query/embeddings/qdrant-graph.ts b/ts/packages/flow/src/query/embeddings/qdrant-graph.ts new file mode 100644 index 00000000..3f26f742 --- /dev/null +++ b/ts/packages/flow/src/query/embeddings/qdrant-graph.ts @@ -0,0 +1,103 @@ +/** + * Qdrant graph embeddings query service. + * + * Input: vector, user, collection, limit + * Output: list of Term entities with scores, deduplicated by entity value + * + * Queries limit*2 points and deduplicates by entity value to ensure + * we return up to `limit` unique entities. + * + * Python reference: trustgraph-flow/trustgraph/query/graph_embeddings/qdrant/service.py + */ + +import { QdrantClient } from "@qdrant/js-client-rest"; +import type { Term } from "@trustgraph/base"; + +export interface QdrantGraphQueryConfig { + url?: string; + apiKey?: string; +} + +export interface EntityMatch { + entity: Term; + score: number; +} + +export interface GraphEmbeddingsQueryRequest { + vector: number[]; + user: string; + collection: string; + limit: number; +} + +function createTerm(value: string): Term { + if (value.startsWith("http://") || value.startsWith("https://")) { + return { type: "IRI", iri: value }; + } + return { type: "LITERAL", value }; +} + +export class QdrantGraphEmbeddingsQuery { + private client: QdrantClient; + + constructor(config: QdrantGraphQueryConfig = {}) { + const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; + const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY; + + this.client = new QdrantClient({ url, apiKey }); + + console.log("[QdrantGraphQuery] Query service initialized"); + } + + async query(request: GraphEmbeddingsQueryRequest): Promise<EntityMatch[]> { + const { vector, user, collection, limit } = request; + + if (!vector || vector.length === 0) { + return []; + } + + const dim = vector.length; + const collectionName = `t_${user}_${collection}_${dim}`; + + // Check if collection exists -- return empty if not + const exists = await this.client.collectionExists(collectionName); + if (!exists.exists) { + console.log( + `[QdrantGraphQuery] Collection ${collectionName} does not exist, returning empty results`, + ); + return []; + } + + // Query 2x the limit so we have a better chance of getting `limit` + // unique entities after deduplication (same heuristic as Python impl) + const searchResult = await this.client.search(collectionName, { + vector, + limit: limit * 2, + with_payload: true, + }); + + const entitySet = new Set<string>(); + const entities: EntityMatch[] = []; + + for (const point of searchResult) { + const payload = point.payload as Record<string, unknown> | undefined; + const entityValue = payload?.entity as string | undefined; + if (!entityValue) continue; + + // Deduplicate by entity value, keeping the highest score (results are + // already sorted by score descending from Qdrant) + if (!entitySet.has(entityValue)) { + entitySet.add(entityValue); + entities.push({ + entity: createTerm(entityValue), + score: point.score, + }); + } + + // Stop once we have enough unique entities + if (entities.length >= limit) break; + } + + return entities; + } +} diff --git a/ts/packages/flow/src/query/triples/falkordb.ts b/ts/packages/flow/src/query/triples/falkordb.ts new file mode 100644 index 00000000..bffececa --- /dev/null +++ b/ts/packages/flow/src/query/triples/falkordb.ts @@ -0,0 +1,243 @@ +/** + * FalkorDB triples query service — queries RDF triples from FalkorDB. + * + * Implements all SPO query patterns (S, P, O, SP, SO, PO, SPO, *). + * + * Python reference: trustgraph-flow/trustgraph/query/triples/falkordb/service.py + */ + +import { createClient, Graph } from "falkordb"; +import type { Term, Triple } from "@trustgraph/base"; + +export interface FalkorDBQueryConfig { + url?: string; + database?: string; +} + +function termToValue(term: Term | undefined): string | null { + if (!term) return null; + switch (term.type) { + case "IRI": return term.iri; + case "LITERAL": return term.value; + case "BLANK": return term.id; + default: return null; + } +} + +function createTerm(value: string): Term { + if (value.startsWith("http://") || value.startsWith("https://")) { + return { type: "IRI", iri: value }; + } + return { type: "LITERAL", value }; +} + +export class FalkorDBTriplesQuery { + private graph: Graph; + + constructor(config: FalkorDBQueryConfig = {}) { + const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379"; + const database = config.database ?? "falkordb"; + + const client = createClient({ url }); + this.graph = new Graph(client, database); + } + + async queryTriples( + s?: Term, + p?: Term, + o?: Term, + limit = 100, + ): Promise<Triple[]> { + const sv = termToValue(s); + const pv = termToValue(p); + const ov = termToValue(o); + + const rawTriples: [string, string, string][] = []; + + // Query both Node and Literal targets for each pattern + if (sv && pv && ov) { + // SPO — exact match + await this.matchPattern(rawTriples, sv, pv, ov, limit); + } else if (sv && pv) { + // SP — known subject + predicate + await this.matchSP(rawTriples, sv, pv, limit); + } else if (sv && ov) { + // SO — known subject + object + await this.matchSO(rawTriples, sv, ov, limit); + } else if (pv && ov) { + // PO — known predicate + object + await this.matchPO(rawTriples, pv, ov, limit); + } else if (sv) { + // S only + await this.matchS(rawTriples, sv, limit); + } else if (pv) { + // P only + await this.matchP(rawTriples, pv, limit); + } else if (ov) { + // O only + await this.matchO(rawTriples, ov, limit); + } else { + // Wildcard — all triples + await this.matchAll(rawTriples, limit); + } + + return rawTriples.slice(0, limit).map(([s, p, o]) => ({ + s: createTerm(s), + p: createTerm(p), + o: createTerm(o), + })); + } + + private async matchPattern( + out: [string, string, string][], + sv: string, pv: string, ov: string, limit: number, + ): Promise<void> { + for (const destType of ["Literal", "Node"] as const) { + const destKey = destType === "Literal" ? "value" : "uri"; + const result = await this.graph.query( + `MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` + + `RETURN src.uri LIMIT ${limit}`, + { params: { src: sv, rel: pv, dest: ov } }, + ); + for (const _rec of (result.data ?? []) as unknown[][]) { + out.push([sv, pv, ov]); + } + } + } + + private async matchSP( + out: [string, string, string][], + sv: string, pv: string, limit: number, + ): Promise<void> { + // Literals + const litResult = await this.graph.query( + `MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Literal) ` + + `RETURN dest.value as dest LIMIT ${limit}`, + { params: { src: sv, rel: pv } }, + ); + for (const rec of (litResult.data ?? []) as string[][]) { + out.push([sv, pv, rec[0] as string]); + } + // Nodes + const nodeResult = await this.graph.query( + `MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Node) ` + + `RETURN dest.uri as dest LIMIT ${limit}`, + { params: { src: sv, rel: pv } }, + ); + for (const rec of (nodeResult.data ?? []) as string[][]) { + out.push([sv, pv, rec[0] as string]); + } + } + + private async matchSO( + out: [string, string, string][], + sv: string, ov: string, limit: number, + ): Promise<void> { + for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) { + const result = await this.graph.query( + `MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` + + `RETURN rel.uri as rel LIMIT ${limit}`, + { params: { src: sv, dest: ov } }, + ); + for (const rec of (result.data ?? []) as string[][]) { + out.push([sv, rec[0] as string, ov]); + } + } + } + + private async matchPO( + out: [string, string, string][], + pv: string, ov: string, limit: number, + ): Promise<void> { + for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) { + const result = await this.graph.query( + `MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` + + `RETURN src.uri as src LIMIT ${limit}`, + { params: { rel: pv, dest: ov } }, + ); + for (const rec of (result.data ?? []) as string[][]) { + out.push([rec[0] as string, pv, ov]); + } + } + } + + private async matchS( + out: [string, string, string][], + sv: string, limit: number, + ): Promise<void> { + const litResult = await this.graph.query( + `MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Literal) ` + + `RETURN rel.uri as rel, dest.value as dest LIMIT ${limit}`, + { params: { src: sv } }, + ); + for (const rec of (litResult.data ?? []) as string[][]) { + out.push([sv, rec[0] as string, rec[1] as string]); + } + const nodeResult = await this.graph.query( + `MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Node) ` + + `RETURN rel.uri as rel, dest.uri as dest LIMIT ${limit}`, + { params: { src: sv } }, + ); + for (const rec of (nodeResult.data ?? []) as string[][]) { + out.push([sv, rec[0] as string, rec[1] as string]); + } + } + + private async matchP( + out: [string, string, string][], + pv: string, limit: number, + ): Promise<void> { + const litResult = await this.graph.query( + `MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Literal) ` + + `RETURN src.uri as src, dest.value as dest LIMIT ${limit}`, + { params: { rel: pv } }, + ); + for (const rec of (litResult.data ?? []) as string[][]) { + out.push([rec[0] as string, pv, rec[1] as string]); + } + const nodeResult = await this.graph.query( + `MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Node) ` + + `RETURN src.uri as src, dest.uri as dest LIMIT ${limit}`, + { params: { rel: pv } }, + ); + for (const rec of (nodeResult.data ?? []) as string[][]) { + out.push([rec[0] as string, pv, rec[1] as string]); + } + } + + private async matchO( + out: [string, string, string][], + ov: string, limit: number, + ): Promise<void> { + for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) { + const result = await this.graph.query( + `MATCH (src:Node)-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` + + `RETURN src.uri as src, rel.uri as rel LIMIT ${limit}`, + { params: { dest: ov } }, + ); + for (const rec of (result.data ?? []) as string[][]) { + out.push([rec[0] as string, rec[1] as string, ov]); + } + } + } + + private async matchAll( + out: [string, string, string][], + limit: number, + ): Promise<void> { + const litResult = await this.graph.query( + `MATCH (src:Node)-[rel:Rel]->(dest:Literal) ` + + `RETURN src.uri as src, rel.uri as rel, dest.value as dest LIMIT ${limit}`, + ); + for (const rec of (litResult.data ?? []) as string[][]) { + out.push([rec[0] as string, rec[1] as string, rec[2] as string]); + } + const nodeResult = await this.graph.query( + `MATCH (src:Node)-[rel:Rel]->(dest:Node) ` + + `RETURN src.uri as src, rel.uri as rel, dest.uri as dest LIMIT ${limit}`, + ); + for (const rec of (nodeResult.data ?? []) as string[][]) { + out.push([rec[0] as string, rec[1] as string, rec[2] as string]); + } + } +} diff --git a/ts/packages/flow/src/retrieval/graph-rag.ts b/ts/packages/flow/src/retrieval/graph-rag.ts index 95902cb5..6d173651 100644 --- a/ts/packages/flow/src/retrieval/graph-rag.ts +++ b/ts/packages/flow/src/retrieval/graph-rag.ts @@ -124,26 +124,156 @@ export class GraphRag { } private async followEdges(entities: Term[]): Promise<Triple[]> { - // Batch triple queries for all entities - const allTriples: Triple[] = []; + // BFS multi-hop traversal up to maxPathLength + const visited = new Set<string>(); + const subgraph: Triple[] = []; - const queries = entities.map((entity) => - this.clients.triples.request({ s: entity, limit: this.config.tripleLimit }), + // Current frontier: the set of entities to expand at this depth level + let currentLevel = new Set<string>( + entities.map((e) => termToString(e)), ); - const results = await Promise.all(queries); - for (const result of results) { - allTriples.push(...(result as TriplesQueryResponse).triples); + for (let depth = 0; depth < this.config.maxPathLength; depth++) { + if (currentLevel.size === 0 || subgraph.length >= this.config.maxSubgraphSize) { + break; + } + + // Filter out already-visited entities + const unvisited = [...currentLevel].filter((e) => !visited.has(e)); + if (unvisited.length === 0) break; + + // Batch triple queries for all unvisited entities at this depth + // Query each entity as subject to get outgoing edges + const queries = unvisited.map((entityStr) => { + const term = stringToTerm(entityStr); + return this.clients.triples.request({ + s: term, + limit: this.config.tripleLimit, + }); + }); + + const results = await Promise.all(queries); + + const nextLevel = new Set<string>(); + + for (const result of results) { + const triples = (result as TriplesQueryResponse).triples; + for (const triple of triples) { + subgraph.push(triple); + + // Collect objects as next-level entities for further expansion + // (only if we have more depth levels remaining) + if (depth < this.config.maxPathLength - 1) { + const objStr = termToString(triple.o); + if (!visited.has(objStr)) { + nextLevel.add(objStr); + } + } + + if (subgraph.length >= this.config.maxSubgraphSize) { + return subgraph; + } + } + } + + // Mark current level as visited and move to next + for (const e of currentLevel) { + visited.add(e); + } + currentLevel = nextLevel; } - // TODO: Multi-hop traversal up to maxPathLength - return allTriples.slice(0, this.config.maxSubgraphSize); + return subgraph.slice(0, this.config.maxSubgraphSize); } private async scoreEdges(query: string, triples: Triple[]): Promise<Triple[]> { - // TODO: LLM-based edge scoring and filtering - // For now, return top N edges - return triples.slice(0, this.config.edgeLimit); + if (triples.length === 0) return []; + + // If the subgraph is small enough, skip LLM scoring entirely + if (triples.length <= this.config.edgeLimit) { + return triples; + } + + // Build a numbered list of edges for the LLM to score + const edgeDescriptions = triples.map((t, i) => ({ + id: String(i), + s: termToString(t.s), + p: termToString(t.p), + o: termToString(t.o), + })); + + // Limit how many edges we send for scoring to avoid overflowing context + const toScore = edgeDescriptions.slice(0, this.config.edgeScoreLimit); + + const knowledgeJson = JSON.stringify(toScore, null, 2); + + // Ask the LLM to score each edge for relevance to the query + const promptResp = await this.clients.prompt.request({ + name: "kg-edge-scoring", + variables: { + query, + knowledge: knowledgeJson, + }, + }); + + const llmResp = await this.clients.llm.request({ + system: (promptResp as PromptResponse).system, + prompt: (promptResp as PromptResponse).prompt, + }); + + const responseText = (llmResp as TextCompletionResponse).response; + + // Parse scores from LLM response + // Expected format: JSON array of { id: string, score: number } + // or newline-separated JSON objects + const scored: Array<{ id: string; score: number }> = []; + + try { + // Try parsing as a JSON array first + const parsed = JSON.parse(responseText) as Array<{ id: string; score: number }>; + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (item && typeof item.id === "string" && typeof item.score === "number") { + scored.push({ id: item.id, score: item.score }); + } + } + } + } catch { + // Fall back to parsing line-by-line JSON objects + for (const line of responseText.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const obj = JSON.parse(trimmed) as { id?: string; score?: number }; + if (obj && typeof obj.id === "string" && typeof obj.score === "number") { + scored.push({ id: obj.id, score: obj.score }); + } + } catch { + // Skip unparseable lines + } + } + } + + // Sort by score descending and keep top N + scored.sort((a, b) => b.score - a.score); + const topN = scored.slice(0, this.config.edgeLimit); + const selectedIds = new Set(topN.map((e) => e.id)); + + // Map back to triples + const result: Triple[] = []; + for (const entry of topN) { + const idx = parseInt(entry.id, 10); + if (!isNaN(idx) && idx >= 0 && idx < triples.length) { + result.push(triples[idx]); + } + } + + // If scoring failed entirely, fall back to returning the first edgeLimit triples + if (result.length === 0) { + return triples.slice(0, this.config.edgeLimit); + } + + return result; } private async synthesize( @@ -205,3 +335,13 @@ function termToString(term: Term): string { return `(${termToString(term.triple.s)} ${termToString(term.triple.p)} ${termToString(term.triple.o)})`; } } + +function stringToTerm(value: string): Term { + if (value.startsWith("http://") || value.startsWith("https://")) { + return { type: "IRI", iri: value }; + } + if (value.startsWith("_:")) { + return { type: "BLANK", id: value.slice(2) }; + } + return { type: "LITERAL", value }; +} diff --git a/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts b/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts new file mode 100644 index 00000000..a133175a --- /dev/null +++ b/ts/packages/flow/src/storage/embeddings/qdrant-doc.ts @@ -0,0 +1,106 @@ +/** + * Qdrant document embeddings write service. + * + * Stores document chunk embeddings in Qdrant for later similarity search. + * Collection naming: d_{user}_{collection}_{dimension} + * Collections are lazily created on first write with cosine distance. + * + * Python reference: trustgraph-flow/trustgraph/storage/doc_embeddings/qdrant/write.py + */ + +import { QdrantClient } from "@qdrant/js-client-rest"; +import { randomUUID } from "node:crypto"; + +export interface QdrantDocEmbeddingsConfig { + url?: string; + apiKey?: string; +} + +export interface DocEmbeddingChunk { + chunkId: string; + vector: number[]; +} + +export interface DocEmbeddingsMessage { + user: string; + collection: string; + chunks: DocEmbeddingChunk[]; +} + +export class QdrantDocEmbeddingsStore { + private client: QdrantClient; + private knownCollections = new Set<string>(); + + constructor(config: QdrantDocEmbeddingsConfig = {}) { + const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; + const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY; + + this.client = new QdrantClient({ url, apiKey }); + + console.log("[QdrantDocEmbeddings] Store initialized"); + } + + private collectionName(user: string, collection: string, dim: number): string { + return `d_${user}_${collection}_${dim}`; + } + + private async ensureCollection(name: string, dim: number): Promise<void> { + if (this.knownCollections.has(name)) return; + + const exists = await this.client.collectionExists(name); + if (!exists.exists) { + console.log(`[QdrantDocEmbeddings] Creating collection ${name} (dim=${dim})`); + await this.client.createCollection(name, { + vectors: { size: dim, distance: "Cosine" }, + }); + } + + this.knownCollections.add(name); + } + + async store(message: DocEmbeddingsMessage): Promise<void> { + for (const chunk of message.chunks) { + if (!chunk.chunkId || chunk.chunkId === "") continue; + if (!chunk.vector || chunk.vector.length === 0) continue; + + const dim = chunk.vector.length; + const name = this.collectionName(message.user, message.collection, dim); + + await this.ensureCollection(name, dim); + + await this.client.upsert(name, { + points: [ + { + id: randomUUID(), + vector: chunk.vector, + payload: { chunk_id: chunk.chunkId }, + }, + ], + }); + } + } + + async deleteCollection(user: string, collection: string): Promise<void> { + const prefix = `d_${user}_${collection}_`; + + const allCollections = await this.client.getCollections(); + const matching = allCollections.collections.filter((c) => + c.name.startsWith(prefix), + ); + + if (matching.length === 0) { + console.log(`[QdrantDocEmbeddings] No collections matching prefix ${prefix}`); + return; + } + + for (const coll of matching) { + await this.client.deleteCollection(coll.name); + this.knownCollections.delete(coll.name); + console.log(`[QdrantDocEmbeddings] Deleted collection: ${coll.name}`); + } + + console.log( + `[QdrantDocEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`, + ); + } +} diff --git a/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts b/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts new file mode 100644 index 00000000..7b6c27ef --- /dev/null +++ b/ts/packages/flow/src/storage/embeddings/qdrant-graph.ts @@ -0,0 +1,127 @@ +/** + * Qdrant graph embeddings write service. + * + * Stores entity/vector pairs in Qdrant for graph embeddings lookup. + * Collection naming: t_{user}_{collection}_{dimension} + * Collections are lazily created on first write with cosine distance. + * + * Python reference: trustgraph-flow/trustgraph/storage/graph_embeddings/qdrant/write.py + */ + +import { QdrantClient } from "@qdrant/js-client-rest"; +import { randomUUID } from "node:crypto"; +import type { Term } from "@trustgraph/base"; + +export interface QdrantGraphEmbeddingsConfig { + url?: string; + apiKey?: string; +} + +export interface GraphEmbeddingEntity { + entity: Term; + vector: number[]; + chunkId?: string; +} + +export interface GraphEmbeddingsMessage { + user: string; + collection: string; + entities: GraphEmbeddingEntity[]; +} + +function getTermValue(term: Term): string | null { + switch (term.type) { + case "IRI": + return term.iri; + case "LITERAL": + return term.value; + case "BLANK": + return term.id; + case "TRIPLE": + return null; + } +} + +export class QdrantGraphEmbeddingsStore { + private client: QdrantClient; + private knownCollections = new Set<string>(); + + constructor(config: QdrantGraphEmbeddingsConfig = {}) { + const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; + const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY; + + this.client = new QdrantClient({ url, apiKey }); + + console.log("[QdrantGraphEmbeddings] Store initialized"); + } + + private collectionName(user: string, collection: string, dim: number): string { + return `t_${user}_${collection}_${dim}`; + } + + private async ensureCollection(name: string, dim: number): Promise<void> { + if (this.knownCollections.has(name)) return; + + const exists = await this.client.collectionExists(name); + if (!exists.exists) { + console.log(`[QdrantGraphEmbeddings] Creating collection ${name} (dim=${dim})`); + await this.client.createCollection(name, { + vectors: { size: dim, distance: "Cosine" }, + }); + } + + this.knownCollections.add(name); + } + + async store(message: GraphEmbeddingsMessage): Promise<void> { + for (const entry of message.entities) { + const entityValue = getTermValue(entry.entity); + if (!entityValue || entityValue === "") continue; + if (!entry.vector || entry.vector.length === 0) continue; + + const dim = entry.vector.length; + const name = this.collectionName(message.user, message.collection, dim); + + await this.ensureCollection(name, dim); + + const payload: Record<string, unknown> = { entity: entityValue }; + if (entry.chunkId) { + payload.chunk_id = entry.chunkId; + } + + await this.client.upsert(name, { + points: [ + { + id: randomUUID(), + vector: entry.vector, + payload, + }, + ], + }); + } + } + + async deleteCollection(user: string, collection: string): Promise<void> { + const prefix = `t_${user}_${collection}_`; + + const allCollections = await this.client.getCollections(); + const matching = allCollections.collections.filter((c) => + c.name.startsWith(prefix), + ); + + if (matching.length === 0) { + console.log(`[QdrantGraphEmbeddings] No collections matching prefix ${prefix}`); + return; + } + + for (const coll of matching) { + await this.client.deleteCollection(coll.name); + this.knownCollections.delete(coll.name); + console.log(`[QdrantGraphEmbeddings] Deleted collection: ${coll.name}`); + } + + console.log( + `[QdrantGraphEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`, + ); + } +} diff --git a/ts/packages/flow/src/storage/triples/falkordb.ts b/ts/packages/flow/src/storage/triples/falkordb.ts new file mode 100644 index 00000000..d293a27d --- /dev/null +++ b/ts/packages/flow/src/storage/triples/falkordb.ts @@ -0,0 +1,116 @@ +/** + * FalkorDB triples store — writes RDF triples to a FalkorDB graph. + * + * FalkorDB is Redis-based and uses Cypher queries, same as the Python impl. + * Pairs well with Graphiti which also uses FalkorDB as its backend. + * + * Python reference: trustgraph-flow/trustgraph/storage/triples/falkordb/write.py + */ + +import { createClient, Graph } from "falkordb"; +import type { Term, Triple } from "@trustgraph/base"; + +export interface FalkorDBConfig { + url?: string; + database?: string; +} + +function getTermValue(term: Term): string { + switch (term.type) { + case "IRI": + return term.iri; + case "LITERAL": + return term.value; + case "BLANK": + return term.id; + case "TRIPLE": + return getTermValue(term.triple.s); // fallback + } +} + +export class FalkorDBTriplesStore { + private graph: Graph; + + constructor(config: FalkorDBConfig = {}) { + const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379"; + const database = config.database ?? "falkordb"; + + const client = createClient({ url }); + this.graph = new Graph(client, database); + } + + async createNode(uri: string, user: string, collection: string): Promise<void> { + await this.graph.query( + "MERGE (n:Node {uri: $uri, user: $user, collection: $collection})", + { params: { uri, user, collection } }, + ); + } + + async createLiteral(value: string, user: string, collection: string): Promise<void> { + await this.graph.query( + "MERGE (n:Literal {value: $value, user: $user, collection: $collection})", + { params: { value, user, collection } }, + ); + } + + async relateNode( + src: string, uri: string, dest: string, + user: string, collection: string, + ): Promise<void> { + await this.graph.query( + "MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " + + "MATCH (dest:Node {uri: $dest, user: $user, collection: $collection}) " + + "MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)", + { params: { src, dest, uri, user, collection } }, + ); + } + + async relateLiteral( + src: string, uri: string, dest: string, + user: string, collection: string, + ): Promise<void> { + await this.graph.query( + "MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " + + "MATCH (dest:Literal {value: $dest, user: $user, collection: $collection}) " + + "MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)", + { params: { src, dest, uri, user, collection } }, + ); + } + + async storeTriples( + triples: Triple[], + user = "default", + collection = "default", + ): Promise<void> { + for (const t of triples) { + const s = getTermValue(t.s); + const p = getTermValue(t.p); + const o = getTermValue(t.o); + + await this.createNode(s, user, collection); + + if (t.o.type === "IRI") { + await this.createNode(o, user, collection); + await this.relateNode(s, p, o, user, collection); + } else { + await this.createLiteral(o, user, collection); + await this.relateLiteral(s, p, o, user, collection); + } + } + } + + async deleteCollection(user: string, collection: string): Promise<void> { + await this.graph.query( + "MATCH (n:Node {user: $user, collection: $collection}) DETACH DELETE n", + { params: { user, collection } }, + ); + await this.graph.query( + "MATCH (n:Literal {user: $user, collection: $collection}) DETACH DELETE n", + { params: { user, collection } }, + ); + await this.graph.query( + "MATCH (c:CollectionMetadata {user: $user, collection: $collection}) DELETE c", + { params: { user, collection } }, + ); + } +} diff --git a/ts/packages/flow/tsconfig.json b/ts/packages/flow/tsconfig.json index 6e302dec..dc82eac5 100644 --- a/ts/packages/flow/tsconfig.json +++ b/ts/packages/flow/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "composite": true }, "include": ["src"], "references": [ diff --git a/ts/packages/mcp/package.json b/ts/packages/mcp/package.json index e7ac4055..2d3430cf 100644 --- a/ts/packages/mcp/package.json +++ b/ts/packages/mcp/package.json @@ -12,11 +12,12 @@ }, "dependencies": { "@trustgraph/base": "workspace:*", + "@trustgraph/client": "workspace:*", "@modelcontextprotocol/sdk": "^1.8.0", - "ws": "^8.18.0" + "zod": "^3.23.0" }, "devDependencies": { - "@types/ws": "^8.5.0", + "@types/node": "^22.0.0", "typescript": "^5.8.0", "vitest": "^3.1.0" } diff --git a/ts/packages/mcp/src/index.ts b/ts/packages/mcp/src/index.ts index ef270139..064d8e71 100644 --- a/ts/packages/mcp/src/index.ts +++ b/ts/packages/mcp/src/index.ts @@ -1,2 +1 @@ export { createMcpServer, run } from "./server.js"; -export { SocketManager, type SocketManagerConfig } from "./socket-manager.js"; diff --git a/ts/packages/mcp/src/server.ts b/ts/packages/mcp/src/server.ts index 7e651f05..9ca8f2a4 100644 --- a/ts/packages/mcp/src/server.ts +++ b/ts/packages/mcp/src/server.ts @@ -2,7 +2,7 @@ * TrustGraph MCP server. * * Exposes TrustGraph capabilities as MCP tools for AI assistants. - * Communicates with the TrustGraph gateway via WebSocket. + * Uses the vendored @trustgraph/client for all gateway communication. * * Python reference: trustgraph-mcp/trustgraph/mcp_server/mcp.py */ @@ -10,10 +10,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; -import { SocketManager } from "./socket-manager.js"; +import { createTrustGraphSocket, type BaseApi, type Term } from "@trustgraph/client"; export function createMcpServer(config: { gatewayUrl: string; + user?: string; token?: string; flowId?: string; }) { @@ -22,13 +23,17 @@ export function createMcpServer(config: { version: "0.1.0", }); - const socket = new SocketManager({ - gatewayUrl: config.gatewayUrl, - token: config.token, - }); + const user = config.user ?? "mcp"; + const socket: BaseApi = createTrustGraphSocket( + user, + config.token, + config.gatewayUrl, + ); const flowId = config.flowId ?? "default"; + // ===================== Flow-scoped tools ===================== + // --- Text Completion --- server.tool( "text_completion", @@ -38,8 +43,9 @@ export function createMcpServer(config: { prompt: z.string().describe("User prompt"), }, async ({ system, prompt }) => { - const resp = await socket.request("text-completion", { system, prompt }, { flowId }) as Record<string, unknown>; - return { content: [{ type: "text" as const, text: String(resp.response ?? resp) }] }; + const flow = socket.flow(flowId); + const response = await flow.textCompletion(system, prompt); + return { content: [{ type: "text" as const, text: response }] }; }, ); @@ -51,14 +57,32 @@ export function createMcpServer(config: { query: z.string().describe("Natural language query"), entity_limit: z.number().optional().describe("Max entities to retrieve"), triple_limit: z.number().optional().describe("Max triples per entity"), + collection: z.string().optional().describe("Collection name"), }, - async ({ query, entity_limit, triple_limit }) => { - const resp = await socket.request( - "graph-rag", - { query, entity_limit, triple_limit }, - { flowId }, - ) as Record<string, unknown>; - return { content: [{ type: "text" as const, text: String(resp.response ?? resp) }] }; + async ({ query, entity_limit, triple_limit, collection }) => { + const flow = socket.flow(flowId); + const response = await flow.graphRag( + query, + { entityLimit: entity_limit, tripleLimit: triple_limit }, + collection, + ); + return { content: [{ type: "text" as const, text: response }] }; + }, + ); + + // --- Document RAG --- + server.tool( + "document_rag", + "Query documents using RAG", + { + query: z.string().describe("Natural language query"), + doc_limit: z.number().optional().describe("Max documents to retrieve"), + collection: z.string().optional().describe("Collection name"), + }, + async ({ query, doc_limit, collection }) => { + const flow = socket.flow(flowId); + const response = await flow.documentRag(query, doc_limit, collection); + return { content: [{ type: "text" as const, text: response }] }; }, ); @@ -70,8 +94,23 @@ export function createMcpServer(config: { question: z.string().describe("Question for the agent"), }, async ({ question }) => { - const resp = await socket.request("agent", { question }, { flowId }) as Record<string, unknown>; - return { content: [{ type: "text" as const, text: String(resp.answer ?? resp) }] }; + const flow = socket.flow(flowId); + let fullAnswer = ""; + + await new Promise<void>((resolve, reject) => { + flow.agent( + question, + () => {}, // think — ignore for MCP + () => {}, // observe — ignore for MCP + (chunk, complete) => { + fullAnswer += chunk; + if (complete) resolve(); + }, + (err) => reject(new Error(err)), + ); + }); + + return { content: [{ type: "text" as const, text: fullAnswer }] }; }, ); @@ -83,8 +122,9 @@ export function createMcpServer(config: { text: z.array(z.string()).describe("Texts to embed"), }, async ({ text }) => { - const resp = await socket.request("embeddings", { text }, { flowId }) as Record<string, unknown>; - return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; + const flow = socket.flow(flowId); + const vectors = await flow.embeddings(text); + return { content: [{ type: "text" as const, text: JSON.stringify(vectors) }] }; }, ); @@ -97,15 +137,15 @@ export function createMcpServer(config: { p: z.string().optional().describe("Predicate IRI"), o: z.string().optional().describe("Object IRI or literal"), limit: z.number().optional().describe("Max results"), + collection: z.string().optional().describe("Collection name"), }, - async ({ s, p, o, limit }) => { - const request: Record<string, unknown> = { limit }; - if (s) request.s = { type: "IRI", iri: s }; - if (p) request.p = { type: "IRI", iri: p }; - if (o) request.o = { type: "IRI", iri: o }; - - const resp = await socket.request("triples-query", request, { flowId }) as Record<string, unknown>; - return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; + async ({ s, p, o, limit, collection }) => { + const flow = socket.flow(flowId); + const sTerm: Term | undefined = s ? { t: "i", i: s } : undefined; + const pTerm: Term | undefined = p ? { t: "i", i: p } : undefined; + const oTerm: Term | undefined = o ? { t: "i", i: o } : undefined; + const triples = await flow.triplesQuery(sTerm, pTerm, oTerm, limit, collection); + return { content: [{ type: "text" as const, text: JSON.stringify(triples, null, 2) }] }; }, ); @@ -116,28 +156,48 @@ export function createMcpServer(config: { { query: z.string().describe("Text to find similar entities for"), limit: z.number().optional().describe("Max results"), + collection: z.string().optional().describe("Collection name"), }, - async ({ query, limit }) => { + async ({ query, limit, collection }) => { + const flow = socket.flow(flowId); // First embed the query, then search - const embResp = await socket.request("embeddings", { text: [query] }, { flowId }) as { vectors: number[][] }; - const resp = await socket.request( - "graph-embeddings-query", - { vectors: embResp.vectors, limit: limit ?? 10 }, - { flowId }, - ) as Record<string, unknown>; + const vectors = await flow.embeddings([query]); + const entities = await flow.graphEmbeddingsQuery( + vectors[0], + limit ?? 10, + collection, + ); + return { content: [{ type: "text" as const, text: JSON.stringify(entities, null, 2) }] }; + }, + ); + + // ===================== Config tools ===================== + + server.tool( + "get_config_all", + "Get all configuration values", + {}, + async () => { + const cfg = socket.config(); + const resp = await cfg.getConfigAll(); return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; }, ); - // --- Config --- server.tool( "get_config", - "Get configuration values", + "Get specific configuration values", { - keys: z.array(z.string()).describe("Config keys to retrieve"), + keys: z.array( + z.object({ + type: z.string().describe("Config type"), + key: z.string().describe("Config key"), + }), + ).describe("Config keys to retrieve"), }, async ({ keys }) => { - const resp = await socket.request("config", { operation: "get", keys }) as Record<string, unknown>; + const cfg = socket.config(); + const resp = await cfg.getConfig(keys); return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; }, ); @@ -146,10 +206,206 @@ export function createMcpServer(config: { "put_config", "Set configuration values", { - values: z.record(z.unknown()).describe("Key-value pairs to set"), + values: z.array( + z.object({ + type: z.string().describe("Config type"), + key: z.string().describe("Config key"), + value: z.string().describe("Config value (JSON-encoded)"), + }), + ).describe("Key-value entries to set"), }, async ({ values }) => { - const resp = await socket.request("config", { operation: "put", values }) as Record<string, unknown>; + const cfg = socket.config(); + const resp = await cfg.putConfig(values); + return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; + }, + ); + + server.tool( + "delete_config", + "Delete a configuration entry", + { + type: z.string().describe("Config type"), + key: z.string().describe("Config key"), + }, + async ({ type, key }) => { + const cfg = socket.config(); + const resp = await cfg.deleteConfig({ type, key }); + return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; + }, + ); + + // ===================== Flow management tools ===================== + + server.tool( + "get_flows", + "List all available flows", + {}, + async () => { + const flows = socket.flows(); + const ids = await flows.getFlows(); + return { content: [{ type: "text" as const, text: JSON.stringify(ids, null, 2) }] }; + }, + ); + + server.tool( + "get_flow", + "Get a specific flow definition", + { + flow_id: z.string().describe("Flow ID to retrieve"), + }, + async ({ flow_id }) => { + const flows = socket.flows(); + const def = await flows.getFlow(flow_id); + return { content: [{ type: "text" as const, text: JSON.stringify(def, null, 2) }] }; + }, + ); + + server.tool( + "start_flow", + "Start a flow instance", + { + flow_id: z.string().describe("Flow ID"), + blueprint_name: z.string().describe("Blueprint name"), + description: z.string().describe("Flow description"), + parameters: z.record(z.unknown()).optional().describe("Optional flow parameters"), + }, + async ({ flow_id, blueprint_name, description, parameters }) => { + const flows = socket.flows(); + const resp = await flows.startFlow(flow_id, blueprint_name, description, parameters); + return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; + }, + ); + + server.tool( + "stop_flow", + "Stop a running flow", + { + flow_id: z.string().describe("Flow ID to stop"), + }, + async ({ flow_id }) => { + const flows = socket.flows(); + const resp = await flows.stopFlow(flow_id); + return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; + }, + ); + + // ===================== Library (document) tools ===================== + + server.tool( + "get_documents", + "List all documents in the library", + {}, + async () => { + const lib = socket.librarian(); + const docs = await lib.getDocuments(); + return { content: [{ type: "text" as const, text: JSON.stringify(docs, null, 2) }] }; + }, + ); + + server.tool( + "load_document", + "Upload a document to the library", + { + document: z.string().describe("Base64-encoded document content"), + mime_type: z.string().describe("Document MIME type"), + title: z.string().describe("Document title"), + comments: z.string().optional().describe("Additional comments"), + tags: z.array(z.string()).optional().describe("Document tags"), + id: z.string().optional().describe("Optional document ID"), + }, + async ({ document, mime_type, title, comments, tags, id }) => { + const lib = socket.librarian(); + const resp = await lib.loadDocument( + document, + mime_type, + title, + comments ?? "", + tags ?? [], + id, + ); + return { content: [{ type: "text" as const, text: JSON.stringify(resp, null, 2) }] }; + }, + ); + + server.tool( + "remove_document", + "Remove a document from the library", + { + id: z.string().describe("Document ID to remove"), + collection: z.string().optional().describe("Collection name"), + }, + async ({ id, collection }) => { + const lib = socket.librarian(); + const resp = await lib.removeDocument(id, collection); + return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; + }, + ); + + // ===================== Prompt tools ===================== + + server.tool( + "get_prompts", + "List available prompt templates", + {}, + async () => { + const cfg = socket.config(); + const prompts = await cfg.getPrompts(); + return { content: [{ type: "text" as const, text: JSON.stringify(prompts, null, 2) }] }; + }, + ); + + server.tool( + "get_prompt", + "Get a specific prompt template", + { + id: z.string().describe("Prompt template ID"), + }, + async ({ id }) => { + const cfg = socket.config(); + const prompt = await cfg.getPrompt(id); + return { content: [{ type: "text" as const, text: JSON.stringify(prompt, null, 2) }] }; + }, + ); + + // ===================== Knowledge core tools ===================== + + server.tool( + "get_knowledge_cores", + "List available knowledge graph cores", + {}, + async () => { + const knowledge = socket.knowledge(); + const cores = await knowledge.getKnowledgeCores(); + return { content: [{ type: "text" as const, text: JSON.stringify(cores, null, 2) }] }; + }, + ); + + server.tool( + "delete_kg_core", + "Delete a knowledge graph core", + { + id: z.string().describe("Knowledge core ID"), + collection: z.string().optional().describe("Collection name"), + }, + async ({ id, collection }) => { + const knowledge = socket.knowledge(); + const resp = await knowledge.deleteKgCore(id, collection); + return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; + }, + ); + + server.tool( + "load_kg_core", + "Load a knowledge graph core", + { + id: z.string().describe("Knowledge core ID"), + flow: z.string().describe("Flow to use for loading"), + collection: z.string().optional().describe("Collection name"), + }, + async ({ id, flow, collection }) => { + const knowledge = socket.knowledge(); + const resp = await knowledge.loadKgCore(id, flow, collection); return { content: [{ type: "text" as const, text: JSON.stringify(resp) }] }; }, ); @@ -160,6 +416,7 @@ export function createMcpServer(config: { export async function run(): Promise<void> { const { server, socket } = createMcpServer({ gatewayUrl: process.env.GATEWAY_URL ?? "ws://localhost:8088/api/v1/socket", + user: process.env.USER_ID ?? "mcp", token: process.env.GATEWAY_SECRET, flowId: process.env.FLOW_ID ?? "default", }); @@ -167,8 +424,8 @@ export async function run(): Promise<void> { const transport = new StdioServerTransport(); await server.connect(transport); - process.on("SIGINT", async () => { - await socket.close(); + process.on("SIGINT", () => { + socket.close(); process.exit(0); }); } diff --git a/ts/packages/mcp/src/socket-manager.ts b/ts/packages/mcp/src/socket-manager.ts deleted file mode 100644 index 78fdb7db..00000000 --- a/ts/packages/mcp/src/socket-manager.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * WebSocket manager for communicating with the TrustGraph gateway. - * - * Maintains a persistent connection per user and handles request/response - * correlation via UUIDs. - * - * Python reference: trustgraph-mcp/trustgraph/mcp_server/tg_socket.py - */ - -import WebSocket from "ws"; -import { randomUUID } from "node:crypto"; - -export interface SocketManagerConfig { - gatewayUrl: string; - token?: string; -} - -interface PendingRequest { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - responses: unknown[]; - streaming: boolean; - onChunk?: (chunk: unknown) => void; -} - -export class SocketManager { - private ws: WebSocket | null = null; - private pending = new Map<string, PendingRequest>(); - private connected = false; - - constructor(private readonly config: SocketManagerConfig) {} - - async connect(): Promise<void> { - if (this.connected) return; - - const url = new URL(this.config.gatewayUrl); - if (this.config.token) { - url.searchParams.set("token", this.config.token); - } - - return new Promise((resolve, reject) => { - this.ws = new WebSocket(url.toString()); - - this.ws.on("open", () => { - this.connected = true; - resolve(); - }); - - this.ws.on("error", (err) => { - if (!this.connected) reject(err); - else console.error("[SocketManager] WebSocket error:", err); - }); - - this.ws.on("message", (data) => { - try { - const msg = JSON.parse(data.toString()); - const { id, response, error, complete } = msg; - - const req = this.pending.get(id); - if (!req) return; - - if (error) { - req.reject(new Error(`${error.type}: ${error.message}`)); - this.pending.delete(id); - return; - } - - if (req.streaming && req.onChunk) { - req.onChunk(response); - } - - req.responses.push(response); - - if (complete) { - req.resolve(req.streaming ? req.responses : response); - this.pending.delete(id); - } - } catch (err) { - console.error("[SocketManager] Failed to parse message:", err); - } - }); - - this.ws.on("close", () => { - this.connected = false; - // Reject all pending requests - for (const [id, req] of this.pending) { - req.reject(new Error("WebSocket closed")); - } - this.pending.clear(); - }); - }); - } - - async request( - service: string, - requestData: Record<string, unknown>, - options?: { - flowId?: string; - timeoutMs?: number; - onChunk?: (chunk: unknown) => void; - }, - ): Promise<unknown> { - await this.connect(); - if (!this.ws) throw new Error("Not connected"); - - const id = randomUUID(); - const timeoutMs = options?.timeoutMs ?? 300_000; - - const msg = { - id, - service, - flow: options?.flowId ?? "default", - request: requestData, - }; - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Request timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - this.pending.set(id, { - resolve: (value) => { - clearTimeout(timer); - resolve(value); - }, - reject: (err) => { - clearTimeout(timer); - reject(err); - }, - responses: [], - streaming: !!options?.onChunk, - onChunk: options?.onChunk, - }); - - this.ws!.send(JSON.stringify(msg)); - }); - } - - async close(): Promise<void> { - if (this.ws) { - this.ws.close(); - this.ws = null; - this.connected = false; - } - } -} diff --git a/ts/packages/mcp/tsconfig.json b/ts/packages/mcp/tsconfig.json index 6e302dec..944bd1d6 100644 --- a/ts/packages/mcp/tsconfig.json +++ b/ts/packages/mcp/tsconfig.json @@ -2,10 +2,12 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "composite": true }, "include": ["src"], "references": [ - { "path": "../base" } + { "path": "../base" }, + { "path": "../client" } ] } diff --git a/ts/packages/workbench/index.html b/ts/packages/workbench/index.html new file mode 100644 index 00000000..608e523f --- /dev/null +++ b/ts/packages/workbench/index.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>TrustGraph Workbench + + +
+ + + diff --git a/ts/packages/workbench/package.json b/ts/packages/workbench/package.json new file mode 100644 index 00000000..06c48738 --- /dev/null +++ b/ts/packages/workbench/package.json @@ -0,0 +1,34 @@ +{ + "name": "@trustgraph/workbench", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.75.0", + "@trustgraph/client": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.0", + "lucide-react": "^0.513.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-force-graph-2d": "^1.29.1", + "react-markdown": "^10.1.0", + "react-router": "^7.6.0", + "tailwind-merge": "^3.3.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.5.0", + "tailwindcss": "^4.1.0", + "typescript": "^5.8.0", + "vite": "^6.3.0" + } +} diff --git a/ts/packages/workbench/src/App.tsx b/ts/packages/workbench/src/App.tsx new file mode 100644 index 00000000..ada5af84 --- /dev/null +++ b/ts/packages/workbench/src/App.tsx @@ -0,0 +1,27 @@ +import { BrowserRouter, Routes, Route, Navigate } from "react-router"; +import { RootLayout } from "@/components/layout/root-layout"; +import ChatPage from "@/pages/chat"; +import LibraryPage from "@/pages/library"; +import GraphPage from "@/pages/graph"; +import FlowsPage from "@/pages/flows"; +import SettingsPage from "@/pages/settings"; +import { NotificationToasts } from "@/components/notification-toasts"; + +export default function App() { + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + ); +} diff --git a/ts/packages/workbench/src/components/layout/flow-selector.tsx b/ts/packages/workbench/src/components/layout/flow-selector.tsx new file mode 100644 index 00000000..a1f9baf7 --- /dev/null +++ b/ts/packages/workbench/src/components/layout/flow-selector.tsx @@ -0,0 +1,25 @@ +import { Workflow, Database } from "lucide-react"; +import { useSessionStore } from "@/hooks/use-session-store"; +import { useSettings } from "@/providers/settings-provider"; + +/** + * Compact badge showing the active flow and collection. + * Will be expanded later into a popover picker. + */ +export function FlowSelector() { + const flowId = useSessionStore((s) => s.flowId); + const collection = useSettings((s) => s.settings.collection); + + return ( +
+ + + {collection} + + + + {flowId || ""} + +
+ ); +} diff --git a/ts/packages/workbench/src/components/layout/root-layout.tsx b/ts/packages/workbench/src/components/layout/root-layout.tsx new file mode 100644 index 00000000..18830791 --- /dev/null +++ b/ts/packages/workbench/src/components/layout/root-layout.tsx @@ -0,0 +1,45 @@ +import { Outlet } from "react-router"; +import { Sidebar } from "./sidebar"; +import { FlowSelector } from "./flow-selector"; +import { useProgressStore } from "@/hooks/use-progress-store"; + +/** + * Top loading bar -- shown when any global activity is in progress. + */ +function LoadingBar() { + const isLoading = useProgressStore((s) => s.isLoading); + + if (!isLoading) return null; + + return ( +
+
+
+ ); +} + +/** + * Root layout: fixed sidebar + scrollable main content area with a top bar. + */ +export function RootLayout() { + return ( +
+ {/* Global loading bar */} + + + + +
+ {/* Top bar */} +
+ +
+ + {/* Page content */} +
+ +
+
+
+ ); +} diff --git a/ts/packages/workbench/src/components/layout/sidebar.tsx b/ts/packages/workbench/src/components/layout/sidebar.tsx new file mode 100644 index 00000000..8907831f --- /dev/null +++ b/ts/packages/workbench/src/components/layout/sidebar.tsx @@ -0,0 +1,168 @@ +import { NavLink } from "react-router"; +import { + MessageSquareText, + LibraryBig, + Rotate3d, + Workflow, + Settings, + TestTube2, + Wifi, + WifiOff, + Database, + ChevronDown, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useConnectionState } from "@/providers/socket-provider"; +import { useSessionStore } from "@/hooks/use-session-store"; +import { useFlows } from "@/hooks/use-flows"; +import { useSettings } from "@/providers/settings-provider"; + +// --------------------------------------------------------------------------- +// Nav item +// --------------------------------------------------------------------------- + +interface NavItemProps { + to: string; + icon: React.ElementType; + label: string; +} + +function NavItem({ to, icon: Icon, label }: NavItemProps) { + return ( + + {({ isActive }) => ( +
+ + {label} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Connection status badge +// --------------------------------------------------------------------------- + +function ConnectionBadge() { + const state = useConnectionState(); + + const isConnected = + state.status === "connected" || + state.status === "authenticated" || + state.status === "unauthenticated"; + + return ( +
+ + {isConnected ? ( + + ) : ( + + )} + {state.status} +
+ ); +} + +// --------------------------------------------------------------------------- +// Flow selector dropdown +// --------------------------------------------------------------------------- + +function FlowSelectorDropdown() { + const { flows } = useFlows(); + const flowId = useSessionStore((s) => s.flowId); + const setFlowId = useSessionStore((s) => s.setFlowId); + const collection = useSettings((s) => s.settings.collection); + + return ( +
+ {/* Flow selector */} +
+ +
+ + +
+
+ + {/* Collection badge */} +
+ + {collection} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- + +export function Sidebar() { + return ( + + ); +} diff --git a/ts/packages/workbench/src/components/notification-toasts.tsx b/ts/packages/workbench/src/components/notification-toasts.tsx new file mode 100644 index 00000000..9472fe8c --- /dev/null +++ b/ts/packages/workbench/src/components/notification-toasts.tsx @@ -0,0 +1,47 @@ +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useNotification, type NotificationType } from "@/providers/notification-provider"; + +const typeStyles: Record = { + success: "border-success/40 bg-success/10 text-success", + error: "border-error/40 bg-error/10 text-error", + warning: "border-warning/40 bg-warning/10 text-warning", + info: "border-brand-500/40 bg-brand-500/10 text-brand-300", +}; + +/** + * Renders the active notification stack in the bottom-right corner. + */ +export function NotificationToasts() { + const notifications = useNotification((s) => s.notifications); + const removeNotification = useNotification((s) => s.removeNotification); + + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((n) => ( +
+
+

{n.title}

+ {n.description && ( +

{n.description}

+ )} +
+ +
+ ))} +
+ ); +} diff --git a/ts/packages/workbench/src/components/ui/badge.tsx b/ts/packages/workbench/src/components/ui/badge.tsx new file mode 100644 index 00000000..fc983260 --- /dev/null +++ b/ts/packages/workbench/src/components/ui/badge.tsx @@ -0,0 +1,31 @@ +import { cn } from "@/lib/utils"; + +type BadgeVariant = "default" | "success" | "warning" | "error" | "info"; + +const variantStyles: Record = { + default: "border-border bg-surface-200 text-fg-muted", + success: "border-success/30 bg-success/10 text-success", + warning: "border-warning/30 bg-warning/10 text-warning", + error: "border-error/30 bg-error/10 text-error", + info: "border-brand-500/30 bg-brand-500/10 text-brand-300", +}; + +interface BadgeProps { + children: React.ReactNode; + variant?: BadgeVariant; + className?: string; +} + +export function Badge({ children, variant = "default", className }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/ts/packages/workbench/src/components/ui/dialog.tsx b/ts/packages/workbench/src/components/ui/dialog.tsx new file mode 100644 index 00000000..e4c93680 --- /dev/null +++ b/ts/packages/workbench/src/components/ui/dialog.tsx @@ -0,0 +1,85 @@ +import { + type ReactNode, + type MouseEvent, + useCallback, + useEffect, +} from "react"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface DialogProps { + open: boolean; + onClose: () => void; + title: string; + children: ReactNode; + footer?: ReactNode; + /** Max width class, defaults to max-w-lg */ + className?: string; +} + +/** + * Simple modal dialog built with Tailwind. + * Renders a backdrop overlay + centered content panel. + */ +export function Dialog({ + open, + onClose, + title, + children, + footer, + className, +}: DialogProps) { + // Close on Escape key + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + const handleBackdrop = useCallback( + (e: MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }, + [onClose], + ); + + if (!open) return null; + + return ( +
+
+ {/* Header */} +
+

{title}

+ +
+ + {/* Body */} +
{children}
+ + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} diff --git a/ts/packages/workbench/src/components/ui/tabs.tsx b/ts/packages/workbench/src/components/ui/tabs.tsx new file mode 100644 index 00000000..90310ad8 --- /dev/null +++ b/ts/packages/workbench/src/components/ui/tabs.tsx @@ -0,0 +1,42 @@ +import { cn } from "@/lib/utils"; + +interface TabItem { + value: string; + label: string; +} + +interface TabsProps { + items: TabItem[]; + value: string; + onChange: (value: string) => void; + className?: string; +} + +/** + * Minimal segmented-control / tab bar. + */ +export function Tabs({ items, value, onChange, className }: TabsProps) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/ts/packages/workbench/src/components/ui/textarea.tsx b/ts/packages/workbench/src/components/ui/textarea.tsx new file mode 100644 index 00000000..c844da8a --- /dev/null +++ b/ts/packages/workbench/src/components/ui/textarea.tsx @@ -0,0 +1,48 @@ +import { useRef, useEffect, type TextareaHTMLAttributes } from "react"; +import { cn } from "@/lib/utils"; + +interface AutoTextareaProps + extends TextareaHTMLAttributes { + /** Maximum number of rows before scrolling */ + maxRows?: number; +} + +/** + * Textarea that auto-resizes to fit its content, up to maxRows. + */ +export function AutoTextarea({ + maxRows = 6, + className, + value, + ...props +}: AutoTextareaProps) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + // Reset height so scrollHeight is recalculated + el.style.height = "auto"; + + // Compute line height from computed styles + const style = window.getComputedStyle(el); + const lineHeight = parseFloat(style.lineHeight) || 20; + const maxHeight = lineHeight * maxRows; + + el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`; + }, [value, maxRows]); + + return ( +