mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 01:19:38 +02:00
init
This commit is contained in:
parent
c386f68743
commit
b6536eca38
100 changed files with 17680 additions and 377 deletions
10
ts/deploy/.env.example
Normal file
10
ts/deploy/.env.example
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# LLM API Keys
|
||||
OPENAI_TOKEN=
|
||||
CLAUDE_KEY=
|
||||
|
||||
# Gateway
|
||||
GATEWAY_SECRET=
|
||||
GATEWAY_PORT=8088
|
||||
|
||||
# Grafana
|
||||
GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
52
ts/deploy/docker-compose.dev.yml
Normal file
52
ts/deploy/docker-compose.dev.yml
Normal file
|
|
@ -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
|
||||
276
ts/deploy/docker-compose.yml
Normal file
276
ts/deploy/docker-compose.yml
Normal file
|
|
@ -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
|
||||
317
ts/deploy/grafana/dashboards/llm-metrics.json
Normal file
317
ts/deploy/grafana/dashboards/llm-metrics.json
Normal file
|
|
@ -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
|
||||
}
|
||||
275
ts/deploy/grafana/dashboards/overview.json
Normal file
275
ts/deploy/grafana/dashboards/overview.json
Normal file
|
|
@ -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
|
||||
}
|
||||
404
ts/deploy/grafana/dashboards/rag-pipeline.json
Normal file
404
ts/deploy/grafana/dashboards/rag-pipeline.json
Normal file
|
|
@ -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
|
||||
}
|
||||
14
ts/deploy/grafana/provisioning/dashboards.yml
Normal file
14
ts/deploy/grafana/provisioning/dashboards.yml
Normal file
|
|
@ -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
|
||||
49
ts/deploy/grafana/provisioning/datasources.yml
Normal file
49
ts/deploy/grafana/provisioning/datasources.yml
Normal file
|
|
@ -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"
|
||||
52
ts/deploy/loki/loki-config.yml
Normal file
52
ts/deploy/loki/loki-config.yml
Normal file
|
|
@ -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
|
||||
41
ts/deploy/otel-collector/config.yml
Normal file
41
ts/deploy/otel-collector/config.yml
Normal file
|
|
@ -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
|
||||
36
ts/deploy/prometheus/prometheus.yml
Normal file
36
ts/deploy/prometheus/prometheus.yml
Normal file
|
|
@ -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"
|
||||
49
ts/deploy/tempo/tempo-config.yml
Normal file
49
ts/deploy/tempo/tempo-config.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
"prom-client": "^15.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T> implements Message<T> {
|
||||
/** 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<string, string> {
|
||||
const headers = this.msg.headers;
|
||||
const headers = this._jsMsg.headers;
|
||||
const props: Record<string, string> = {};
|
||||
if (headers) {
|
||||
for (const [key, values] of headers) {
|
||||
|
|
@ -84,7 +90,7 @@ class NatsProducer<T> implements BackendProducer<T> {
|
|||
}
|
||||
|
||||
class NatsConsumer<T> implements BackendConsumer<T> {
|
||||
private messages: ConsumerMessages | null = null;
|
||||
private consumer: NatsJsConsumer | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly js: JetStreamClient,
|
||||
|
|
@ -106,43 +112,57 @@ class NatsConsumer<T> implements BackendConsumer<T> {
|
|||
});
|
||||
}
|
||||
|
||||
// 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<Message<T> | 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<T>): Promise<void> {
|
||||
const natsMsg = message as NatsMessage<T>;
|
||||
// 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<T>): Promise<void> {
|
||||
void message;
|
||||
const natsMsg = message as NatsMessage<T>;
|
||||
natsMsg._jsMsg.nak();
|
||||
}
|
||||
|
||||
async unsubscribe(): Promise<void> {
|
||||
// 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<void> {
|
||||
if (this.messages) {
|
||||
this.messages.stop();
|
||||
}
|
||||
this.consumer = null;
|
||||
}
|
||||
|
||||
private streamNameFromSubject(subject: string): string {
|
||||
|
|
|
|||
|
|
@ -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<T> = (
|
||||
|
|
@ -16,6 +17,8 @@ export type MessageHandler<T> = (
|
|||
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<T> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,16 +29,24 @@ export abstract class EmbeddingsService extends FlowProcessor {
|
|||
private async onRequest(
|
||||
msg: EmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
_flowCtx: FlowContext,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<EmbeddingsResponse>("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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
// 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<TextCompletionResponse>("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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@trustgraph/base": "workspace:*",
|
||||
"@trustgraph/mcp": "workspace:*",
|
||||
"@trustgraph/client": "workspace:*",
|
||||
"commander": "^13.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<void>((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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("<key>", "Config key")
|
||||
.argument("<key>", "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("<key>", "Config key")
|
||||
.argument("<key>", "Config key (format: type/key)")
|
||||
.argument("<value>", "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("<key>", "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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
27
ts/packages/cli/src/commands/embeddings.ts
Normal file
27
ts/packages/cli/src/commands/embeddings.ts
Normal file
|
|
@ -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...>", "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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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("<id>", "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("<name>", "Flow name")
|
||||
.action(async (name: string, _opts, cmd) => {
|
||||
.argument("<id>", "Flow ID")
|
||||
.requiredOption("-b, --blueprint <name>", "Blueprint name")
|
||||
.option("-d, --description <text>", "Flow description", "")
|
||||
.option("-p, --parameters <json>", "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<string, unknown> | undefined,
|
||||
);
|
||||
console.log(JSON.stringify(resp, null, 2));
|
||||
} finally {
|
||||
await socket.close();
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
|
||||
flow
|
||||
.command("stop")
|
||||
.description("Stop a flow")
|
||||
.argument("<name>", "Flow name")
|
||||
.action(async (name: string, _opts, cmd) => {
|
||||
.argument("<id>", "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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("<query>", "Natural language query")
|
||||
.option("--entity-limit <n>", "Max entities", "50")
|
||||
.option("--triple-limit <n>", "Max triples per entity", "30")
|
||||
.option("--collection <name>", "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("<query>", "Natural language query")
|
||||
.action(async (query: string, _cmdOpts, cmd) => {
|
||||
.option("--doc-limit <n>", "Max documents", "20")
|
||||
.option("--collection <name>", "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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
126
ts/packages/cli/src/commands/library.ts
Normal file
126
ts/packages/cli/src/commands/library.ts
Normal file
|
|
@ -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("<file>", "Path to the file to load")
|
||||
.option("-t, --title <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();
|
||||
}
|
||||
});
|
||||
}
|
||||
48
ts/packages/cli/src/commands/triples.ts
Normal file
48
ts/packages/cli/src/commands/triples.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
30
ts/packages/client/package.json
Normal file
30
ts/packages/client/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
221
ts/packages/client/src/__tests__/flows-api.test.ts
Normal file
221
ts/packages/client/src/__tests__/flows-api.test.ts
Normal file
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
370
ts/packages/client/src/__tests__/messages.test.ts
Normal file
370
ts/packages/client/src/__tests__/messages.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
285
ts/packages/client/src/__tests__/service-call-multi.test.ts
Normal file
285
ts/packages/client/src/__tests__/service-call-multi.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
239
ts/packages/client/src/__tests__/service-call.test.ts
Normal file
239
ts/packages/client/src/__tests__/service-call.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
13
ts/packages/client/src/index.ts
Normal file
13
ts/packages/client/src/index.ts
Normal file
|
|
@ -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";
|
||||
40
ts/packages/client/src/models/Triple.ts
Normal file
40
ts/packages/client/src/models/Triple.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
496
ts/packages/client/src/models/messages.ts
Normal file
496
ts/packages/client/src/models/messages.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
42
ts/packages/client/src/models/namespaces.ts
Normal file
42
ts/packages/client/src/models/namespaces.ts
Normal file
|
|
@ -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";
|
||||
172
ts/packages/client/src/socket/service-call-multi.ts
Normal file
172
ts/packages/client/src/socket/service-call-multi.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
240
ts/packages/client/src/socket/service-call.ts
Normal file
240
ts/packages/client/src/socket/service-call.ts
Normal file
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
2366
ts/packages/client/src/socket/trustgraph-socket.ts
Normal file
2366
ts/packages/client/src/socket/trustgraph-socket.ts
Normal file
File diff suppressed because it is too large
Load diff
133
ts/packages/client/src/socket/websocket-adapter.ts
Normal file
133
ts/packages/client/src/socket/websocket-adapter.ts
Normal file
|
|
@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
3
ts/packages/client/src/types.ts
Normal file
3
ts/packages/client/src/types.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Type definitions for TrustGraph client
|
||||
|
||||
export {};
|
||||
11
ts/packages/client/tsconfig.json
Normal file
11
ts/packages/client/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__tests__"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
357
ts/packages/flow/src/config/service.ts
Normal file
357
ts/packages/flow/src/config/service.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
76
ts/packages/flow/src/embeddings/ollama.ts
Normal file
76
ts/packages/flow/src/embeddings/ollama.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
272
ts/packages/flow/src/gateway/dispatch/serialize.ts
Normal file
272
ts/packages/flow/src/gateway/dispatch/serialize.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
154
ts/packages/flow/src/prompt/template.ts
Normal file
154
ts/packages/flow/src/prompt/template.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
80
ts/packages/flow/src/query/embeddings/qdrant-doc.ts
Normal file
80
ts/packages/flow/src/query/embeddings/qdrant-doc.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
103
ts/packages/flow/src/query/embeddings/qdrant-graph.ts
Normal file
103
ts/packages/flow/src/query/embeddings/qdrant-graph.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
243
ts/packages/flow/src/query/triples/falkordb.ts
Normal file
243
ts/packages/flow/src/query/triples/falkordb.ts
Normal file
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
106
ts/packages/flow/src/storage/embeddings/qdrant-doc.ts
Normal file
106
ts/packages/flow/src/storage/embeddings/qdrant-doc.ts
Normal file
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
127
ts/packages/flow/src/storage/embeddings/qdrant-graph.ts
Normal file
127
ts/packages/flow/src/storage/embeddings/qdrant-graph.ts
Normal file
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
116
ts/packages/flow/src/storage/triples/falkordb.ts
Normal file
116
ts/packages/flow/src/storage/triples/falkordb.ts
Normal file
|
|
@ -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 } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
export { createMcpServer, run } from "./server.js";
|
||||
export { SocketManager, type SocketManagerConfig } from "./socket-manager.js";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
13
ts/packages/workbench/index.html
Normal file
13
ts/packages/workbench/index.html
Normal file
|
|
@ -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</title>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
ts/packages/workbench/package.json
Normal file
34
ts/packages/workbench/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
27
ts/packages/workbench/src/App.tsx
Normal file
27
ts/packages/workbench/src/App.tsx
Normal file
|
|
@ -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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<RootLayout />}>
|
||||
<Route index element={<Navigate to="/chat" replace />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/library" element={<LibraryPage />} />
|
||||
<Route path="/graph" element={<GraphPage />} />
|
||||
<Route path="/flows" element={<FlowsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
<NotificationToasts />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex items-center gap-4 rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
{collection}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Workflow className="h-3.5 w-3.5" />
|
||||
{flowId || "<none>"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
ts/packages/workbench/src/components/layout/root-layout.tsx
Normal file
45
ts/packages/workbench/src/components/layout/root-layout.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="absolute left-0 right-0 top-0 z-40 h-0.5 overflow-hidden bg-surface-200">
|
||||
<div className="h-full w-1/3 animate-[slide_1.2s_ease-in-out_infinite] bg-brand-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root layout: fixed sidebar + scrollable main content area with a top bar.
|
||||
*/
|
||||
export function RootLayout() {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full overflow-hidden bg-surface-0">
|
||||
{/* Global loading bar */}
|
||||
<LoadingBar />
|
||||
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<header className="flex h-14 shrink-0 items-center justify-end border-b border-border bg-surface-50 px-6">
|
||||
<FlowSelector />
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
ts/packages/workbench/src/components/layout/sidebar.tsx
Normal file
168
ts/packages/workbench/src/components/layout/sidebar.tsx
Normal file
|
|
@ -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 (
|
||||
<NavLink to={to} className="w-full">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-brand-600/20 text-brand-400"
|
||||
: "text-fg-muted hover:bg-surface-200 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection status badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConnectionBadge() {
|
||||
const state = useConnectionState();
|
||||
|
||||
const isConnected =
|
||||
state.status === "connected" ||
|
||||
state.status === "authenticated" ||
|
||||
state.status === "unauthenticated";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium",
|
||||
isConnected ? "text-success" : "text-fg-subtle",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
isConnected ? "bg-success animate-pulse" : "bg-fg-subtle",
|
||||
)}
|
||||
/>
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<WifiOff className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="truncate capitalize">{state.status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="space-y-2 px-3">
|
||||
{/* Flow selector */}
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
<Workflow className="h-3 w-3" />
|
||||
Flow
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={flowId}
|
||||
onChange={(e) => setFlowId(e.target.value)}
|
||||
className="w-full appearance-none rounded-md border border-border bg-surface-100 py-1.5 pl-2.5 pr-7 text-xs text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="default">default</option>
|
||||
{flows.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-fg-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collection badge */}
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-surface-100 px-2.5 py-1.5 text-xs text-fg-muted">
|
||||
<Database className="h-3 w-3 shrink-0 text-fg-subtle" />
|
||||
<span className="truncate">{collection}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="flex h-screen w-sidebar shrink-0 flex-col border-r border-border bg-surface-50">
|
||||
{/* Logo area */}
|
||||
<div className="flex h-14 items-center gap-2 px-4">
|
||||
<TestTube2 className="h-5 w-5 text-brand-500" />
|
||||
<span className="text-lg font-bold text-fg">TrustGraph</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
|
||||
{/* Flow & collection selectors */}
|
||||
<div className="py-3">
|
||||
<FlowSelectorDropdown />
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
|
||||
{/* Navigation links */}
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 py-3">
|
||||
<NavItem to="/chat" icon={MessageSquareText} label="Chat" />
|
||||
<NavItem to="/library" icon={LibraryBig} label="Library" />
|
||||
<NavItem to="/graph" icon={Rotate3d} label="Graph" />
|
||||
<NavItem to="/flows" icon={Workflow} label="Flows" />
|
||||
<NavItem to="/settings" icon={Settings} label="Settings" />
|
||||
</nav>
|
||||
|
||||
{/* Footer: connection badge */}
|
||||
<div className="border-t border-border px-2 py-2">
|
||||
<ConnectionBadge />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
47
ts/packages/workbench/src/components/notification-toasts.tsx
Normal file
47
ts/packages/workbench/src/components/notification-toasts.tsx
Normal file
|
|
@ -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<NotificationType, string> = {
|
||||
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 (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-lg border px-4 py-3 text-sm shadow-lg",
|
||||
typeStyles[n.type],
|
||||
)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{n.title}</p>
|
||||
{n.description && (
|
||||
<p className="mt-0.5 text-xs opacity-80">{n.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeNotification(n.id)}
|
||||
className="shrink-0 opacity-60 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
ts/packages/workbench/src/components/ui/badge.tsx
Normal file
31
ts/packages/workbench/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BadgeVariant = "default" | "success" | "warning" | "error" | "info";
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
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 (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
85
ts/packages/workbench/src/components/ui/dialog.tsx
Normal file
85
ts/packages/workbench/src/components/ui/dialog.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleBackdrop}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full max-w-lg rounded-xl border border-border bg-surface-100 shadow-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-fg">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
ts/packages/workbench/src/components/ui/tabs.tsx
Normal file
42
ts/packages/workbench/src/components/ui/tabs.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex rounded-lg border border-border bg-surface-100 p-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
onClick={() => onChange(item.value)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
value === item.value
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
ts/packages/workbench/src/components/ui/textarea.tsx
Normal file
48
ts/packages/workbench/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useRef, useEffect, type TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AutoTextareaProps
|
||||
extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
/** 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<HTMLTextAreaElement>(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 (
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
className={cn(
|
||||
"w-full resize-none rounded-lg border border-border bg-surface-100 px-4 py-3 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500",
|
||||
className,
|
||||
)}
|
||||
rows={1}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
215
ts/packages/workbench/src/hooks/use-chat.ts
Normal file
215
ts/packages/workbench/src/hooks/use-chat.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { useCallback } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import {
|
||||
useConversation,
|
||||
nextMessageId,
|
||||
type ChatMessage,
|
||||
} from "./use-conversation";
|
||||
import { useSessionStore } from "./use-session-store";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import type { StreamingMetadata } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseChatReturn {
|
||||
submitMessage: (opts: { input: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates sending a chat message through the selected RAG / agent
|
||||
* pipeline and accumulates streamed chunks into the conversation store.
|
||||
*/
|
||||
export function useChat(): UseChatReturn {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const chatMode = useConversation((s) => s.chatMode);
|
||||
const addMessage = useConversation((s) => s.addMessage);
|
||||
const updateLastMessage = useConversation((s) => s.updateLastMessage);
|
||||
const setInput = useConversation((s) => s.setInput);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
({ input }: { input: string }) => {
|
||||
if (!input.trim()) return;
|
||||
|
||||
const activityLabel = "Chat request";
|
||||
|
||||
// 1. Add the user message
|
||||
const userMsg: ChatMessage = {
|
||||
id: nextMessageId(),
|
||||
role: "user",
|
||||
content: input,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
addMessage(userMsg);
|
||||
setInput("");
|
||||
|
||||
// 2. Add a placeholder assistant message for streaming
|
||||
const assistantId = nextMessageId();
|
||||
const isAgent = chatMode === "agent";
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: Date.now(),
|
||||
isStreaming: true,
|
||||
...(isAgent
|
||||
? {
|
||||
agentPhases: { think: "", observe: "", answer: "" },
|
||||
activePhase: "think" as const,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
addMessage(assistantMsg);
|
||||
addActivity(activityLabel);
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
|
||||
// Shared handler for streaming responses (graph-rag / document-rag)
|
||||
const onChunk = (
|
||||
chunk: string,
|
||||
complete: boolean,
|
||||
metadata?: StreamingMetadata,
|
||||
) => {
|
||||
updateLastMessage((prev) => ({
|
||||
...prev,
|
||||
content: prev.content + chunk,
|
||||
isStreaming: !complete,
|
||||
...(complete && metadata
|
||||
? {
|
||||
metadata: {
|
||||
model: metadata.model,
|
||||
inTokens: metadata.in_token,
|
||||
outTokens: metadata.out_token,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
if (complete) {
|
||||
removeActivity(activityLabel);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (error: string) => {
|
||||
updateLastMessage((prev) => ({
|
||||
...prev,
|
||||
content: prev.content || `Error: ${error}`,
|
||||
isStreaming: false,
|
||||
}));
|
||||
removeActivity(activityLabel);
|
||||
};
|
||||
|
||||
// 3. Dispatch based on chat mode
|
||||
switch (chatMode) {
|
||||
case "graph-rag":
|
||||
flow.graphRagStreaming(input, onChunk, onError, undefined, collection);
|
||||
break;
|
||||
|
||||
case "document-rag":
|
||||
flow.documentRagStreaming(input, onChunk, onError, undefined, collection);
|
||||
break;
|
||||
|
||||
case "agent": {
|
||||
// Agent has separate think / observe / answer streams.
|
||||
// We track each phase in agentPhases and display the answer
|
||||
// as the main content.
|
||||
|
||||
flow.agent(
|
||||
input,
|
||||
// think
|
||||
(chunk, complete) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
think: phases.think + chunk,
|
||||
},
|
||||
activePhase: complete ? prev.activePhase : "think",
|
||||
};
|
||||
});
|
||||
},
|
||||
// observe
|
||||
(chunk, complete) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
observe: phases.observe + chunk,
|
||||
},
|
||||
activePhase: complete ? prev.activePhase : "observe",
|
||||
};
|
||||
});
|
||||
},
|
||||
// answer
|
||||
(chunk, complete, metadata) => {
|
||||
updateLastMessage((prev) => {
|
||||
const phases = prev.agentPhases ?? {
|
||||
think: "",
|
||||
observe: "",
|
||||
answer: "",
|
||||
};
|
||||
const newAnswer = phases.answer + chunk;
|
||||
return {
|
||||
...prev,
|
||||
content: newAnswer,
|
||||
agentPhases: {
|
||||
...phases,
|
||||
answer: newAnswer,
|
||||
},
|
||||
activePhase: complete ? undefined : "answer",
|
||||
isStreaming: !complete,
|
||||
...(complete && metadata
|
||||
? {
|
||||
metadata: {
|
||||
model: metadata.model,
|
||||
inTokens: metadata.in_token,
|
||||
outTokens: metadata.out_token,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
if (complete) {
|
||||
removeActivity(activityLabel);
|
||||
}
|
||||
},
|
||||
// error
|
||||
onError,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
socket,
|
||||
flowId,
|
||||
chatMode,
|
||||
collection,
|
||||
addMessage,
|
||||
updateLastMessage,
|
||||
setInput,
|
||||
addActivity,
|
||||
removeActivity,
|
||||
],
|
||||
);
|
||||
|
||||
return { submitMessage };
|
||||
}
|
||||
91
ts/packages/workbench/src/hooks/use-conversation.ts
Normal file
91
ts/packages/workbench/src/hooks/use-conversation.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ChatMode = "graph-rag" | "document-rag" | "agent";
|
||||
|
||||
export type MessageRole = "user" | "assistant" | "system";
|
||||
|
||||
/** Phase labels for agent-mode messages */
|
||||
export type AgentPhase = "think" | "observe" | "answer";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
/** Timestamp (epoch ms) */
|
||||
timestamp: number;
|
||||
/** If true the message is still being streamed */
|
||||
isStreaming?: boolean;
|
||||
/** Optional metadata attached on completion */
|
||||
metadata?: {
|
||||
model?: string;
|
||||
inTokens?: number;
|
||||
outTokens?: number;
|
||||
};
|
||||
/** Agent-mode phases with their accumulated content */
|
||||
agentPhases?: {
|
||||
think: string;
|
||||
observe: string;
|
||||
answer: string;
|
||||
};
|
||||
/** Indicates the current active phase during streaming */
|
||||
activePhase?: AgentPhase;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ConversationState {
|
||||
messages: ChatMessage[];
|
||||
input: string;
|
||||
chatMode: ChatMode;
|
||||
|
||||
setInput: (value: string) => void;
|
||||
setChatMode: (mode: ChatMode) => void;
|
||||
|
||||
addMessage: (message: ChatMessage) => void;
|
||||
|
||||
/**
|
||||
* Update the last message in the list (used during streaming to append
|
||||
* chunks). The `updater` receives the current last message and must
|
||||
* return the replacement.
|
||||
*/
|
||||
updateLastMessage: (
|
||||
updater: (prev: ChatMessage) => ChatMessage,
|
||||
) => void;
|
||||
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
let _nextMsgId = 0;
|
||||
export function nextMessageId(): string {
|
||||
return `msg-${++_nextMsgId}-${Date.now()}`;
|
||||
}
|
||||
|
||||
export const useConversation = create<ConversationState>()((set) => ({
|
||||
messages: [],
|
||||
input: "",
|
||||
chatMode: "graph-rag",
|
||||
|
||||
setInput: (value) => set({ input: value }),
|
||||
setChatMode: (mode) => set({ chatMode: mode }),
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({ messages: [...state.messages, message] })),
|
||||
|
||||
updateLastMessage: (updater) =>
|
||||
set((state) => {
|
||||
if (state.messages.length === 0) return state;
|
||||
const last = state.messages[state.messages.length - 1]!;
|
||||
const updated = updater(last);
|
||||
return {
|
||||
messages: [...state.messages.slice(0, -1), updated],
|
||||
};
|
||||
}),
|
||||
|
||||
clearMessages: () => set({ messages: [] }),
|
||||
}));
|
||||
130
ts/packages/workbench/src/hooks/use-flows.ts
Normal file
130
ts/packages/workbench/src/hooks/use-flows.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FlowSummary {
|
||||
id: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UseFlowsReturn {
|
||||
flows: FlowSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Refresh the flow list from the server */
|
||||
getFlows: () => Promise<void>;
|
||||
/** Start a new flow */
|
||||
startFlow: (
|
||||
id: string,
|
||||
blueprintName: string,
|
||||
description: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
/** Stop a running flow */
|
||||
stopFlow: (id: string) => Promise<void>;
|
||||
/** Fetch a single flow definition */
|
||||
getFlow: (id: string) => Promise<FlowSummary>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useFlows(): UseFlowsReturn {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [flows, setFlows] = useState<FlowSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getFlows = useCallback(async () => {
|
||||
const act = "Load flows";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
|
||||
const ids: string[] = await socket.flows().getFlows();
|
||||
const results = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const def = await socket.flows().getFlow(id);
|
||||
return { id, ...def } as FlowSummary;
|
||||
}),
|
||||
);
|
||||
|
||||
setFlows(results);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("useFlows.getFlows error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, addActivity, removeActivity]);
|
||||
|
||||
const startFlow = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
blueprintName: string,
|
||||
description: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => {
|
||||
const act = `Start flow ${id}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.flows().startFlow(id, blueprintName, description, parameters);
|
||||
// Refresh list after starting
|
||||
await getFlows();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getFlows],
|
||||
);
|
||||
|
||||
const stopFlow = useCallback(
|
||||
async (id: string) => {
|
||||
const act = `Stop flow ${id}`;
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.flows().stopFlow(id);
|
||||
await getFlows();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getFlows],
|
||||
);
|
||||
|
||||
const getFlow = useCallback(
|
||||
async (id: string): Promise<FlowSummary> => {
|
||||
const def = await socket.flows().getFlow(id);
|
||||
return { id, ...def } as FlowSummary;
|
||||
},
|
||||
[socket],
|
||||
);
|
||||
|
||||
// Auto-load flows when the connection becomes ready
|
||||
useEffect(() => {
|
||||
if (
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated"
|
||||
) {
|
||||
getFlows();
|
||||
}
|
||||
}, [connectionState.status, getFlows]);
|
||||
|
||||
return { flows, loading, error, getFlows, startFlow, stopFlow, getFlow };
|
||||
}
|
||||
134
ts/packages/workbench/src/hooks/use-library.ts
Normal file
134
ts/packages/workbench/src/hooks/use-library.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useProgressStore } from "./use-progress-store";
|
||||
import type { DocumentMetadata } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessingMetadata {
|
||||
id: string;
|
||||
"document-id": string;
|
||||
flow: string;
|
||||
collection: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UseLibraryReturn {
|
||||
documents: DocumentMetadata[];
|
||||
processing: ProcessingMetadata[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Refresh the documents list */
|
||||
getDocuments: () => Promise<void>;
|
||||
/** Upload a new document */
|
||||
uploadDocument: (
|
||||
document: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
id?: string,
|
||||
) => Promise<void>;
|
||||
/** Remove a document */
|
||||
removeDocument: (id: string, collection?: string) => Promise<void>;
|
||||
/** Get the list of currently-processing documents */
|
||||
getProcessing: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLibrary(): UseLibraryReturn {
|
||||
const socket = useSocket();
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [documents, setDocuments] = useState<DocumentMetadata[]>([]);
|
||||
const [processing, setProcessing] = useState<ProcessingMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getDocuments = useCallback(async () => {
|
||||
const act = "Load documents";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
const docs = await socket.librarian().getDocuments();
|
||||
setDocuments(docs);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
console.error("useLibrary.getDocuments error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, addActivity, removeActivity]);
|
||||
|
||||
const uploadDocument = useCallback(
|
||||
async (
|
||||
document: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
id?: string,
|
||||
) => {
|
||||
const act = "Upload document";
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket
|
||||
.librarian()
|
||||
.loadDocument(document, mimeType, title, comments, tags, id);
|
||||
// Refresh list after upload
|
||||
await getDocuments();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getDocuments],
|
||||
);
|
||||
|
||||
const removeDocument = useCallback(
|
||||
async (id: string, collection?: string) => {
|
||||
const act = "Remove document";
|
||||
try {
|
||||
addActivity(act);
|
||||
await socket.librarian().removeDocument(id, collection);
|
||||
await getDocuments();
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
},
|
||||
[socket, addActivity, removeActivity, getDocuments],
|
||||
);
|
||||
|
||||
const getProcessing = useCallback(async () => {
|
||||
const act = "Load processing";
|
||||
try {
|
||||
addActivity(act);
|
||||
const procs = await socket.librarian().getProcessing();
|
||||
setProcessing(procs as ProcessingMetadata[]);
|
||||
} catch (err) {
|
||||
console.error("useLibrary.getProcessing error:", err);
|
||||
} finally {
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, addActivity, removeActivity]);
|
||||
|
||||
return {
|
||||
documents,
|
||||
processing,
|
||||
loading,
|
||||
error,
|
||||
getDocuments,
|
||||
uploadDocument,
|
||||
removeDocument,
|
||||
getProcessing,
|
||||
};
|
||||
}
|
||||
39
ts/packages/workbench/src/hooks/use-progress-store.ts
Normal file
39
ts/packages/workbench/src/hooks/use-progress-store.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProgressState {
|
||||
/** Set of currently-running activity labels */
|
||||
activities: Set<string>;
|
||||
|
||||
/** Derived: true when at least one activity is running */
|
||||
isLoading: boolean;
|
||||
|
||||
addActivity: (label: string) => void;
|
||||
removeActivity: (label: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useProgressStore = create<ProgressState>()((set) => ({
|
||||
activities: new Set<string>(),
|
||||
isLoading: false,
|
||||
|
||||
addActivity: (label) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.activities);
|
||||
next.add(label);
|
||||
return { activities: next, isLoading: next.size > 0 };
|
||||
}),
|
||||
|
||||
removeActivity: (label) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.activities);
|
||||
next.delete(label);
|
||||
return { activities: next, isLoading: next.size > 0 };
|
||||
}),
|
||||
}));
|
||||
34
ts/packages/workbench/src/hooks/use-session-store.ts
Normal file
34
ts/packages/workbench/src/hooks/use-session-store.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal flow description kept in session state after selection. */
|
||||
export interface FlowInfo {
|
||||
id: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
/** Currently-selected flow id */
|
||||
flowId: string;
|
||||
/** Cached flow definition for the selected flow */
|
||||
flow: FlowInfo | null;
|
||||
|
||||
setFlowId: (id: string) => void;
|
||||
setFlow: (flow: FlowInfo | null) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const useSessionStore = create<SessionState>()((set) => ({
|
||||
flowId: "default",
|
||||
flow: null,
|
||||
|
||||
setFlowId: (id) => set({ flowId: id }),
|
||||
setFlow: (flow) => set({ flow }),
|
||||
}));
|
||||
126
ts/packages/workbench/src/index.css
Normal file
126
ts/packages/workbench/src/index.css
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* TrustGraph Workbench -- Dark-mode-first design tokens
|
||||
*
|
||||
* Tailwind CSS v4 uses CSS-first configuration.
|
||||
* Custom theme values are declared as CSS custom properties.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* Brand palette */
|
||||
--color-brand-50: #eef2ff;
|
||||
--color-brand-100: #dce4ff;
|
||||
--color-brand-200: #b9c9ff;
|
||||
--color-brand-300: #8aa5ff;
|
||||
--color-brand-400: #5b80ff;
|
||||
--color-brand-500: #3b63ed;
|
||||
--color-brand-600: #2d4ec4;
|
||||
--color-brand-700: #213a9b;
|
||||
--color-brand-800: #162872;
|
||||
--color-brand-900: #0e1a4d;
|
||||
|
||||
/* Surface / background colors (dark-first) */
|
||||
--color-surface-0: #09090b;
|
||||
--color-surface-50: #111113;
|
||||
--color-surface-100: #18181b;
|
||||
--color-surface-200: #27272a;
|
||||
--color-surface-300: #3f3f46;
|
||||
--color-surface-400: #52525b;
|
||||
|
||||
/* Foreground / text colors */
|
||||
--color-fg: #fafafa;
|
||||
--color-fg-muted: #a1a1aa;
|
||||
--color-fg-subtle: #71717a;
|
||||
|
||||
/* Border colors */
|
||||
--color-border: #27272a;
|
||||
--color-border-hover: #3f3f46;
|
||||
|
||||
/* Semantic: success / warning / error */
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #eab308;
|
||||
--color-error: #ef4444;
|
||||
|
||||
/* Sidebar width */
|
||||
--spacing-sidebar: 16rem;
|
||||
--spacing-sidebar-collapsed: 4rem;
|
||||
|
||||
/* Font families */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Base layer: dark background, light text by default */
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-surface-0);
|
||||
color: var(--color-fg);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark mode */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-surface-100);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-surface-300);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-surface-400);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading bar slide animation */
|
||||
@keyframes slide {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prose overrides for dark mode markdown rendering */
|
||||
@layer base {
|
||||
.prose-invert code {
|
||||
color: var(--color-brand-300);
|
||||
}
|
||||
|
||||
.prose-invert pre {
|
||||
background: var(--color-surface-200);
|
||||
}
|
||||
}
|
||||
|
||||
/* Light mode overrides (activated by .light class on <html>) */
|
||||
html.light {
|
||||
--color-surface-0: #ffffff;
|
||||
--color-surface-50: #fafafa;
|
||||
--color-surface-100: #f4f4f5;
|
||||
--color-surface-200: #e4e4e7;
|
||||
--color-surface-300: #d4d4d8;
|
||||
--color-surface-400: #a1a1aa;
|
||||
|
||||
--color-fg: #18181b;
|
||||
--color-fg-muted: #52525b;
|
||||
--color-fg-subtle: #71717a;
|
||||
|
||||
--color-border: #e4e4e7;
|
||||
--color-border-hover: #d4d4d8;
|
||||
}
|
||||
6
ts/packages/workbench/src/lib/utils.ts
Normal file
6
ts/packages/workbench/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
36
ts/packages/workbench/src/main.tsx
Normal file
36
ts/packages/workbench/src/main.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import App from "@/App";
|
||||
import { SocketProvider } from "@/providers/socket-provider";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import "@/index.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/**
|
||||
* AppRoot reads settings from the Zustand store and passes them
|
||||
* into the SocketProvider so the WebSocket connection is configured
|
||||
* before any child component mounts.
|
||||
*/
|
||||
function AppRoot() {
|
||||
const settings = useSettings((s) => s.settings);
|
||||
|
||||
return (
|
||||
<SocketProvider
|
||||
user={settings.user}
|
||||
apiKey={settings.apiKey || undefined}
|
||||
socketUrl={settings.gatewayUrl || undefined}
|
||||
>
|
||||
<App />
|
||||
</SocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppRoot />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
305
ts/packages/workbench/src/pages/chat.tsx
Normal file
305
ts/packages/workbench/src/pages/chat.tsx
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import {
|
||||
MessageSquareText,
|
||||
Send,
|
||||
Trash2,
|
||||
Brain,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Markdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConversation, type ChatMessage } from "@/hooks/use-conversation";
|
||||
import { useChat } from "@/hooks/use-chat";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { AutoTextarea } from "@/components/ui/textarea";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODES = [
|
||||
{ value: "graph-rag" as const, label: "Graph RAG" },
|
||||
{ value: "document-rag" as const, label: "Doc RAG" },
|
||||
{ value: "agent" as const, label: "Agent" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent phase section (collapsible)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentPhaseBlock({
|
||||
phase,
|
||||
icon,
|
||||
label,
|
||||
content,
|
||||
isActive,
|
||||
}: {
|
||||
phase: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
content: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!content && !isActive) return null;
|
||||
|
||||
const phaseColors: Record<string, string> = {
|
||||
think: "border-amber-500/30 bg-amber-500/5",
|
||||
observe: "border-sky-500/30 bg-sky-500/5",
|
||||
answer: "border-emerald-500/30 bg-emerald-500/5",
|
||||
};
|
||||
|
||||
const badgeColors: Record<string, string> = {
|
||||
think: "bg-amber-500/20 text-amber-400",
|
||||
observe: "bg-sky-500/20 text-sky-400",
|
||||
answer: "bg-emerald-500/20 text-emerald-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border",
|
||||
phaseColors[phase] ?? "border-border bg-surface-100",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
{icon}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5",
|
||||
badgeColors[phase] ?? "bg-surface-200 text-fg-muted",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<Loader2 className="ml-auto h-3 w-3 animate-spin text-fg-subtle" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && content && (
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs leading-relaxed text-fg-muted">
|
||||
<p className="whitespace-pre-wrap">{content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
const isUser = msg.role === "user";
|
||||
const hasAgentPhases = msg.agentPhases != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-3 text-sm leading-relaxed",
|
||||
isUser
|
||||
? "ml-auto max-w-[80%] bg-brand-700/30 text-fg"
|
||||
: "mr-auto max-w-[80%] bg-surface-100 text-fg",
|
||||
)}
|
||||
>
|
||||
{/* Agent phase blocks (only for agent messages) */}
|
||||
{hasAgentPhases && msg.agentPhases && (
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<AgentPhaseBlock
|
||||
phase="think"
|
||||
icon={<Brain className="h-3 w-3" />}
|
||||
label="Thinking"
|
||||
content={msg.agentPhases.think}
|
||||
isActive={msg.activePhase === "think"}
|
||||
/>
|
||||
<AgentPhaseBlock
|
||||
phase="observe"
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Observing"
|
||||
content={msg.agentPhases.observe}
|
||||
isActive={msg.activePhase === "observe"}
|
||||
/>
|
||||
{msg.agentPhases.answer && (
|
||||
<div className="flex items-center gap-1.5 px-1 pt-1 text-xs text-emerald-400">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span className="font-medium">Answer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content (markdown for assistant, plain for user) */}
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
) : (
|
||||
<div className="prose prose-invert prose-sm max-w-none prose-p:my-1 prose-pre:bg-surface-200 prose-pre:text-fg prose-code:text-brand-300">
|
||||
<Markdown>{msg.content || (msg.isStreaming ? "" : "(empty)")}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{msg.isStreaming && (
|
||||
<span className="mt-1 inline-block h-2 w-2 animate-pulse rounded-full bg-brand-400" />
|
||||
)}
|
||||
|
||||
{/* Token metadata */}
|
||||
{msg.metadata && (
|
||||
<div className="mt-2 flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
{msg.metadata.model && <span>{msg.metadata.model}</span>}
|
||||
{msg.metadata.inTokens != null && (
|
||||
<span>in: {msg.metadata.inTokens}</span>
|
||||
)}
|
||||
{msg.metadata.outTokens != null && (
|
||||
<span>out: {msg.metadata.outTokens}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ChatPage() {
|
||||
const messages = useConversation((s) => s.messages);
|
||||
const input = useConversation((s) => s.input);
|
||||
const chatMode = useConversation((s) => s.chatMode);
|
||||
const setInput = useConversation((s) => s.setInput);
|
||||
const setChatMode = useConversation((s) => s.setChatMode);
|
||||
const clearMessages = useConversation((s) => s.clearMessages);
|
||||
const { submitMessage } = useChat();
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (input.trim()) {
|
||||
submitMessage({ input });
|
||||
}
|
||||
}, [input, submitMessage]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<MessageSquareText className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Chat</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
|
||||
{collection}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode selector */}
|
||||
<div className="flex rounded-lg border border-border bg-surface-100 p-0.5">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
onClick={() => setChatMode(mode.value)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||
chatMode === mode.value
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-fg-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clearMessages}
|
||||
className="rounded-lg p-2 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
title="Clear messages"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pb-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-fg-subtle">
|
||||
<MessageSquareText className="mb-3 h-10 w-10 opacity-30" />
|
||||
<p>Send a message to start a conversation.</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Mode: <span className="text-fg-muted">{chatMode}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
))}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 pb-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex items-end gap-2 border-t border-border pt-4">
|
||||
<AutoTextarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
|
||||
maxRows={6}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-600 text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
490
ts/packages/workbench/src/pages/flows.tsx
Normal file
490
ts/packages/workbench/src/pages/flows.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Workflow,
|
||||
Plus,
|
||||
Square,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlows, type FlowSummary } from "@/hooks/use-flows";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start flow dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StartFlowDialog({
|
||||
open,
|
||||
onClose,
|
||||
onStart,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onStart: (
|
||||
id: string,
|
||||
blueprint: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const socket = useSocket();
|
||||
const [blueprints, setBlueprints] = useState<string[]>([]);
|
||||
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
|
||||
const [id, setId] = useState("");
|
||||
const [blueprint, setBlueprint] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [paramsJson, setParamsJson] = useState("{}");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [paramsError, setParamsError] = useState<string | null>(null);
|
||||
|
||||
// Fetch blueprints when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoadingBlueprints(true);
|
||||
socket
|
||||
.flows()
|
||||
.getFlowBlueprints()
|
||||
.then((names) => {
|
||||
const list = names ?? [];
|
||||
setBlueprints(list);
|
||||
if (list.length > 0 && !blueprint) {
|
||||
setBlueprint(list[0]!);
|
||||
}
|
||||
})
|
||||
.catch(() => setBlueprints([]))
|
||||
.finally(() => setLoadingBlueprints(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, socket]);
|
||||
|
||||
const reset = () => {
|
||||
setId("");
|
||||
setBlueprint("");
|
||||
setDescription("");
|
||||
setParamsJson("{}");
|
||||
setParamsError(null);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
let params: Record<string, unknown> = {};
|
||||
try {
|
||||
params = JSON.parse(paramsJson);
|
||||
setParamsError(null);
|
||||
} catch {
|
||||
setParamsError("Invalid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onStart(id, blueprint, description, params);
|
||||
reset();
|
||||
onClose();
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = id.trim().length > 0 && blueprint.length > 0 && description.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!submitting) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Start Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || submitting}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Start
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Flow ID */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Flow ID <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="my-flow-id"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blueprint name */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Blueprint <span className="text-error">*</span>
|
||||
</label>
|
||||
{loadingBlueprints ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading blueprints...
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={blueprint}
|
||||
onChange={(e) => setBlueprint(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a blueprint
|
||||
</option>
|
||||
{blueprints.map((bp) => (
|
||||
<option key={bp} value={bp}>
|
||||
{bp}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Description <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Human-readable description"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parameters (JSON) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Parameters (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={paramsJson}
|
||||
onChange={(e) => {
|
||||
setParamsJson(e.target.value);
|
||||
setParamsError(null);
|
||||
}}
|
||||
rows={4}
|
||||
className={cn(
|
||||
"w-full resize-none rounded-lg border bg-surface-100 px-3 py-2 font-mono text-xs text-fg placeholder:text-fg-subtle focus:outline-none focus:ring-1",
|
||||
paramsError
|
||||
? "border-error focus:border-error focus:ring-error"
|
||||
: "border-border focus:border-brand-500 focus:ring-brand-500",
|
||||
)}
|
||||
/>
|
||||
{paramsError && (
|
||||
<p className="text-xs text-error">{paramsError}</p>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stop flow confirm dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StopFlowDialog({
|
||||
open,
|
||||
flowId,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
flowId: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Stop Flow"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to stop flow{" "}
|
||||
<span className="font-mono font-medium text-fg">{flowId}</span>?
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flow detail row (expandable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FlowRow({
|
||||
flow,
|
||||
onStop,
|
||||
}: {
|
||||
flow: FlowSummary;
|
||||
onStop: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Determine all the extra keys beyond id/description
|
||||
const detailKeys = Object.keys(flow).filter(
|
||||
(k) => k !== "id" && k !== "description",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="cursor-pointer hover:bg-surface-100/50"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-subtle" />
|
||||
)}
|
||||
<span className="font-mono text-sm text-fg">{flow.id}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-fg-muted">
|
||||
{flow.description || "--"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="success">Running</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStop(flow.id);
|
||||
}}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
|
||||
title="Stop flow"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Detail row */}
|
||||
{expanded && detailKeys.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="bg-surface-50 px-8 py-3">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
|
||||
{detailKeys.map((key) => (
|
||||
<div key={key}>
|
||||
<span className="font-medium text-fg-muted">{key}: </span>
|
||||
<span className="text-fg-subtle">
|
||||
{typeof flow[key] === "object"
|
||||
? JSON.stringify(flow[key])
|
||||
: String(flow[key] ?? "")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flows page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function FlowsPage() {
|
||||
const { flows, loading, error, getFlows, startFlow, stopFlow } = useFlows();
|
||||
const notify = useNotification();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [stopTarget, setStopTarget] = useState<string | null>(null);
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
getFlows();
|
||||
}, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [getFlows]);
|
||||
|
||||
// Also refresh on window focus
|
||||
useEffect(() => {
|
||||
const handler = () => getFlows();
|
||||
window.addEventListener("focus", handler);
|
||||
return () => window.removeEventListener("focus", handler);
|
||||
}, [getFlows]);
|
||||
|
||||
const handleStart = async (
|
||||
id: string,
|
||||
blueprint: string,
|
||||
description: string,
|
||||
params: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await startFlow(id, blueprint, description, params);
|
||||
notify.success("Flow started", `Flow "${id}" has been started.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to start flow",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
throw err; // re-throw so dialog stays open
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!stopTarget) return;
|
||||
try {
|
||||
await stopFlow(stopTarget);
|
||||
notify.success("Flow stopped", `Flow "${stopTarget}" has been stopped.`);
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Failed to stop flow",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
setStopTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Workflow className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Flows</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
|
||||
{flows.length} active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => getFlows()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Start Flow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading && flows.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading flows...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && flows.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<Workflow className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No flows configured.</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">
|
||||
Click "Start Flow" to create one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flows.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border bg-surface-100 text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium">Description</th>
|
||||
<th className="px-4 py-3 font-medium">Status</th>
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{flows.map((flow) => (
|
||||
<FlowRow
|
||||
key={flow.id}
|
||||
flow={flow}
|
||||
onStop={(id) => setStopTarget(id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<StartFlowDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
|
||||
<StopFlowDialog
|
||||
open={stopTarget != null}
|
||||
flowId={stopTarget ?? ""}
|
||||
onClose={() => setStopTarget(null)}
|
||||
onConfirm={handleStop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
586
ts/packages/workbench/src/pages/graph.tsx
Normal file
586
ts/packages/workbench/src/pages/graph.tsx
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Rotate3d,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
Loader2,
|
||||
X,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Triple, Term } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D to keep bundle size down
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// react-force-graph-2d ships a default export
|
||||
import ForceGraph2D, {
|
||||
type ForceGraphMethods,
|
||||
type NodeObject,
|
||||
type LinkObject,
|
||||
} from "react-force-graph-2d";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GraphNode extends NodeObject {
|
||||
id: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
/** Number of connections (used for sizing) */
|
||||
degree: number;
|
||||
}
|
||||
|
||||
interface GraphLink extends LinkObject {
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers -- Term value extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
|
||||
function termValue(t: Term): string {
|
||||
switch (t.t) {
|
||||
case "i":
|
||||
return t.i;
|
||||
case "l":
|
||||
return t.v;
|
||||
case "b":
|
||||
return t.d;
|
||||
case "t":
|
||||
return "[triple]";
|
||||
}
|
||||
}
|
||||
|
||||
function isIri(t: Term): boolean {
|
||||
return t.t === "i";
|
||||
}
|
||||
|
||||
/** Extract the local name from a URI for display */
|
||||
function localName(uri: string): string {
|
||||
const hash = uri.lastIndexOf("#");
|
||||
const slash = uri.lastIndexOf("/");
|
||||
const idx = Math.max(hash, slash);
|
||||
if (idx >= 0 && idx < uri.length - 1) return uri.substring(idx + 1);
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** Deterministic color from a string (for node types) */
|
||||
function hashColor(s: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
hash = s.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = ((hash % 360) + 360) % 360;
|
||||
return `hsl(${hue}, 60%, 55%)`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build graph data from triples
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function triplesToGraph(triples: Triple[]): {
|
||||
data: GraphData;
|
||||
labelMap: Map<string, string>;
|
||||
typeMap: Map<string, string>;
|
||||
} {
|
||||
const labelMap = new Map<string, string>();
|
||||
const typeMap = new Map<string, string>();
|
||||
|
||||
// First pass: collect labels and types
|
||||
for (const t of triples) {
|
||||
const pred = termValue(t.p);
|
||||
if (pred === RDFS_LABEL && t.o.t === "l") {
|
||||
labelMap.set(termValue(t.s), t.o.v);
|
||||
}
|
||||
if (pred === RDF_TYPE && isIri(t.o)) {
|
||||
typeMap.set(termValue(t.s), termValue(t.o));
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build nodes and links (skip structural triples)
|
||||
const nodeMap = new Map<string, GraphNode>();
|
||||
const links: GraphLink[] = [];
|
||||
|
||||
const ensureNode = (uri: string): void => {
|
||||
if (!nodeMap.has(uri)) {
|
||||
const type = typeMap.get(uri);
|
||||
nodeMap.set(uri, {
|
||||
id: uri,
|
||||
label: labelMap.get(uri) ?? localName(uri),
|
||||
color: type ? hashColor(localName(type)) : "#5b80ff",
|
||||
degree: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of triples) {
|
||||
const sVal = termValue(t.s);
|
||||
const pVal = termValue(t.p);
|
||||
const oVal = termValue(t.o);
|
||||
|
||||
// Skip label and type predicates -- they are metadata, not graph edges
|
||||
if (pVal === RDFS_LABEL) continue;
|
||||
if (pVal === RDF_TYPE) continue;
|
||||
|
||||
// Only build edges when both endpoints are IRIs (entity-to-entity)
|
||||
if (!isIri(t.s) || !isIri(t.o)) continue;
|
||||
|
||||
ensureNode(sVal);
|
||||
ensureNode(oVal);
|
||||
nodeMap.get(sVal)!.degree++;
|
||||
nodeMap.get(oVal)!.degree++;
|
||||
|
||||
links.push({
|
||||
source: sVal,
|
||||
target: oVal,
|
||||
label: labelMap.get(pVal) ?? localName(pVal),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: { nodes: Array.from(nodeMap.values()), links },
|
||||
labelMap,
|
||||
typeMap,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NodeDetailPanel({
|
||||
nodeId,
|
||||
label,
|
||||
triples,
|
||||
labelMap,
|
||||
onClose,
|
||||
}: {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
triples: Triple[];
|
||||
labelMap: Map<string, string>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Find triples where this node is subject or object
|
||||
const related = useMemo(() => {
|
||||
const outbound: { predicate: string; object: string; objectLabel: string }[] = [];
|
||||
const inbound: { predicate: string; subject: string; subjectLabel: string }[] = [];
|
||||
|
||||
for (const t of triples) {
|
||||
const sVal = termValue(t.s);
|
||||
const pVal = termValue(t.p);
|
||||
const oVal = termValue(t.o);
|
||||
|
||||
if (pVal === RDFS_LABEL || pVal === RDF_TYPE) continue;
|
||||
|
||||
if (sVal === nodeId) {
|
||||
outbound.push({
|
||||
predicate: labelMap.get(pVal) ?? localName(pVal),
|
||||
object: oVal,
|
||||
objectLabel: labelMap.get(oVal) ?? localName(oVal),
|
||||
});
|
||||
}
|
||||
if (oVal === nodeId) {
|
||||
inbound.push({
|
||||
predicate: labelMap.get(pVal) ?? localName(pVal),
|
||||
subject: sVal,
|
||||
subjectLabel: labelMap.get(sVal) ?? localName(sVal),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { outbound, inbound };
|
||||
}, [nodeId, triples, labelMap]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-surface-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="truncate text-sm font-semibold text-fg">{label}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-fg-subtle hover:bg-surface-200 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<p className="mb-3 truncate font-mono text-[10px] text-fg-subtle">
|
||||
{nodeId}
|
||||
</p>
|
||||
|
||||
{/* Outbound relationships */}
|
||||
{related.outbound.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
Outbound ({related.outbound.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{related.outbound.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<Badge variant="default">{r.predicate}</Badge>
|
||||
<span className="truncate text-fg-muted">{r.objectLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inbound relationships */}
|
||||
{related.inbound.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-fg-muted">
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Inbound ({related.inbound.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{related.inbound.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded bg-surface-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span className="truncate text-fg-muted">{r.subjectLabel}</span>
|
||||
<Badge variant="default">{r.predicate}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{related.outbound.length === 0 && related.inbound.length === 0 && (
|
||||
<p className="text-xs text-fg-subtle">No relationships found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function GraphPage() {
|
||||
const socket = useSocket();
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const addActivity = useProgressStore((s) => s.addActivity);
|
||||
const removeActivity = useProgressStore((s) => s.removeActivity);
|
||||
|
||||
const [triples, setTriples] = useState<Triple[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Fetch triples
|
||||
const fetchTriples = useCallback(async () => {
|
||||
const act = "Load graph";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
const result = await flow.triplesQuery(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
2000,
|
||||
collection,
|
||||
);
|
||||
setTriples(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, flowId, collection, addActivity, removeActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriples();
|
||||
}, [fetchTriples]);
|
||||
|
||||
// Build graph
|
||||
const { data: graphData, labelMap } = useMemo(
|
||||
() => triplesToGraph(triples),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Search filter -- highlight matching nodes
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchingIds = useMemo(() => {
|
||||
if (!searchLower) return new Set<string>();
|
||||
return new Set(
|
||||
graphData.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
n.label.toLowerCase().includes(searchLower) ||
|
||||
n.id.toLowerCase().includes(searchLower),
|
||||
)
|
||||
.map((n) => n.id),
|
||||
);
|
||||
}, [graphData.nodes, searchLower]);
|
||||
|
||||
const selectedLabel = selectedNode
|
||||
? labelMap.get(selectedNode) ?? localName(selectedNode)
|
||||
: "";
|
||||
|
||||
// Zoom helpers
|
||||
const zoomIn = () => fgRef.current?.zoom(2, 300);
|
||||
const zoomOut = () => fgRef.current?.zoom(0.5, 300);
|
||||
const zoomFit = () =>
|
||||
fgRef.current?.zoomToFit(400, 40);
|
||||
|
||||
// Node paint callback
|
||||
const paintNode = useCallback(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const isSelected = node.id === selectedNode;
|
||||
const isMatch = matchingIds.size > 0 && matchingIds.has(node.id);
|
||||
const dim = matchingIds.size > 0 && !isMatch && !isSelected;
|
||||
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
|
||||
// Node circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = dim
|
||||
? "rgba(100,100,100,0.3)"
|
||||
: isSelected
|
||||
? "#fbbf24"
|
||||
: isMatch
|
||||
? "#22c55e"
|
||||
: node.color ?? "#5b80ff";
|
||||
ctx.fill();
|
||||
|
||||
if (isSelected || isMatch) {
|
||||
ctx.strokeStyle = isSelected ? "#fbbf24" : "#22c55e";
|
||||
ctx.lineWidth = 1.5 / globalScale;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Label
|
||||
const fontSize = Math.max(10 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillStyle = dim ? "rgba(100,100,100,0.3)" : "rgba(250,250,250,0.9)";
|
||||
ctx.fillText(node.label, x, y + radius + 1);
|
||||
},
|
||||
[selectedNode, matchingIds],
|
||||
);
|
||||
|
||||
// Link label painting
|
||||
const paintLink = useCallback(
|
||||
(link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (globalScale < 1.5) return; // only show labels when zoomed in enough
|
||||
|
||||
const src = link.source as unknown as GraphNode;
|
||||
const tgt = link.target as unknown as GraphNode;
|
||||
if (!src.x || !tgt.x) return;
|
||||
|
||||
const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2;
|
||||
const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2;
|
||||
|
||||
const fontSize = Math.max(8 / globalScale, 1.5);
|
||||
ctx.font = `${fontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(161,161,170,0.7)";
|
||||
ctx.fillText(link.label, midX, midY);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Rotate3d className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Graph</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} edges
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-subtle" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search nodes..."
|
||||
className="w-48 rounded-lg border border-border bg-surface-100 py-1.5 pl-8 pr-3 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex rounded-lg border border-border bg-surface-100">
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
className="px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
className="border-l border-r border-border px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomFit}
|
||||
className="px-2 py-1.5 text-fg-muted hover:text-fg"
|
||||
title="Fit to view"
|
||||
>
|
||||
<Maximize className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Rotate3d className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && triples.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading graph data...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && graphData.nodes.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-border">
|
||||
<div className="text-center">
|
||||
<Rotate3d className="mx-auto mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">No graph data in this collection.</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">
|
||||
Upload documents and process them to populate the knowledge graph.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graphData.nodes.length > 0 && (
|
||||
<div className="flex flex-1 overflow-hidden rounded-lg border border-border">
|
||||
{/* Graph canvas */}
|
||||
<div className="relative flex-1 bg-surface-0">
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
graphData={graphData}
|
||||
nodeCanvasObject={paintNode}
|
||||
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
|
||||
const radius = Math.max(3, Math.sqrt(node.degree + 1) * 2.5);
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, radius + 2, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}}
|
||||
linkCanvasObjectMode={() => "after"}
|
||||
linkCanvasObject={paintLink}
|
||||
linkColor={() => "rgba(91,128,255,0.25)"}
|
||||
linkDirectionalArrowLength={4}
|
||||
linkDirectionalArrowRelPos={0.85}
|
||||
onNodeClick={(node: GraphNode) => {
|
||||
setSelectedNode((prev) =>
|
||||
prev === node.id ? null : node.id,
|
||||
);
|
||||
}}
|
||||
onBackgroundClick={() => setSelectedNode(null)}
|
||||
backgroundColor="transparent"
|
||||
width={undefined}
|
||||
height={undefined}
|
||||
/>
|
||||
|
||||
{/* Search results badge overlay */}
|
||||
{searchTerm && matchingIds.size > 0 && (
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<Badge variant="success">
|
||||
{matchingIds.size} match{matchingIds.size > 1 ? "es" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedNode && (
|
||||
<NodeDetailPanel
|
||||
nodeId={selectedNode}
|
||||
label={selectedLabel}
|
||||
triples={triples}
|
||||
labelMap={labelMap}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
486
ts/packages/workbench/src/pages/library.tsx
Normal file
486
ts/packages/workbench/src/pages/library.tsx
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
LibraryBig,
|
||||
Upload,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
FileType2,
|
||||
Loader2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLibrary } from "@/hooks/use-library";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DocumentMetadata } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UploadDialog({
|
||||
open,
|
||||
onClose,
|
||||
onUpload,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onUpload: (
|
||||
data: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [comments, setComments] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setComments("");
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleFile = (f: File) => {
|
||||
setFile(f);
|
||||
if (!title) setTitle(f.name.replace(/\.[^/.]+$/, ""));
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) handleFile(f);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const tagList = tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
await onUpload(base64, file.type || "application/octet-stream", title, comments, tagList);
|
||||
reset();
|
||||
onClose();
|
||||
} catch {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!uploading) {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Upload Document"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!file || !title.trim() || uploading}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
{uploading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Upload
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(
|
||||
"mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-8 transition-colors",
|
||||
dragOver
|
||||
? "border-brand-500 bg-brand-500/10"
|
||||
: "border-border hover:border-border-hover",
|
||||
)}
|
||||
>
|
||||
<Upload className="mb-2 h-8 w-8 text-fg-subtle" />
|
||||
{file ? (
|
||||
<div className="flex items-center gap-2 text-sm text-fg">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>{file.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFile(null);
|
||||
}}
|
||||
className="ml-1 text-fg-subtle hover:text-fg"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-fg-muted">
|
||||
Drop a file here or click to browse
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-fg-subtle">PDF, TXT, or other text formats</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.txt,.md,.csv,.json,.xml,.html"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Document title"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">Comments</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Optional comments"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="Comma-separated tags"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confirm delete dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConfirmDeleteDialog({
|
||||
open,
|
||||
docTitle,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
docTitle: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Delete Document"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-fg-muted hover:bg-surface-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="rounded-lg bg-error px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-error" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-medium text-fg">{docTitle || "this document"}</span>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function LibraryPage() {
|
||||
const {
|
||||
documents,
|
||||
processing,
|
||||
loading,
|
||||
error,
|
||||
getDocuments,
|
||||
uploadDocument,
|
||||
removeDocument,
|
||||
getProcessing,
|
||||
} = useLibrary();
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const notify = useNotification();
|
||||
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentMetadata | null>(null);
|
||||
|
||||
// Load documents and processing on mount
|
||||
useEffect(() => {
|
||||
getDocuments();
|
||||
getProcessing();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleUpload = async (
|
||||
data: string,
|
||||
mimeType: string,
|
||||
title: string,
|
||||
comments: string,
|
||||
tags: string[],
|
||||
) => {
|
||||
try {
|
||||
await uploadDocument(data, mimeType, title, comments, tags);
|
||||
notify.success("Document uploaded", `"${title}" is being processed.`);
|
||||
getProcessing();
|
||||
} catch {
|
||||
notify.error("Upload failed", "Could not upload the document.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget?.id) return;
|
||||
try {
|
||||
await removeDocument(deleteTarget.id, collection);
|
||||
notify.success("Document deleted");
|
||||
} catch {
|
||||
notify.error("Delete failed");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
getDocuments();
|
||||
getProcessing();
|
||||
};
|
||||
|
||||
const guessKind = (doc: DocumentMetadata): string => {
|
||||
const kind = doc.kind ?? doc["document-type"] ?? "";
|
||||
if (kind.includes("pdf")) return "PDF";
|
||||
if (kind.includes("text") || kind.includes("plain")) return "Text";
|
||||
if (kind.includes("html")) return "HTML";
|
||||
if (kind.includes("json")) return "JSON";
|
||||
return kind || "--";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<LibraryBig className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Library</h1>
|
||||
<span className="ml-2 rounded bg-surface-200 px-2 py-0.5 text-xs text-fg-subtle">
|
||||
{collection}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-fg-muted transition-colors hover:bg-surface-200 disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUploadOpen(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processing status */}
|
||||
{processing.length > 0 && (
|
||||
<div className="mb-4 rounded-lg border border-brand-500/30 bg-brand-500/5 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-brand-300">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Processing ({processing.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{processing.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<FileType2 className="h-3 w-3" />
|
||||
<span className="truncate">{p["document-id"] || p.id}</span>
|
||||
<Badge variant="info" className="ml-auto">
|
||||
{p.flow || "processing"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{loading && documents.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin text-fg-subtle" />
|
||||
<span className="text-fg-subtle">Loading documents...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="py-8 text-center text-error">Error: {error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && documents.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<LibraryBig className="mb-3 h-10 w-10 text-fg-subtle opacity-30" />
|
||||
<p className="text-fg-subtle">
|
||||
No documents yet. Upload one to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{documents.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-border bg-surface-100 text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Title</th>
|
||||
<th className="px-4 py-3 font-medium">Type</th>
|
||||
<th className="px-4 py-3 font-medium">Tags</th>
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.id} className="hover:bg-surface-100/50">
|
||||
<td className="px-4 py-3 text-fg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-fg-subtle" />
|
||||
{doc.title || "Untitled"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="default">{guessKind(doc)}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(doc.tags ?? []).map((tag) => (
|
||||
<Badge key={tag} variant="info">{tag}</Badge>
|
||||
))}
|
||||
{(!doc.tags || doc.tags.length === 0) && (
|
||||
<span className="text-fg-subtle">--</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="max-w-[12rem] truncate px-4 py-3 font-mono text-xs text-fg-subtle">
|
||||
{doc.id}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setDeleteTarget(doc)}
|
||||
className="rounded p-1.5 text-fg-subtle hover:bg-error/10 hover:text-error"
|
||||
title="Delete document"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<UploadDialog
|
||||
open={uploadOpen}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteTarget != null}
|
||||
docTitle={deleteTarget?.title ?? deleteTarget?.id ?? ""}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// Strip the data URL prefix (e.g. "data:application/pdf;base64,")
|
||||
const base64 = result.includes(",") ? result.split(",")[1]! : result;
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
340
ts/packages/workbench/src/pages/settings.tsx
Normal file
340
ts/packages/workbench/src/pages/settings.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Key,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Database,
|
||||
Workflow,
|
||||
Info,
|
||||
Loader2,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
import { useConnectionState } from "@/providers/socket-provider";
|
||||
import { useFlows } from "@/hooks/use-flows";
|
||||
import { useSessionStore } from "@/hooks/use-session-store";
|
||||
import { useNotification } from "@/providers/notification-provider";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Section({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-50 p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-fg">
|
||||
{icon}
|
||||
{title}
|
||||
</h2>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const connectionState = useConnectionState();
|
||||
const socket = useSocket();
|
||||
const { flows } = useFlows();
|
||||
const notify = useNotification();
|
||||
|
||||
const flowId = useSessionStore((s) => s.flowId);
|
||||
const setFlowId = useSessionStore((s) => s.setFlowId);
|
||||
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [collections, setCollections] = useState<
|
||||
Array<{ id?: string; name?: string; [key: string]: unknown }>
|
||||
>([]);
|
||||
const [loadingCollections, setLoadingCollections] = useState(false);
|
||||
|
||||
// Dark mode toggle -- uses a class on <html> and persists to localStorage
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
return !document.documentElement.classList.contains("light");
|
||||
});
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const next = !isDark;
|
||||
setIsDark(next);
|
||||
if (next) {
|
||||
document.documentElement.classList.remove("light");
|
||||
localStorage.setItem("tg-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.add("light");
|
||||
localStorage.setItem("tg-theme", "light");
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
// Fetch collections
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingCollections(true);
|
||||
socket
|
||||
.collectionManagement()
|
||||
.listCollections()
|
||||
.then((cols) => {
|
||||
if (!cancelled) {
|
||||
setCollections(
|
||||
Array.isArray(cols)
|
||||
? (cols as Array<{ id?: string; name?: string; [key: string]: unknown }>)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* silent -- collections endpoint may not be available */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingCollections(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
// Connection status helpers
|
||||
const isConnected =
|
||||
connectionState.status === "connected" ||
|
||||
connectionState.status === "authenticated" ||
|
||||
connectionState.status === "unauthenticated";
|
||||
|
||||
const statusBadge = isConnected ? (
|
||||
<Badge variant="success">
|
||||
<Wifi className="h-3 w-3" /> {connectionState.status}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="error">
|
||||
<WifiOff className="h-3 w-3" /> {connectionState.status}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<SettingsIcon className="h-6 w-6 text-brand-400" />
|
||||
<h1 className="text-2xl font-bold text-fg">Settings</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="max-w-2xl space-y-5">
|
||||
{/* Connection */}
|
||||
<Section
|
||||
title="Connection"
|
||||
icon={<Wifi className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-fg-muted">Status:</span>
|
||||
{statusBadge}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Gateway URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.gatewayUrl}
|
||||
onChange={(e) => updateSetting("gatewayUrl", e.target.value)}
|
||||
placeholder="Leave blank to use the default proxy"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
The WebSocket URL for the TrustGraph gateway.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
User ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.user}
|
||||
onChange={(e) => updateSetting("user", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Authentication */}
|
||||
<Section
|
||||
title="Authentication"
|
||||
icon={<Key className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => updateSetting("apiKey", e.target.value)}
|
||||
placeholder="Leave blank for unauthenticated access"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 pr-10 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((p) => !p)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Changing the API key will reconnect the WebSocket.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Collection */}
|
||||
<Section
|
||||
title="Collection"
|
||||
icon={<Database className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Active Collection
|
||||
</label>
|
||||
{loadingCollections ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-fg-subtle">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Loading
|
||||
collections...
|
||||
</div>
|
||||
) : collections.length > 0 ? (
|
||||
<select
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
{collections.map((c) => {
|
||||
const id = c.id ?? String(c.name ?? c);
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{c.name ?? id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={settings.collection}
|
||||
onChange={(e) => updateSetting("collection", e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Flow */}
|
||||
<Section
|
||||
title="Active Flow"
|
||||
icon={<Workflow className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-fg-muted">
|
||||
Flow
|
||||
</label>
|
||||
{flows.length > 0 ? (
|
||||
<select
|
||||
value={flowId}
|
||||
onChange={(e) => setFlowId(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="default">default</option>
|
||||
{flows.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.id}
|
||||
{f.description ? ` -- ${f.description}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={flowId}
|
||||
onChange={(e) => setFlowId(e.target.value)}
|
||||
placeholder="default"
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-4 py-2 text-sm text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-fg-subtle">
|
||||
The flow ID used for chat, graph queries, and document processing.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Theme */}
|
||||
<Section
|
||||
title="Appearance"
|
||||
icon={isDark ? <Moon className="h-4 w-4 text-fg-subtle" /> : <Sun className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-fg">Theme</p>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Toggle between dark and light mode.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isDark ? "bg-brand-600" : "bg-surface-300",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||
isDark ? "translate-x-6" : "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* About */}
|
||||
<Section
|
||||
title="About"
|
||||
icon={<Info className="h-4 w-4 text-fg-subtle" />}
|
||||
>
|
||||
<div className="space-y-2 text-sm text-fg-muted">
|
||||
<p>
|
||||
<span className="font-medium text-fg">TrustGraph Workbench</span>{" "}
|
||||
v0.1.0
|
||||
</p>
|
||||
<p>
|
||||
A web-based interface for interacting with the TrustGraph
|
||||
knowledge-graph system.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NotificationType = "success" | "error" | "warning" | "info";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[];
|
||||
|
||||
addNotification: (
|
||||
type: NotificationType,
|
||||
title: string,
|
||||
description?: string,
|
||||
) => string;
|
||||
|
||||
removeNotification: (id: string) => void;
|
||||
|
||||
/** Convenience wrappers */
|
||||
success: (title: string, description?: string) => string;
|
||||
error: (title: string, description?: string) => string;
|
||||
warning: (title: string, description?: string) => string;
|
||||
info: (title: string, description?: string) => string;
|
||||
}
|
||||
|
||||
let _nextId = 0;
|
||||
function nextId(): string {
|
||||
return `notif-${++_nextId}-${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple toast-notification system backed by Zustand.
|
||||
*
|
||||
* Components can call `useNotification().success("Done!")` and render the
|
||||
* current `notifications` array however they like (e.g. a shadcn Toast list).
|
||||
*
|
||||
* Notifications are auto-dismissed after 5 seconds.
|
||||
*/
|
||||
export const useNotification = create<NotificationState>()((set, get) => {
|
||||
const AUTO_DISMISS_MS = 5_000;
|
||||
|
||||
const addNotification: NotificationState["addNotification"] = (
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
) => {
|
||||
const id = nextId();
|
||||
const notification: Notification = { id, type, title, description };
|
||||
|
||||
set((state) => ({
|
||||
notifications: [...state.notifications, notification],
|
||||
}));
|
||||
|
||||
// Auto-dismiss
|
||||
setTimeout(() => {
|
||||
get().removeNotification(id);
|
||||
}, AUTO_DISMISS_MS);
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
return {
|
||||
notifications: [],
|
||||
|
||||
addNotification,
|
||||
|
||||
removeNotification: (id) =>
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
})),
|
||||
|
||||
success: (title, description) =>
|
||||
addNotification("success", title, description),
|
||||
error: (title, description) =>
|
||||
addNotification("error", title, description),
|
||||
warning: (title, description) =>
|
||||
addNotification("warning", title, description),
|
||||
info: (title, description) => addNotification("info", title, description),
|
||||
};
|
||||
});
|
||||
110
ts/packages/workbench/src/providers/settings-provider.tsx
Normal file
110
ts/packages/workbench/src/providers/settings-provider.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FeatureSwitches {
|
||||
flowClasses: boolean;
|
||||
submissions: boolean;
|
||||
tokenCost: boolean;
|
||||
schemas: boolean;
|
||||
structuredQuery: boolean;
|
||||
ontologyEditor: boolean;
|
||||
agentTools: boolean;
|
||||
mcpTools: boolean;
|
||||
llmModels: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
/** Display name / identifier sent with every request */
|
||||
user: string;
|
||||
/** Optional API key for gateway authentication */
|
||||
apiKey: string;
|
||||
/** Active knowledge-graph collection */
|
||||
collection: string;
|
||||
/** Gateway base URL (used when building the WebSocket URL) */
|
||||
gatewayUrl: string;
|
||||
/** Toggle optional sections of the UI */
|
||||
featureSwitches: FeatureSwitches;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_FEATURE_SWITCHES: FeatureSwitches = {
|
||||
flowClasses: false,
|
||||
submissions: false,
|
||||
tokenCost: false,
|
||||
schemas: false,
|
||||
structuredQuery: false,
|
||||
ontologyEditor: false,
|
||||
agentTools: false,
|
||||
mcpTools: false,
|
||||
llmModels: false,
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
user: "user",
|
||||
apiKey: "",
|
||||
collection: "default",
|
||||
gatewayUrl: "",
|
||||
featureSwitches: DEFAULT_FEATURE_SWITCHES,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SettingsState {
|
||||
settings: Settings;
|
||||
isLoaded: boolean;
|
||||
|
||||
/** Replace the entire settings object */
|
||||
setSettings: (settings: Settings) => void;
|
||||
|
||||
/** Update a single top-level key */
|
||||
updateSetting: <K extends keyof Settings>(
|
||||
key: K,
|
||||
value: Settings[K],
|
||||
) => void;
|
||||
|
||||
/** Merge partial feature-switch overrides */
|
||||
updateFeatureSwitches: (partial: Partial<FeatureSwitches>) => void;
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
settings: DEFAULT_SETTINGS,
|
||||
isLoaded: true,
|
||||
|
||||
setSettings: (settings) => set({ settings }),
|
||||
|
||||
updateSetting: (key, value) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, [key]: value },
|
||||
})),
|
||||
|
||||
updateFeatureSwitches: (partial) =>
|
||||
set((state) => ({
|
||||
settings: {
|
||||
...state.settings,
|
||||
featureSwitches: {
|
||||
...state.settings.featureSwitches,
|
||||
...partial,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "trustgraph-settings",
|
||||
// Mark loaded once rehydration completes
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) state.isLoaded = true;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
125
ts/packages/workbench/src/providers/socket-provider.tsx
Normal file
125
ts/packages/workbench/src/providers/socket-provider.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { BaseApi, type ConnectionState } from "@trustgraph/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SocketContextValue {
|
||||
api: BaseApi;
|
||||
}
|
||||
|
||||
const SocketContext = createContext<SocketContextValue | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SocketProviderProps {
|
||||
/** Username sent with every API request */
|
||||
user: string;
|
||||
/** Optional API key for authenticated connections */
|
||||
apiKey?: string;
|
||||
/** WebSocket URL (defaults to "/api/socket", proxied by Vite in dev) */
|
||||
socketUrl?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* SocketProvider creates a single BaseApi instance that lives for the
|
||||
* lifetime of the provider and tears down the WebSocket on unmount.
|
||||
*
|
||||
* The BaseApi is recreated if `user`, `apiKey`, or `socketUrl` change.
|
||||
*/
|
||||
export function SocketProvider({
|
||||
user,
|
||||
apiKey,
|
||||
socketUrl,
|
||||
children,
|
||||
}: SocketProviderProps) {
|
||||
const apiRef = useRef<BaseApi | null>(null);
|
||||
|
||||
// Re-create the API instance when connection parameters change.
|
||||
// We track a serial number so downstream consumers re-render.
|
||||
const [serial, setSerial] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Close the previous socket if it exists
|
||||
apiRef.current?.close();
|
||||
|
||||
const api = new BaseApi(user, apiKey, socketUrl);
|
||||
apiRef.current = api;
|
||||
setSerial((s) => s + 1);
|
||||
|
||||
return () => {
|
||||
api.close();
|
||||
if (apiRef.current === api) {
|
||||
apiRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [user, apiKey, socketUrl]);
|
||||
|
||||
// Don't render children until the first API instance is ready
|
||||
if (!apiRef.current) return null;
|
||||
|
||||
return (
|
||||
<SocketContext.Provider
|
||||
// eslint-disable-next-line react/no-children-prop
|
||||
key={serial}
|
||||
value={{ api: apiRef.current }}
|
||||
>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the shared BaseApi instance.
|
||||
*
|
||||
* Must be called inside a `<SocketProvider>`.
|
||||
*/
|
||||
export function useSocket(): BaseApi {
|
||||
const ctx = useContext(SocketContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useSocket must be used within a <SocketProvider>");
|
||||
}
|
||||
return ctx.api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to connection-state changes emitted by BaseApi.
|
||||
*
|
||||
* Uses `useSyncExternalStore` for tear-free reads.
|
||||
*/
|
||||
export function useConnectionState(): ConnectionState {
|
||||
const api = useSocket();
|
||||
|
||||
// We store the latest snapshot in a ref so the getSnapshot function is stable.
|
||||
const stateRef = useRef<ConnectionState>({
|
||||
status: "connecting",
|
||||
hasApiKey: false,
|
||||
});
|
||||
|
||||
const subscribe = (onStoreChange: () => void) => {
|
||||
return api.onConnectionStateChange((next) => {
|
||||
stateRef.current = next;
|
||||
onStoreChange();
|
||||
});
|
||||
};
|
||||
|
||||
const getSnapshot = () => stateRef.current;
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
18
ts/packages/workbench/tsconfig.json
Normal file
18
ts/packages/workbench/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"noEmit": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src", "vite-env.d.ts"]
|
||||
}
|
||||
1
ts/packages/workbench/vite-env.d.ts
vendored
Normal file
1
ts/packages/workbench/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
25
ts/packages/workbench/vite.config.ts
Normal file
25
ts/packages/workbench/vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api/socket": {
|
||||
target: "ws://localhost:8088/",
|
||||
ws: true,
|
||||
rewrite: (p) => p.replace("/api/socket", "/api/v1/socket"),
|
||||
},
|
||||
"/api/v1": {
|
||||
target: "http://localhost:8088/",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
4632
ts/pnpm-lock.yaml
generated
Normal file
4632
ts/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@
|
|||
},
|
||||
"references": [
|
||||
{ "path": "packages/base" },
|
||||
{ "path": "packages/client" },
|
||||
{ "path": "packages/flow" },
|
||||
{ "path": "packages/cli" },
|
||||
{ "path": "packages/mcp" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue